Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1496b7afeb | ||
|
|
1d224218df | ||
|
|
17de4f6376 | ||
|
|
212f35d6bb | ||
|
|
018395d0c5 | ||
|
|
c0f06d095e | ||
|
|
01f0540726 |
@@ -1,173 +0,0 @@
|
|||||||
name: Build and Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build ${{ matrix.platform }}
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: windows-x64
|
|
||||||
os: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
- platform: windows-arm64
|
|
||||||
os: windows-11-arm
|
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
- platform: macos-x64
|
|
||||||
os: macos-15-intel
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
- platform: macos-arm64
|
|
||||||
os: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
- platform: linux-x64
|
|
||||||
os: ubuntu-latest
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
- platform: linux-arm64
|
|
||||||
os: ubuntu-24.04-arm
|
|
||||||
target: aarch64-unknown-linux-gnu
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
# Windows and macOS builds using cargo-packager
|
|
||||||
- name: Build with cargo-packager (Windows/macOS)
|
|
||||||
if: runner.os != 'Linux'
|
|
||||||
working-directory: desktop
|
|
||||||
run: |
|
|
||||||
cargo install cargo-packager --locked
|
|
||||||
cargo packager --release
|
|
||||||
|
|
||||||
- name: Upload Windows/macOS artifacts
|
|
||||||
if: runner.os != 'Linux'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.platform }}-artifacts
|
|
||||||
path: |
|
|
||||||
dist/*.dmg
|
|
||||||
dist/*.msi
|
|
||||||
dist/*.exe
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
# Linux builds using custom scripts
|
|
||||||
- name: Install Linux build dependencies
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y flatpak flatpak-builder snapd squashfs-tools jq gettext-base
|
|
||||||
|
|
||||||
- name: Install Snapcraft
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: sudo snap install snapcraft --classic
|
|
||||||
|
|
||||||
- name: Make scripts executable
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
chmod +x script/get-crate-version
|
|
||||||
chmod +x script/linux
|
|
||||||
chmod +x script/bundle-snap
|
|
||||||
chmod +x script/bundle-linux
|
|
||||||
chmod +x script/flatpak/deps
|
|
||||||
chmod +x script/flatpak/bundle-flatpak
|
|
||||||
|
|
||||||
- name: Install required dependencies
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: ./script/linux
|
|
||||||
|
|
||||||
# Build the release tarball on every Linux architecture
|
|
||||||
- name: Build release tarball
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: ./script/bundle-linux
|
|
||||||
|
|
||||||
# Only build Flatpak and Snap for x86_64 (most common use case)
|
|
||||||
- name: Build Flatpak
|
|
||||||
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-gnu'
|
|
||||||
run: |
|
|
||||||
./script/flatpak/deps
|
|
||||||
./script/flatpak/bundle-flatpak
|
|
||||||
|
|
||||||
- name: Build Snap
|
|
||||||
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-gnu'
|
|
||||||
run: |
|
|
||||||
VERSION=$(script/get-crate-version signed)
|
|
||||||
./script/bundle-snap $VERSION
|
|
||||||
|
|
||||||
- name: Collect Linux artifacts
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
working-directory: ${{ github.workspace }}
|
|
||||||
run: |
|
|
||||||
mkdir -p linux-artifacts
|
|
||||||
# Copy the tarball created by bundle-linux
|
|
||||||
find target/release -name "*.tar.gz" -exec cp {} linux-artifacts/ \;
|
|
||||||
# Find and copy flatpak files (if they exist)
|
|
||||||
find . -name "*.flatpak" -exec cp {} linux-artifacts/ \; || true
|
|
||||||
# Find and copy snap files (if they exist)
|
|
||||||
find . -name "*.snap" -exec cp {} linux-artifacts/ \; || true
|
|
||||||
ls -la linux-artifacts/
|
|
||||||
|
|
||||||
- name: Upload Linux artifacts
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.platform }}-artifacts
|
|
||||||
path: linux-artifacts/**/*
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Create Release
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Make get-crate-version executable
|
|
||||||
run: chmod +x script/get-crate-version
|
|
||||||
|
|
||||||
- name: Get version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=$(script/get-crate-version signed)
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Display artifacts structure
|
|
||||||
run: |
|
|
||||||
echo "Artifacts structure:"
|
|
||||||
find artifacts -type f -exec ls -la {} \;
|
|
||||||
|
|
||||||
- name: Create draft release
|
|
||||||
id: create_release
|
|
||||||
uses: akkuman/gitea-release-action@v1
|
|
||||||
with:
|
|
||||||
server_url: "https://git.reya.info/"
|
|
||||||
repository: "reya/signed"
|
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
draft: true
|
|
||||||
prerelease: false
|
|
||||||
files: |
|
|
||||||
artifacts/**/*
|
|
||||||
|
|
||||||
- name: Output release info
|
|
||||||
run: |
|
|
||||||
echo "Created draft release: ${{ steps.create_release.outputs.url }}"
|
|
||||||
echo "Release ID: ${{ steps.create_release.outputs.id }}"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
name: Rust
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["m**"]
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
||||||
rustup: [stable]
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Linux build dependencies
|
|
||||||
run: chmod +x ./script/linux && ./script/linux
|
|
||||||
if: matrix.os == 'ubuntu-latest'
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: cargo build --verbose
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: cargo test --verbose
|
|
||||||
+1
-20
@@ -1,20 +1 @@
|
|||||||
# Generated by Cargo
|
/target
|
||||||
# will have compiled files and executables
|
|
||||||
debug/
|
|
||||||
target/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Snap and Flatpak local build output
|
|
||||||
/snap
|
|
||||||
/info.reya.signed.json
|
|
||||||
/linux-artifacts
|
|
||||||
|
|
||||||
# Vendored dependencies + cargo config generated by script/prepare-flathub
|
|
||||||
.cargo/
|
|
||||||
vendor/
|
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
||||||
*.pdb
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|||||||
@@ -1,162 +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.
|
|
||||||
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
|
||||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
|
||||||
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
|
|
||||||
* Do not wrap clients or SDK dependencies in project structs or pass-through helper functions, call their methods directly where needed. Extract a helper only when it holds real logic that is used in more than one place.
|
|
||||||
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `CheckoutsStore::global(cx)` inline.
|
|
||||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
|
||||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
|
||||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
|
||||||
- Propagate errors with `?` when the calling function should handle them
|
|
||||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
|
||||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
|
||||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
|
||||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
|
||||||
* Avoid creative additions unless explicitly requested
|
|
||||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
|
||||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
|
||||||
Example:
|
|
||||||
```rust
|
|
||||||
executor.spawn({
|
|
||||||
let task_ran = task_ran.clone();
|
|
||||||
async move {
|
|
||||||
*task_ran.borrow_mut() = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
# Timers in tests
|
|
||||||
|
|
||||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
|
||||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
|
||||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
|
||||||
|
|
||||||
# GPUI
|
|
||||||
|
|
||||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
|
||||||
|
|
||||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
|
||||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
|
||||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
|
||||||
|
|
||||||
## `Window`
|
|
||||||
|
|
||||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
|
||||||
|
|
||||||
## Entities
|
|
||||||
|
|
||||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
|
||||||
|
|
||||||
* `thing.entity_id()` returns `EntityId`
|
|
||||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
|
||||||
* `thing.read(cx: &App)` returns `&T`.
|
|
||||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
|
||||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
|
||||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
|
||||||
|
|
||||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
|
||||||
|
|
||||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
|
||||||
|
|
||||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
|
||||||
|
|
||||||
## Concurrency
|
|
||||||
|
|
||||||
All use of entities and UI rendering occurs on a single foreground thread.
|
|
||||||
|
|
||||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
|
||||||
|
|
||||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
|
||||||
|
|
||||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
|
||||||
|
|
||||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
|
||||||
|
|
||||||
* Awaiting the task in some other async context.
|
|
||||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
|
||||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
|
||||||
|
|
||||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
|
||||||
|
|
||||||
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
|
|
||||||
|
|
||||||
## Elements
|
|
||||||
|
|
||||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
struct TextWithBorder(SharedString);
|
|
||||||
|
|
||||||
impl Render for TextWithBorder {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
div().border_1().child(self.0.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
|
||||||
|
|
||||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
|
||||||
|
|
||||||
The style methods on elements are similar to those used by Tailwind CSS.
|
|
||||||
|
|
||||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
|
||||||
|
|
||||||
## Input events
|
|
||||||
|
|
||||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
|
||||||
|
|
||||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
|
||||||
|
|
||||||
## Actions
|
|
||||||
|
|
||||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
|
||||||
|
|
||||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
|
||||||
|
|
||||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
|
||||||
|
|
||||||
## Notify
|
|
||||||
|
|
||||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
|
||||||
|
|
||||||
GPUI also tracks the entities a window reads while rendering. When an entity notifies, every view that read that entity during the previous frame is rerendered automatically.
|
|
||||||
|
|
||||||
* A view that reads an entity in render (e.g. `state.read(cx).profile(...)`) does not need `cx.observe(&entity, ...)` to pick up its changes - the read itself is the dependency. Reserve `cx.observe` and `cx.subscribe` for when the view must run logic in response to a change (rebuild derived state, re-query, dedupe), not to trigger a re-render.
|
|
||||||
* Notify the narrowest entity holding the change. Notifying a global store rerenders every view that ever read it; notifying a per-panel entity rerenders only the panels reading it.
|
|
||||||
* This works with cached panels (`panel.cached(...)`): a cached subtree repaints when the view it belongs to is invalidated.
|
|
||||||
|
|
||||||
## Entity events
|
|
||||||
|
|
||||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
|
||||||
|
|
||||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
|
||||||
|
|
||||||
# Pull request hygiene
|
|
||||||
|
|
||||||
When an agent opens or updates a pull request, it must:
|
|
||||||
|
|
||||||
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
|
|
||||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
|
||||||
- Avoid trailing punctuation in PR titles.
|
|
||||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
|
|
||||||
- Include a `Release Notes:` section as the final section in the PR body.
|
|
||||||
- Use one bullet under `Release Notes:`:
|
|
||||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
|
||||||
- `- N/A` for docs-only and other non-user-facing changes.
|
|
||||||
- Format release notes exactly with a blank line after the heading, for example:
|
|
||||||
|
|
||||||
```
|
|
||||||
Release Notes:
|
|
||||||
|
|
||||||
- N/A
|
|
||||||
```
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## v0.2.0 - 2026/10/21
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME), applying the OS colors, fonts, radii and accessibility preferences.
|
|
||||||
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
|
|
||||||
- Add a dropdown menu on the signed-in user button in the sidebar
|
|
||||||
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
|
|
||||||
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
|
|
||||||
- Allow choosing the default branch when publishing a repository
|
|
||||||
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
|
|
||||||
- Add a step-by-step push checklist while publishing and syncing a repository
|
|
||||||
- Add a `Reset remote to local` action that republishes the mirror and deletes grasp refs the mirror does not have
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Use the built-in light/dark syntax highlighting palette instead of the Signed palette bundled in `signed.json` when the native theme is active
|
|
||||||
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
|
|
||||||
- Surface backend errors as error notifications in the workspace instead of silently dropping them
|
|
||||||
- Break ties in repository activity lists by event id, so same-second events order deterministically
|
|
||||||
- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils`
|
|
||||||
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
|
|
||||||
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
|
|
||||||
- Refresh the checkouts store only when the checkouts settings change
|
|
||||||
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
|
|
||||||
- Publish nostr events directly through the client with relay acceptance required, removing the grasp push helper and its per-server retry loop so failures surface immediately
|
|
||||||
- Poll the repository sync drift check every 180 seconds instead of 60
|
|
||||||
- Track sync status per view instead of globally
|
|
||||||
- Merge the `PixelAvatar` into the `Avatar` component. Seed user pixel avatars from the public key instead of the display name, so avatars stay stable when a user renames
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fix the repository view refreshing its mirror from the network while a push is in progress, so the refresh no longer races the push
|
|
||||||
- Fix opening a repository failing with a `destination already exists` error when two loads raced to clone its mirror, clones are now serialized and later loads reuse the finished clone
|
|
||||||
- Fix a repository never opening again after an interrupted clone left an unusable mirror directory behind, which is now removed and re-cloned
|
|
||||||
- Fix signing in with a `bunker://` URI silently generating a new identity instead of erroring when its master key cannot be parsed
|
|
||||||
- Fix repository sync failing with `state event in purgatory ... doesn't match push` when grasp servers hold branches or tags the local repository lacks
|
|
||||||
- Fix sync failing with `could not fetch refs/tags/...^{}` on repositories with annotated tags, where the peel entries `git ls-remote` reports for annotated tags were mistaken for missing refs
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme
|
|
||||||
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
|
|
||||||
|
|
||||||
## v0.2.0-alpha - 2026/09/27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
|
|
||||||
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
|
|
||||||
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
|
||||||
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
|
||||||
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
|
||||||
- Connect to bootstrap relays on demand instead of at startup
|
|
||||||
- Fetch profile metadata in batches of 100 authors, applying every requested profile in a single database query
|
|
||||||
- Prefetch the 500 most recent profiles at startup instead of 200
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
|
|
||||||
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
|
|
||||||
- Fix background tasks outliving their view, so a closed repository panel or pull request view stops fetching and publishing on its own
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
|
|
||||||
|
|
||||||
## v0.1.0-alpha - 2026/09/14
|
|
||||||
|
|
||||||
Initial alpha release.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Create and unlock a passphrase-protected Nostr identity
|
|
||||||
- Browse and search NIP-34 repositories with All, Popular and Recent filters
|
|
||||||
- Scan local Git repositories and detect their NIP-34 and GRASP bindings
|
|
||||||
- Create, publish, clone and open repositories
|
|
||||||
- Repository view with files, commit history, branches and tags
|
|
||||||
- Colour-coded commit diffs
|
|
||||||
- Issues and pull requests with discussions
|
|
||||||
- Send patches and push branches
|
|
||||||
- Inbox for repository activity
|
|
||||||
- Settings for appearance, theme, GRASP servers and repository scan paths
|
|
||||||
- Release and packaging infrastructure
|
|
||||||
Generated
+3402
-1497
File diff suppressed because it is too large
Load Diff
+19
-9
@@ -2,31 +2,37 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/*", "desktop"]
|
members = ["crates/*", "desktop"]
|
||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
exclude = ["crates/dock", "crates/signed_ui", "crates/workspace"]
|
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "1.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" }
|
# GPUI
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] }
|
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||||
reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" }
|
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-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "97106b7a3a54ec929c47feb70cdab4bed044d20e", features = ["layout", "editor"] }
|
# `tree-sitter-languages` enables syntax highlighting for the TextView
|
||||||
|
# code preview (fenced code blocks are highlighted with tree-sitter).
|
||||||
|
gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] }
|
||||||
|
gpui-base = { git = "https://github.com/longbridge/gpui-component" }
|
||||||
|
gpui-fps = { git = "https://github.com/longbridge/gpui-component" }
|
||||||
|
|
||||||
|
dock = { path = "crates/dock" }
|
||||||
settings = { path = "crates/settings" }
|
settings = { path = "crates/settings" }
|
||||||
signed_app = { path = "crates/signed_app" }
|
|
||||||
|
|
||||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
|
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
|
||||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||||
|
|
||||||
gix = { version = "0.88", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
|
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] }
|
||||||
|
|
||||||
smol = "2"
|
smol = "2"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
@@ -43,12 +49,16 @@ tracing = "0.1"
|
|||||||
errno = { version = "0.3.14", default-features = false }
|
errno = { version = "0.3.14", default-features = false }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# Use stacker's psm version which may have better WASM support
|
||||||
|
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
panic = "unwind"
|
panic = "abort"
|
||||||
|
|
||||||
[profile.profiling]
|
[profile.profiling]
|
||||||
inherits = "release"
|
inherits = "release"
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -6,7 +6,8 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-native-kit.workspace = true
|
gpui-component.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 258 B |
@@ -0,0 +1,433 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||||
|
"name": "Signed",
|
||||||
|
"author": "Signed",
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"is_default": true,
|
||||||
|
"name": "Signed Light",
|
||||||
|
"mode": "light",
|
||||||
|
"colors": {
|
||||||
|
"accent.background": "#F0F0F0",
|
||||||
|
"accent.foreground": "#202020",
|
||||||
|
"accordion.background": "#FFFFFF",
|
||||||
|
"background": "#FFFFFF",
|
||||||
|
"border": "#E4E4E7",
|
||||||
|
"caret": "#202020",
|
||||||
|
"chart.1": "#93C5FD",
|
||||||
|
"chart.2": "#3B82F6",
|
||||||
|
"chart.3": "#2563EB",
|
||||||
|
"chart.4": "#1D4ED8",
|
||||||
|
"chart.5": "#1E40AF",
|
||||||
|
"chart_bullish": "green-600",
|
||||||
|
"chart_bearish": "red-600",
|
||||||
|
"danger.background": "red-500",
|
||||||
|
"danger.foreground": "neutral-50",
|
||||||
|
"description_list.label.foreground": "#202020",
|
||||||
|
"drag.border": "#65A30D",
|
||||||
|
"drop_target.background": "#C6FF4D40",
|
||||||
|
"foreground": "#18181B",
|
||||||
|
"group_box.background": "#F0F0F0",
|
||||||
|
"group_box.foreground": "#202020",
|
||||||
|
"info.background": "cyan-500",
|
||||||
|
"info.foreground": "neutral-50",
|
||||||
|
"input.border": "#E4E4E7",
|
||||||
|
"link": "#3F6212",
|
||||||
|
"link.active": "#3F6212",
|
||||||
|
"link.hover": "#65A30D",
|
||||||
|
"list.background": "#FFFFFF",
|
||||||
|
"list.active.background": "#C6FF4D33",
|
||||||
|
"list.active.border": "#65A30D",
|
||||||
|
"list.even.background": "#F9F9F9",
|
||||||
|
"list.head.background": "#F9F9F9",
|
||||||
|
"list.hover.background": "#F0F0F0",
|
||||||
|
"muted.background": "#F9F9F9",
|
||||||
|
"muted.foreground": "#646464",
|
||||||
|
"overlay": "#0000000D",
|
||||||
|
"popover.background": "#FFFFFF",
|
||||||
|
"popover.foreground": "#18181B",
|
||||||
|
"primary.background": "#C6FF4D",
|
||||||
|
"primary.active.background": "#65A30D",
|
||||||
|
"primary.active.foreground": "#F7FEE7",
|
||||||
|
"primary.foreground": "#1A2E05",
|
||||||
|
"primary.hover.background": "#B7F03E",
|
||||||
|
"progress.bar.background": "#18181B",
|
||||||
|
"ring": "#BBBBBB",
|
||||||
|
"scrollbar.background": "#F9F9F900",
|
||||||
|
"scrollbar.thumb.background": "#BBBBBBE6",
|
||||||
|
"scrollbar.thumb.hover.background": "#BBBBBB",
|
||||||
|
"secondary.background": "#F0F0F0",
|
||||||
|
"secondary.active.background": "#E0E0E0",
|
||||||
|
"secondary.foreground": "#202020",
|
||||||
|
"secondary.hover.background": "#E8E8E8",
|
||||||
|
"selection.background": "#55A0FC",
|
||||||
|
"sidebar.background": "#F9F9F9",
|
||||||
|
"sidebar.accent.background": "#F0F0F0",
|
||||||
|
"sidebar.accent.foreground": "#202020",
|
||||||
|
"sidebar.border": "#E8E8E8",
|
||||||
|
"sidebar.foreground": "#202020",
|
||||||
|
"sidebar.primary.background": "#C6FF4D",
|
||||||
|
"sidebar.primary.foreground": "#1A2E05",
|
||||||
|
"skeleton.background": "#F0F0F0",
|
||||||
|
"slider.background": "#18181B",
|
||||||
|
"slider.thumb.background": "#FFFFFF",
|
||||||
|
"status_bar.background": "#F9F9F9",
|
||||||
|
"status_bar.border": "#E8E8E8",
|
||||||
|
"success.background": "#2FBF71",
|
||||||
|
"success.foreground": "#052E16",
|
||||||
|
"switch.background": "#CECECE",
|
||||||
|
"switch.thumb.background": "#FFFFFF",
|
||||||
|
"tab.background": "#00000000",
|
||||||
|
"tab.active.background": "#EBFFC1",
|
||||||
|
"tab.active.foreground": "#3F6212",
|
||||||
|
"tab.foreground": "#646464",
|
||||||
|
"tab_bar.background": "#F0F0F0",
|
||||||
|
"tab_bar.segmented.background": "#F0F0F0",
|
||||||
|
"table.background": "#FFFFFF",
|
||||||
|
"table.active.background": "#C6FF4D33",
|
||||||
|
"table.active.border": "#65A30D",
|
||||||
|
"table.even.background": "#F9F9F9",
|
||||||
|
"table.head.background": "#F9F9F9",
|
||||||
|
"table.head.foreground": "#838383",
|
||||||
|
"table.hover.background": "#F0F0F0",
|
||||||
|
"table.row.border": "#E8E8E8B3",
|
||||||
|
"tiles.background": "#F9F9F9",
|
||||||
|
"title_bar.background": "#F9F9F9",
|
||||||
|
"title_bar.border": "#E8E8E8",
|
||||||
|
"warning.background": "yellow-500",
|
||||||
|
"warning.foreground": "neutral-50",
|
||||||
|
"window.border": "#E8E8E8",
|
||||||
|
"base.red": "red-600",
|
||||||
|
"base.red.light": "red-400",
|
||||||
|
"base.green": "#16A34A",
|
||||||
|
"base.green.light": "green-400",
|
||||||
|
"base.blue": "blue-600",
|
||||||
|
"base.blue.light": "blue-400",
|
||||||
|
"base.yellow": "yellow-600",
|
||||||
|
"base.yellow.light": "yellow-400",
|
||||||
|
"base.magenta": "purple-600",
|
||||||
|
"base.magenta.light": "purple-400",
|
||||||
|
"base.cyan": "cyan-600",
|
||||||
|
"base.cyan.light": "cyan-400"
|
||||||
|
},
|
||||||
|
"highlight": {
|
||||||
|
"editor.foreground": "#000000",
|
||||||
|
"editor.background": "#ffffff",
|
||||||
|
"editor.active_line.background": "#F5F5F5",
|
||||||
|
"editor.line_number": "#929292",
|
||||||
|
"editor.active_line_number": "#000000",
|
||||||
|
"editor.invisible": "#73737366",
|
||||||
|
"conflict": "#C5060B",
|
||||||
|
"created": "#1642FF",
|
||||||
|
"hidden": "#6D6D6D",
|
||||||
|
"hint": "#9e5dff",
|
||||||
|
"modified": "#9e7008",
|
||||||
|
"predictive": "#A4ABB6",
|
||||||
|
"warning": "#C99401",
|
||||||
|
"syntax": {
|
||||||
|
"attribute": {
|
||||||
|
"color": "#957931"
|
||||||
|
},
|
||||||
|
"boolean": {
|
||||||
|
"color": "#C5060B"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"color": "#007fff"
|
||||||
|
},
|
||||||
|
"comment.doc": {
|
||||||
|
"color": "#007fff"
|
||||||
|
},
|
||||||
|
"constant": {
|
||||||
|
"color": "#C5060B"
|
||||||
|
},
|
||||||
|
"constructor": {
|
||||||
|
"color": "#0433ff"
|
||||||
|
},
|
||||||
|
"embedded": {
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"emphasis": {
|
||||||
|
"font_style": "italic"
|
||||||
|
},
|
||||||
|
"emphasis.strong": {
|
||||||
|
"font_weight": 700
|
||||||
|
},
|
||||||
|
"function": {
|
||||||
|
"color": "#0000A2"
|
||||||
|
},
|
||||||
|
"keyword": {
|
||||||
|
"color": "#0433ff"
|
||||||
|
},
|
||||||
|
"link_text": {
|
||||||
|
"color": "#0000A2",
|
||||||
|
"font_style": "normal"
|
||||||
|
},
|
||||||
|
"link_uri": {
|
||||||
|
"color": "#6A7293",
|
||||||
|
"font_style": "italic"
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"color": "#0433ff"
|
||||||
|
},
|
||||||
|
"string": {
|
||||||
|
"color": "#036A07"
|
||||||
|
},
|
||||||
|
"string.escape": {
|
||||||
|
"color": "#036A07"
|
||||||
|
},
|
||||||
|
"string.regex": {
|
||||||
|
"color": "#036A07"
|
||||||
|
},
|
||||||
|
"string.special": {
|
||||||
|
"color": "#d21f07"
|
||||||
|
},
|
||||||
|
"string.special.symbol": {
|
||||||
|
"color": "#d21f07"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"color": "#0433ff"
|
||||||
|
},
|
||||||
|
"text.literal": {
|
||||||
|
"color": "#6F42C1"
|
||||||
|
},
|
||||||
|
"text.code.span": {
|
||||||
|
"color": "#6F42C1"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"color": "#0433FF"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"color": "#6f42c1"
|
||||||
|
},
|
||||||
|
"property": {
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"variable": {
|
||||||
|
"color": "#333333"
|
||||||
|
},
|
||||||
|
"variable.special": {
|
||||||
|
"color": "#C5060B"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_default": true,
|
||||||
|
"name": "Signed Dark",
|
||||||
|
"mode": "dark",
|
||||||
|
"colors": {
|
||||||
|
"accent.background": "#222222",
|
||||||
|
"accent.foreground": "#EEEEEE",
|
||||||
|
"accordion.background": "#0A0A0A",
|
||||||
|
"background": "#0A0A0A",
|
||||||
|
"border": "#27272A",
|
||||||
|
"caret": "#EEEEEE",
|
||||||
|
"chart.1": "#93C5FD",
|
||||||
|
"chart.2": "#3B82F6",
|
||||||
|
"chart.3": "#2563EB",
|
||||||
|
"chart.4": "#1D4ED8",
|
||||||
|
"chart.5": "#1E40AF",
|
||||||
|
"chart_bullish": "green-600",
|
||||||
|
"chart_bearish": "red-600",
|
||||||
|
"danger.background": "red-400",
|
||||||
|
"danger.foreground": "red-600",
|
||||||
|
"description_list.label.background": "#191919",
|
||||||
|
"description_list.label.foreground": "#EEEEEE",
|
||||||
|
"drag.border": "#C6FF4D",
|
||||||
|
"drop_target.background": "#C6FF4D2E",
|
||||||
|
"foreground": "#FAFAFA",
|
||||||
|
"group_box.background": "#191919",
|
||||||
|
"group_box.foreground": "#EEEEEE",
|
||||||
|
"info.background": "cyan-400",
|
||||||
|
"info.foreground": "cyan-600",
|
||||||
|
"input.border": "#2F2F2F",
|
||||||
|
"link": "#A3E635",
|
||||||
|
"link.active": "#A3E635",
|
||||||
|
"link.hover": "#C6FF4D",
|
||||||
|
"list.background": "#0A0A0A",
|
||||||
|
"list.active.background": "#C6FF4D33",
|
||||||
|
"list.active.border": "#C6FF4D",
|
||||||
|
"list.even.background": "#191919",
|
||||||
|
"list.head.background": "#191919",
|
||||||
|
"list.hover.background": "#222222",
|
||||||
|
"muted.background": "#191919",
|
||||||
|
"muted.foreground": "#B4B4B4",
|
||||||
|
"overlay": "#00000033",
|
||||||
|
"popover.background": "#0A0A0A",
|
||||||
|
"popover.foreground": "#FAFAFA",
|
||||||
|
"primary.background": "#C6FF4D",
|
||||||
|
"primary.active.background": "#65A30D",
|
||||||
|
"primary.active.foreground": "#F7FEE7",
|
||||||
|
"primary.foreground": "#1A2E05",
|
||||||
|
"primary.hover.background": "#D6FF7A",
|
||||||
|
"progress.bar.background": "#FAFAFA",
|
||||||
|
"ring": "#606060",
|
||||||
|
"scrollbar.background": "#19191900",
|
||||||
|
"scrollbar.thumb.background": "#606060E6",
|
||||||
|
"scrollbar.thumb.hover.background": "#606060",
|
||||||
|
"secondary.background": "#222222",
|
||||||
|
"secondary.active.background": "#313131",
|
||||||
|
"secondary.foreground": "#EEEEEE",
|
||||||
|
"secondary.hover.background": "#2A2A2A",
|
||||||
|
"selection.background": "#1D4ED8",
|
||||||
|
"sidebar.background": "#111111",
|
||||||
|
"sidebar.accent.background": "#222222",
|
||||||
|
"sidebar.accent.foreground": "#EEEEEE",
|
||||||
|
"sidebar.border": "#2A2A2A",
|
||||||
|
"sidebar.foreground": "#EEEEEE",
|
||||||
|
"sidebar.primary.background": "#C6FF4D",
|
||||||
|
"sidebar.primary.foreground": "#1A2E05",
|
||||||
|
"skeleton.background": "#222222",
|
||||||
|
"slider.background": "#FAFAFA",
|
||||||
|
"slider.thumb.background": "#0A0A0A",
|
||||||
|
"status_bar.background": "#191919",
|
||||||
|
"status_bar.border": "#2A2A2A",
|
||||||
|
"success.background": "#2FBF71",
|
||||||
|
"success.foreground": "#052E16",
|
||||||
|
"switch.background": "#484848",
|
||||||
|
"switch.thumb.background": "#0A0A0A",
|
||||||
|
"tab.background": "#00000000",
|
||||||
|
"tab.active.background": "#19200A",
|
||||||
|
"tab.active.foreground": "#C6FF4D",
|
||||||
|
"tab.foreground": "#B4B4B4",
|
||||||
|
"tab_bar.background": "#191919",
|
||||||
|
"tab_bar.segmented.background": "#191919",
|
||||||
|
"table.background": "#0A0A0A",
|
||||||
|
"table.active.background": "#C6FF4D33",
|
||||||
|
"table.active.border": "#C6FF4D",
|
||||||
|
"table.even.background": "#191919",
|
||||||
|
"table.head.background": "#191919",
|
||||||
|
"table.head.foreground": "#7B7B7B",
|
||||||
|
"table.hover.background": "#222222",
|
||||||
|
"table.row.border": "#2A2A2AB3",
|
||||||
|
"tiles.background": "#191919",
|
||||||
|
"title_bar.background": "#191919",
|
||||||
|
"title_bar.border": "#2A2A2A",
|
||||||
|
"warning.background": "yellow-400",
|
||||||
|
"warning.foreground": "yellow-600",
|
||||||
|
"window.border": "#2A2A2A",
|
||||||
|
"base.red": "red-400",
|
||||||
|
"base.red.light": "red-300",
|
||||||
|
"base.green": "#22C55E",
|
||||||
|
"base.green.light": "green-300",
|
||||||
|
"base.blue": "blue-400",
|
||||||
|
"base.blue.light": "blue-300",
|
||||||
|
"base.yellow": "yellow-400",
|
||||||
|
"base.yellow.light": "yellow-300",
|
||||||
|
"base.magenta": "purple-400",
|
||||||
|
"base.magenta.light": "purple-300",
|
||||||
|
"base.cyan": "cyan-400",
|
||||||
|
"base.cyan.light": "cyan-300"
|
||||||
|
},
|
||||||
|
"highlight": {
|
||||||
|
"editor.foreground": "#CACCCA",
|
||||||
|
"editor.background": "#0a0a0a",
|
||||||
|
"editor.active_line.background": "#171717",
|
||||||
|
"editor.line_number": "#8F8F8F",
|
||||||
|
"editor.active_line_number": "#DDDDDD",
|
||||||
|
"editor.invisible": "#73737366",
|
||||||
|
"conflict": "#D2602D",
|
||||||
|
"created": "#3f72e2",
|
||||||
|
"created.background": "#0C4619",
|
||||||
|
"deleted.background": "#46190C",
|
||||||
|
"error.background": "#46190C",
|
||||||
|
"error.border": "#E44A4F",
|
||||||
|
"hidden": "#9E9E9E",
|
||||||
|
"hint": "#b283f8",
|
||||||
|
"hint.background": "#250c4b",
|
||||||
|
"hint.border": "#3f0891",
|
||||||
|
"info.background": "#0059D1",
|
||||||
|
"info.border": "#0059D1",
|
||||||
|
"modified": "#B0A878",
|
||||||
|
"modified.background": "#3A310E",
|
||||||
|
"predictive": "#5D5945",
|
||||||
|
"success.background": "#0C4619",
|
||||||
|
"warning.background": "#3A310E",
|
||||||
|
"warning.border": "#7B6508",
|
||||||
|
"syntax": {
|
||||||
|
"attribute": {
|
||||||
|
"color": "#7FAEF9"
|
||||||
|
},
|
||||||
|
"boolean": {
|
||||||
|
"color": "#CC9E00"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"color": "#9D9D9D"
|
||||||
|
},
|
||||||
|
"comment.doc": {
|
||||||
|
"color": "#9D9D9D"
|
||||||
|
},
|
||||||
|
"constant": {
|
||||||
|
"color": "#CC9E00"
|
||||||
|
},
|
||||||
|
"constructor": {
|
||||||
|
"color": "#CBA6F7"
|
||||||
|
},
|
||||||
|
"embedded": {
|
||||||
|
"color": "#CACCCA"
|
||||||
|
},
|
||||||
|
"emphasis": {
|
||||||
|
"font_style": "italic"
|
||||||
|
},
|
||||||
|
"emphasis.strong": {
|
||||||
|
"font_weight": 700
|
||||||
|
},
|
||||||
|
"function": {
|
||||||
|
"color": "#B3C5F3"
|
||||||
|
},
|
||||||
|
"keyword": {
|
||||||
|
"color": "#87B1F6"
|
||||||
|
},
|
||||||
|
"link_text": {
|
||||||
|
"color": "#419CFF",
|
||||||
|
"font_style": "normal"
|
||||||
|
},
|
||||||
|
"link_uri": {
|
||||||
|
"color": "#7faef9",
|
||||||
|
"font_style": "italic"
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"color": "#CC9E00"
|
||||||
|
},
|
||||||
|
"string": {
|
||||||
|
"color": "#A3E09F"
|
||||||
|
},
|
||||||
|
"string.escape": {
|
||||||
|
"color": "#68DC7C"
|
||||||
|
},
|
||||||
|
"string.regex": {
|
||||||
|
"color": "#68DC7C"
|
||||||
|
},
|
||||||
|
"string.special": {
|
||||||
|
"color": "#A3E09F"
|
||||||
|
},
|
||||||
|
"string.special.symbol": {
|
||||||
|
"color": "#A3E09F"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"color": "#419CFF"
|
||||||
|
},
|
||||||
|
"text.literal": {
|
||||||
|
"color": "#A3E09F"
|
||||||
|
},
|
||||||
|
"text.code.span": {
|
||||||
|
"color": "#A3E09F"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"color": "#CC9E00",
|
||||||
|
"font_weight": 600
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"color": "#CBA6F7"
|
||||||
|
},
|
||||||
|
"property": {
|
||||||
|
"color": "#BCC4E0"
|
||||||
|
},
|
||||||
|
"variable.special": {
|
||||||
|
"color": "#419CFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+92
-17
@@ -1,10 +1,12 @@
|
|||||||
|
use anyhow::Context;
|
||||||
use gpui::{AssetSource, Result, SharedString};
|
use gpui::{AssetSource, Result, SharedString};
|
||||||
use gpui_native_kit::icons::IconNamed;
|
use gpui_component::IconNamed;
|
||||||
use rust_embed::RustEmbed;
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
#[derive(RustEmbed)]
|
#[derive(RustEmbed)]
|
||||||
#[folder = "assets"]
|
#[folder = "assets"]
|
||||||
#[include = "icons/**/*.svg"]
|
#[include = "icons/**/*.svg"]
|
||||||
|
#[include = "themes/**/*.json"]
|
||||||
#[include = "backgrounds/**/*.jpg"]
|
#[include = "backgrounds/**/*.jpg"]
|
||||||
#[include = "backgrounds/**/*.png"]
|
#[include = "backgrounds/**/*.png"]
|
||||||
#[exclude = "*.DS_Store"]
|
#[exclude = "*.DS_Store"]
|
||||||
@@ -12,19 +14,40 @@ pub struct Assets;
|
|||||||
|
|
||||||
impl AssetSource for Assets {
|
impl AssetSource for Assets {
|
||||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||||
if let Some(file) = Self::get(path) {
|
Self::get(path)
|
||||||
return Ok(Some(file.data));
|
.map(|f| Some(f.data))
|
||||||
}
|
.with_context(|| format!("loading asset at path {path:?}"))
|
||||||
gpui_native_kit::icons::IconAssets.load(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||||
let mut paths: Vec<SharedString> = Self::iter()
|
Ok(Self::iter()
|
||||||
.filter(|p| p.starts_with(path))
|
.filter_map(|p| {
|
||||||
.map(Into::into)
|
if p.starts_with(path) {
|
||||||
.collect();
|
Some(p.into())
|
||||||
paths.extend(gpui_native_kit::icons::IconAssets.list(path)?);
|
} else {
|
||||||
Ok(paths)
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Assets {
|
||||||
|
pub fn themes(&self) -> Vec<(String, String)> {
|
||||||
|
Self::iter()
|
||||||
|
.filter(|path| path.starts_with("themes/"))
|
||||||
|
.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()?,
|
||||||
|
};
|
||||||
|
Some((name.to_owned(), content))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +77,6 @@ pub enum CustomIconName {
|
|||||||
Refresh,
|
Refresh,
|
||||||
Grid,
|
Grid,
|
||||||
Init,
|
Init,
|
||||||
EllipsisVertical,
|
|
||||||
ArrowUp,
|
|
||||||
Trash,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IconNamed for CustomIconName {
|
impl IconNamed for CustomIconName {
|
||||||
@@ -87,10 +107,65 @@ impl IconNamed for CustomIconName {
|
|||||||
CustomIconName::Recent => "icons/recent.svg",
|
CustomIconName::Recent => "icons/recent.svg",
|
||||||
CustomIconName::Grid => "icons/grid.svg",
|
CustomIconName::Grid => "icons/grid.svg",
|
||||||
CustomIconName::Init => "icons/init.svg",
|
CustomIconName::Init => "icons/init.svg",
|
||||||
CustomIconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
|
|
||||||
CustomIconName::ArrowUp => "icons/arrow-up.svg",
|
|
||||||
CustomIconName::Trash => "icons/trash.svg",
|
|
||||||
}
|
}
|
||||||
.into()
|
.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,7 +9,6 @@ publish.workspace = true
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
gpui-base.workspace = true
|
gpui-base.workspace = true
|
||||||
settings = { path = "../settings" }
|
|
||||||
signed_ui = { path = "../signed_ui" }
|
signed_ui = { path = "../signed_ui" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -11,19 +11,22 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use gpui_base::dock::{
|
use gpui_base::dock::{
|
||||||
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
||||||
PanelView, TabGroupRenderer,
|
PanelView, TabGroupRenderer, TilesRenderer,
|
||||||
};
|
};
|
||||||
use gpui_base::{HandleEdge, resize_handle};
|
use gpui_base::resize_handle;
|
||||||
use gpui_component::ActiveTheme as _;
|
use gpui_component::scroll::ScrollbarMode;
|
||||||
|
use gpui_component::{ActiveTheme as _, Side, StyledExt as _};
|
||||||
|
|
||||||
use crate::invalid_panel::InvalidPanel;
|
use crate::invalid_panel::InvalidPanel;
|
||||||
use crate::panel_handle;
|
|
||||||
use crate::tab_panel::SignedTabGroupSkin;
|
use crate::tab_panel::SignedTabGroupSkin;
|
||||||
|
use crate::tiles::SignedTilesSkin;
|
||||||
|
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
||||||
|
|
||||||
/// State the skin shares with its per-container renderers.
|
/// State the skin shares with its per-container renderers.
|
||||||
pub(crate) struct SkinShared {
|
pub(crate) struct SkinShared {
|
||||||
area: WeakEntity<DockArea>,
|
area: WeakEntity<DockArea>,
|
||||||
toggle_button_visible: Cell<bool>,
|
toggle_button_visible: Cell<bool>,
|
||||||
|
tiles_scrollbar_mode: Cell<Option<ScrollbarMode>>,
|
||||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||||
resizing_dock: Cell<Option<DockPlacement>>,
|
resizing_dock: Cell<Option<DockPlacement>>,
|
||||||
}
|
}
|
||||||
@@ -37,9 +40,18 @@ impl SkinShared {
|
|||||||
self.toggle_button_visible.get()
|
self.toggle_button_visible.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
||||||
|
self.tiles_scrollbar_mode.get()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||||
&self.resizing_dock
|
&self.resizing_dock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
|
||||||
|
pub(crate) fn notify(&self, cx: &mut App) {
|
||||||
|
_ = self.area.update(cx, |_, cx| cx.notify());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Signed appearance for a [`DockArea`].
|
/// The Signed appearance for a [`DockArea`].
|
||||||
@@ -61,6 +73,7 @@ impl SignedDockSkin {
|
|||||||
shared: Rc::new(SkinShared {
|
shared: Rc::new(SkinShared {
|
||||||
area: cx.weak_entity(),
|
area: cx.weak_entity(),
|
||||||
toggle_button_visible: Cell::new(true),
|
toggle_button_visible: Cell::new(true),
|
||||||
|
tiles_scrollbar_mode: Cell::new(None),
|
||||||
resizing_dock: Cell::new(None),
|
resizing_dock: Cell::new(None),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -69,10 +82,29 @@ impl SignedDockSkin {
|
|||||||
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
|
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
|
||||||
&self.shared
|
&self.shared
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether tab bars offer the affordance that collapses a neighbouring dock.
|
||||||
|
pub fn is_toggle_button_visible(&self) -> bool {
|
||||||
|
self.shared.is_toggle_button_visible()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_toggle_button_visible(&self, visible: bool, cx: &mut App) {
|
||||||
|
self.shared.toggle_button_visible.set(visible);
|
||||||
|
self.shared.notify(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a tiles canvas shows its scrollbar. `None` follows the theme.
|
||||||
|
pub fn tiles_scrollbar_mode(&self) -> Option<ScrollbarMode> {
|
||||||
|
self.shared.tiles_scrollbar_mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_tiles_scrollbar_mode(&self, mode: Option<ScrollbarMode>, cx: &mut App) {
|
||||||
|
self.shared.tiles_scrollbar_mode.set(mode);
|
||||||
|
self.shared.notify(cx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Payload a dock's resize handle drags.
|
/// Payload a dock's resize handle drags.
|
||||||
///
|
|
||||||
/// It draws nothing, the handle element is the visible affordance.
|
/// It draws nothing, the handle element is the visible affordance.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ResizePanel;
|
struct ResizePanel;
|
||||||
@@ -121,10 +153,30 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
let placement = dock.placement();
|
||||||
|
let open = dock.is_open();
|
||||||
|
|
||||||
|
// A closed left or right dock takes no space.
|
||||||
|
// A closed bottom dock keeps a strip so its tab bar stays clickable.
|
||||||
|
if !open && !placement.is_bottom() {
|
||||||
|
return div().into_any_element();
|
||||||
|
}
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.size_full()
|
.flex_none()
|
||||||
.relative()
|
.relative()
|
||||||
|
.overflow_hidden()
|
||||||
|
.map(|this| match placement {
|
||||||
|
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(dock.size()),
|
||||||
|
DockPlacement::Bottom => this.w_full().h(dock.size()),
|
||||||
|
// Base never builds a dock for the centre.
|
||||||
|
DockPlacement::Center => this,
|
||||||
|
})
|
||||||
|
// The closed bottom dock's strip is the tab bar itself, a full tab bar tall.
|
||||||
|
.when(!open && placement.is_bottom(), |this| {
|
||||||
|
this.h(TAB_BAR_HEIGHT)
|
||||||
|
})
|
||||||
.child(content)
|
.child(content)
|
||||||
.child(self.render_resize_handle(dock, window, cx))
|
.child(self.render_resize_handle(dock, window, cx))
|
||||||
.child(DockResizeTracker {
|
.child(DockResizeTracker {
|
||||||
@@ -151,6 +203,10 @@ impl DockAreaRenderer for SignedDockSkin {
|
|||||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||||
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
|
||||||
|
Rc::new(SignedTilesSkin::new(self.shared().clone()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SignedDockSkin {
|
impl SignedDockSkin {
|
||||||
@@ -164,12 +220,7 @@ impl SignedDockSkin {
|
|||||||
let shared = self.shared().clone();
|
let shared = self.shared().clone();
|
||||||
|
|
||||||
resize_handle("resize-handle", placement.axis())
|
resize_handle("resize-handle", placement.axis())
|
||||||
.when(placement.is_left(), |this| {
|
.when(placement.is_left(), |this| this.placement(Side::Left))
|
||||||
this.inside(HandleEdge::Trailing)
|
|
||||||
})
|
|
||||||
.when(placement.is_right() || placement.is_bottom(), |this| {
|
|
||||||
this.inside(HandleEdge::Leading)
|
|
||||||
})
|
|
||||||
.on_drag(ResizePanel, move |info, _, _, cx| {
|
.on_drag(ResizePanel, move |info, _, _, cx| {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
shared.resizing_dock().set(Some(placement));
|
shared.resizing_dock().set(Some(placement));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use gpui_base::dock::PanelView;
|
|||||||
mod dock_area;
|
mod dock_area;
|
||||||
mod invalid_panel;
|
mod invalid_panel;
|
||||||
mod tab_panel;
|
mod tab_panel;
|
||||||
|
mod tiles;
|
||||||
mod window_controls;
|
mod window_controls;
|
||||||
|
|
||||||
pub use dock_area::SignedDockSkin;
|
pub use dock_area::SignedDockSkin;
|
||||||
|
|||||||
+169
-297
@@ -5,9 +5,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
|
Anchor, Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds,
|
||||||
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
|
Context, Div, Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point,
|
||||||
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
Render, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||||
Styled as _, Window, div, px, size,
|
Styled as _, Window, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_base::dock::{
|
use gpui_base::dock::{
|
||||||
@@ -18,24 +18,17 @@ use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
|||||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||||
use gpui_component::button::{Button, ButtonVariants as _};
|
use gpui_component::button::{Button, ButtonVariants as _};
|
||||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||||
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
use gpui_component::menu::DropdownMenu as _;
|
||||||
use settings::{SettingsStore, TabBarSettings};
|
use gpui_component::{
|
||||||
|
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||||
|
};
|
||||||
use signed_ui::title_bar_drag_handlers;
|
use signed_ui::title_bar_drag_handlers;
|
||||||
|
|
||||||
use crate::dock_area::SkinShared;
|
use crate::dock_area::SkinShared;
|
||||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||||
|
|
||||||
|
/// The drag preview's size, reported to base for the drop placeholder.
|
||||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||||
const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar";
|
|
||||||
const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab";
|
|
||||||
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close";
|
|
||||||
const TAB_HOVER_GROUP: &str = "signed-dock-tab";
|
|
||||||
const NAVIGATION_BUTTONS_TAB_THRESHOLD: usize = 10;
|
|
||||||
|
|
||||||
/// Whether a group's prev/next buttons should be drawn.
|
|
||||||
fn navigation_buttons_visible(hide_navigation_buttons: bool, panels_len: usize) -> bool {
|
|
||||||
!hide_navigation_buttons && panels_len > NAVIGATION_BUTTONS_TAB_THRESHOLD
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A panel's title, or its registered name when the panel has no handle.
|
/// A panel's title, or its registered name when the panel has no handle.
|
||||||
pub(crate) fn panel_title(
|
pub(crate) fn panel_title(
|
||||||
@@ -50,20 +43,24 @@ pub(crate) fn panel_title(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The preview that follows the cursor while a panel is dragged.
|
/// The preview that follows the cursor while a panel is dragged.
|
||||||
|
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
|
||||||
struct DragPanelPreview {
|
struct DragPanelPreview {
|
||||||
panel: Arc<dyn BasePanelView>,
|
panel: Arc<dyn BasePanelView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for DragPanelPreview {
|
impl Render for DragPanelPreview {
|
||||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
h_flex()
|
div()
|
||||||
.id("drag-panel")
|
.id("drag-panel")
|
||||||
.cursor_grab()
|
.cursor_grab()
|
||||||
.p_1()
|
.py_1()
|
||||||
|
.px_3()
|
||||||
|
.w_24()
|
||||||
|
.overflow_hidden()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.rounded(cx.theme().radius_lg)
|
.rounded(cx.theme().radius)
|
||||||
.text_color(cx.theme().tab_foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
.bg(cx.theme().tokens.tab_active)
|
.bg(cx.theme().tokens.tab_active)
|
||||||
.opacity(0.75)
|
.opacity(0.75)
|
||||||
@@ -71,10 +68,10 @@ impl Render for DragPanelPreview {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The zoom affordance `panel` offers, if any.
|
/// 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.
|
/// 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> {
|
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||||
|
let panel = group.active_panel()?;
|
||||||
panel
|
panel
|
||||||
.zoomable(cx)
|
.zoomable(cx)
|
||||||
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
||||||
@@ -82,17 +79,16 @@ fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The left-most, top-most tab group in a container.
|
/// The left-most, top-most tab group in a container.
|
||||||
///
|
|
||||||
/// A left dock's collapse button lives in this group.
|
/// A left dock's collapse button lives in this group.
|
||||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
PaneRef::Tabs { .. } => Some(node.id()),
|
PaneRef::Tabs { .. } => Some(node.id()),
|
||||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||||
|
PaneRef::Tiles { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The right-most, top-most tab group.
|
/// The right-most, top-most tab group.
|
||||||
///
|
|
||||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
@@ -102,53 +98,7 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
gpui::Axis::Horizontal => children.last(),
|
gpui::Axis::Horizontal => children.last(),
|
||||||
}
|
}
|
||||||
.and_then(right_top_group),
|
.and_then(right_top_group),
|
||||||
}
|
PaneRef::Tiles { .. } => None,
|
||||||
}
|
|
||||||
|
|
||||||
/// The bottom or right dock whose root tab group is this one, if any.
|
|
||||||
///
|
|
||||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
|
||||||
fn dock_root_group(
|
|
||||||
shared: &SkinShared,
|
|
||||||
group: &TabGroupContext,
|
|
||||||
cx: &App,
|
|
||||||
) -> Option<DockPlacement> {
|
|
||||||
let area = shared.area().upgrade()?;
|
|
||||||
let area = area.read(cx);
|
|
||||||
|
|
||||||
[DockPlacement::Bottom, DockPlacement::Right]
|
|
||||||
.into_iter()
|
|
||||||
.find(|placement| {
|
|
||||||
area.layout(*placement)
|
|
||||||
.is_some_and(|tree| tree.root().id() == group.node())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close `panel`, removing its dock instead when base would refuse an empty one.
|
|
||||||
///
|
|
||||||
/// A bottom or right dock's only panel is that case, so the whole dock goes.
|
|
||||||
fn close_panel(
|
|
||||||
shared: &SkinShared,
|
|
||||||
group: &TabGroupContext,
|
|
||||||
panel: &Arc<dyn BasePanelView>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
if !panel.closable(cx) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dock = (group.panels().len() <= 1)
|
|
||||||
.then(|| dock_root_group(shared, group, cx))
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
match dock {
|
|
||||||
Some(placement) => {
|
|
||||||
_ = shared.area().update(cx, |area, cx| {
|
|
||||||
area.remove_dock(placement, window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
None => group.close(panel.panel_id(cx), window, cx),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,14 +144,26 @@ impl SignedTabGroupSkin {
|
|||||||
left == group.node() && group.panels().len() == 1
|
left == group.node() && group.panels().len() == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bottom or right dock whose root tab group is this one, if any.
|
||||||
|
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||||
|
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
|
||||||
|
let area = self.shared.area().upgrade()?;
|
||||||
|
let area = area.read(cx);
|
||||||
|
[DockPlacement::Bottom, DockPlacement::Right]
|
||||||
|
.into_iter()
|
||||||
|
.find(|placement| {
|
||||||
|
area.layout(*placement)
|
||||||
|
.is_some_and(|tree| tree.root().id() == group.node())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||||
///
|
|
||||||
/// A locked group never is, a bottom or right dock root always is.
|
/// A locked group never is, a bottom or right dock root always is.
|
||||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||||
if group.is_locked() {
|
if group.is_locked() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
|
if !group.is_draggable() && self.is_dock_root_group(group, cx).is_none() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
group.drag_panel(ix, cx)
|
group.drag_panel(ix, cx)
|
||||||
@@ -236,7 +198,7 @@ impl SignedTabGroupSkin {
|
|||||||
DockPlacement::Bottom => area
|
DockPlacement::Bottom => area
|
||||||
.layout(DockPlacement::Bottom)
|
.layout(DockPlacement::Bottom)
|
||||||
.and_then(|tree| left_top_group(tree.root())),
|
.and_then(|tree| left_top_group(tree.root())),
|
||||||
DockPlacement::Center => return None,
|
DockPlacement::Center => None,
|
||||||
};
|
};
|
||||||
if designated != Some(group.node()) {
|
if designated != Some(group.node()) {
|
||||||
return None;
|
return None;
|
||||||
@@ -271,22 +233,15 @@ impl SignedTabGroupSkin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||||
///
|
/// Always rendered, disabled at the strip ends or when collapsed.
|
||||||
/// Hidden by default, and only offered for crowded strips where the active
|
fn render_prev_next_tab_buttons(
|
||||||
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
|
&self,
|
||||||
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
|
group: &TabGroupContext,
|
||||||
|
_cx: &mut App,
|
||||||
|
) -> impl IntoElement {
|
||||||
let collapsed = group.is_collapsed();
|
let collapsed = group.is_collapsed();
|
||||||
let active_ix = group.active_ix();
|
let active_ix = group.active_ix();
|
||||||
let panels_len = group.panels().len();
|
let panels_len = group.panels().len();
|
||||||
|
|
||||||
let hidden = SettingsStore::try_global(cx)
|
|
||||||
.map(|store| store.read(cx).settings().tab_bar.hide_navigation_buttons)
|
|
||||||
.unwrap_or_else(|| TabBarSettings::default().hide_navigation_buttons);
|
|
||||||
|
|
||||||
if !navigation_buttons_visible(hidden, panels_len) {
|
|
||||||
return Empty.into_any_element();
|
|
||||||
}
|
|
||||||
|
|
||||||
let prev_enabled = !collapsed && active_ix > 0;
|
let prev_enabled = !collapsed && active_ix > 0;
|
||||||
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
||||||
|
|
||||||
@@ -318,10 +273,100 @@ impl SignedTabGroupSkin {
|
|||||||
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
}
|
||||||
|
|
||||||
|
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
|
||||||
|
fn render_toolbar(
|
||||||
|
&self,
|
||||||
|
group: &TabGroupContext,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
if group.is_collapsed() {
|
||||||
|
return div();
|
||||||
|
}
|
||||||
|
|
||||||
|
let zoomed = group.is_zoomed();
|
||||||
|
let handle = group.active_panel().and_then(PanelHandle::of);
|
||||||
|
let control = zoom_control(group, cx);
|
||||||
|
let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
|
||||||
|
let menu_zoom = control.is_some_and(|control| control.menu_visible());
|
||||||
|
// A bottom or right dock's only panel cannot close through the group.
|
||||||
|
// The close item is offered, the skin removes the whole dock instead.
|
||||||
|
let closable = group.is_closable()
|
||||||
|
|| (self.is_dock_root_group(group, cx).is_some()
|
||||||
|
&& group.active_panel().is_some_and(|panel| panel.closable(cx)));
|
||||||
|
let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
|
||||||
|
let panel = handle.map(|handle| handle.panel());
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.occlude()
|
||||||
|
.when_some(buttons, |this, buttons| {
|
||||||
|
this.children(
|
||||||
|
buttons
|
||||||
|
.into_iter()
|
||||||
|
.map(|button| button.small().ghost().tab_stop(false)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|this| {
|
||||||
|
let value = if zoomed {
|
||||||
|
Some(("zoom-out", IconName::Minimize, t("Dock.Zoom Out")))
|
||||||
|
} else if toolbar_zoom {
|
||||||
|
Some(("zoom-in", IconName::Maximize, t("Dock.Zoom In")))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some((id, icon, tooltip)) = value {
|
||||||
|
this.child(
|
||||||
|
Button::new(id)
|
||||||
|
.icon(icon)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.tab_stop(false)
|
||||||
|
.tooltip_with_action(tooltip, &ToggleZoom, None)
|
||||||
|
.selected(zoomed)
|
||||||
|
.on_click({
|
||||||
|
let group = group.clone();
|
||||||
|
move |_, window, cx| group.toggle_zoom(window, cx)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
Button::new("menu")
|
||||||
|
.icon(IconName::Ellipsis)
|
||||||
|
.small()
|
||||||
|
.ghost()
|
||||||
|
.tab_stop(false)
|
||||||
|
.dropdown_menu(move |menu, window, cx| {
|
||||||
|
menu.when_some(panel.clone(), |menu, panel| {
|
||||||
|
panel.dropdown_menu(menu, window, cx)
|
||||||
|
})
|
||||||
|
.separator()
|
||||||
|
.menu_with_disabled(
|
||||||
|
if zoomed {
|
||||||
|
t("Dock.Zoom Out")
|
||||||
|
} else {
|
||||||
|
t("Dock.Zoom In")
|
||||||
|
},
|
||||||
|
Box::new(ToggleZoom),
|
||||||
|
!menu_zoom,
|
||||||
|
)
|
||||||
|
.when(closable, |menu| {
|
||||||
|
menu.separator().menu(t("Dock.Close"), Box::new(ClosePanel))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.anchor(Anchor::TopRight),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tab of the pill strip.
|
/// One tab of the pill strip.
|
||||||
|
/// While collapsed, tabs lose the active style and all interactions.
|
||||||
|
/// The strip is also how a closed bottom dock is opened again.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_tab(
|
fn render_tab(
|
||||||
&self,
|
&self,
|
||||||
@@ -337,73 +382,25 @@ impl SignedTabGroupSkin {
|
|||||||
let droppable = group.is_droppable();
|
let droppable = group.is_droppable();
|
||||||
let drag = self.tab_drag(group, ix, cx);
|
let drag = self.tab_drag(group, ix, cx);
|
||||||
let handle = PanelHandle::of(&panel);
|
let handle = PanelHandle::of(&panel);
|
||||||
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
|
|
||||||
|
|
||||||
let closable = !collapsed
|
|
||||||
&& (group.is_closable()
|
|
||||||
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
|
|
||||||
|
|
||||||
// Whether a double-click zooms this tab in.
|
|
||||||
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
|
|
||||||
|
|
||||||
Tab::new(ix)
|
Tab::new(ix)
|
||||||
.group(TAB_HOVER_GROUP)
|
.h_6()
|
||||||
.when(active, |this| {
|
.px_3()
|
||||||
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
.text_sm()
|
||||||
})
|
.whitespace_nowrap()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.flex_shrink_0()
|
.justify_center()
|
||||||
.h_7()
|
|
||||||
.min_w_40()
|
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.px_1p5()
|
.flex_shrink_0()
|
||||||
.text_sm()
|
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.rounded(cx.theme().radius)
|
.rounded(cx.theme().radius)
|
||||||
.text_color(cx.theme().tab_foreground)
|
.text_color(cx.theme().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)) {
|
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||||
Some(tab_name) => this.child(tab_name),
|
Some(tab_name) => this.child(tab_name),
|
||||||
None => this.child(panel_title(&panel, window, cx)),
|
None => this.child(panel_title(&panel, window, cx)),
|
||||||
}),
|
|
||||||
)
|
|
||||||
.when(closable, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.when(active, |this| {
|
|
||||||
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
|
|
||||||
})
|
|
||||||
.when(!active, |this| {
|
|
||||||
this.invisible()
|
|
||||||
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
Button::new(id)
|
|
||||||
.icon(IconName::Close)
|
|
||||||
.tooltip(t("Dock.Close"))
|
|
||||||
.ghost()
|
|
||||||
.xsmall()
|
|
||||||
.tab_stop(false)
|
|
||||||
.on_click({
|
|
||||||
let group = group.clone();
|
|
||||||
let shared = self.shared.clone();
|
|
||||||
let panel = panel.clone();
|
|
||||||
move |_, window, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
close_panel(&shared, &group, &panel, window, cx);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
// Pill style, the selected tab is the filled pill, others show only on hover.
|
||||||
.styles(|styles| {
|
.styles(|styles| {
|
||||||
styles.selected(|style| {
|
styles.selected(|style| {
|
||||||
style
|
style
|
||||||
@@ -415,15 +412,15 @@ impl SignedTabGroupSkin {
|
|||||||
if active {
|
if active {
|
||||||
this
|
this
|
||||||
} else {
|
} else {
|
||||||
this.text_color(cx.theme().tab_foreground)
|
this.text_color(cx.theme().secondary_foreground)
|
||||||
.bg(cx.theme().tab)
|
.bg(cx.theme().secondary_hover)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.selected(active)
|
.selected(active)
|
||||||
.on_click({
|
.on_click({
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let area = self.shared.area().clone();
|
let area = self.shared.area().clone();
|
||||||
move |event, window, cx| {
|
move |_, window, cx| {
|
||||||
group.select_tab(ix, window, cx);
|
group.select_tab(ix, window, cx);
|
||||||
|
|
||||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||||
@@ -431,15 +428,7 @@ impl SignedTabGroupSkin {
|
|||||||
_ = area.update(cx, |area, cx| {
|
_ = area.update(cx, |area, cx| {
|
||||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A double-click zooms the tab's panel
|
|
||||||
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
group.toggle_zoom(window, cx);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.when(!collapsed, |this| {
|
.when(!collapsed, |this| {
|
||||||
@@ -537,13 +526,15 @@ impl SignedTabGroupSkin {
|
|||||||
|
|
||||||
impl TabGroupRenderer for SignedTabGroupSkin {
|
impl TabGroupRenderer for SignedTabGroupSkin {
|
||||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||||
let control = group
|
let control = zoom_control(group, cx);
|
||||||
.active_panel()
|
|
||||||
.and_then(|panel| panel_zoom_control(panel, cx));
|
|
||||||
// An emptied group draws nothing, so no bare tab bar is left behind.
|
// An emptied group draws nothing, so no bare tab bar is left behind.
|
||||||
if group.panels().is_empty() {
|
if group.panels().is_empty() {
|
||||||
return div().id("tab-panel");
|
return div().id("tab-panel");
|
||||||
}
|
}
|
||||||
|
// Base refuses an empty dock, so closing its only panel removes the dock.
|
||||||
|
let dock_to_remove = (group.panels().len() <= 1)
|
||||||
|
.then(|| self.is_dock_root_group(group, cx))
|
||||||
|
.flatten();
|
||||||
let shared = self.shared.clone();
|
let shared = self.shared.clone();
|
||||||
|
|
||||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||||
@@ -552,10 +543,14 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.size_full()
|
.size_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(cx.theme().tokens.background)
|
.bg(cx.theme().tokens.background)
|
||||||
|
// A collapsed group has no content, so these actions are not registered.
|
||||||
.when(!group.is_collapsed(), |this| {
|
.when(!group.is_collapsed(), |this| {
|
||||||
this.on_action({
|
this.on_action({
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
move |_: &ToggleZoom, window, cx| {
|
move |_: &ToggleZoom, window, cx| {
|
||||||
|
// A panel with no zoom control is not zoomed in by the keybinding.
|
||||||
|
// Zooming out is never refused.
|
||||||
|
// Otherwise a zoomed panel that lost its control would strand the user.
|
||||||
if !group.is_zoomed() && control.is_none() {
|
if !group.is_zoomed() && control.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -569,7 +564,18 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
let Some(panel) = group.active_panel() else {
|
let Some(panel) = group.active_panel() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
close_panel(&shared, &group, panel, window, cx);
|
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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -607,23 +613,17 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||||
let is_bottom_dock = bottom_dock_button.is_some();
|
let is_bottom_dock = bottom_dock_button.is_some();
|
||||||
|
|
||||||
// On macOS the traffic lights overlay the window's top-left corner, so the
|
// On macOS the traffic lights overlay the window's top-left corner.
|
||||||
// tab bar under them reserves the space. That is the center's top-left group
|
// Only the tab bar that sits under them reserves the space.
|
||||||
// when the left dock is closed or absent, and the zoomed group otherwise:
|
// That is the center's top-left group when the left dock is closed or absent.
|
||||||
// a zoom fills the whole area, hiding the docks the frame would draw.
|
|
||||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||||
let area = area.read(cx);
|
let area = area.read(cx);
|
||||||
match area.zoomed_group() {
|
|
||||||
Some(zoomed) => zoomed == group.node(),
|
|
||||||
None => {
|
|
||||||
!area.is_dock_open(DockPlacement::Left)
|
!area.is_dock_open(DockPlacement::Left)
|
||||||
&& area
|
&& area
|
||||||
.layout(DockPlacement::Center)
|
.layout(DockPlacement::Center)
|
||||||
.and_then(|tree| left_top_group(tree.root()))
|
.and_then(|tree| left_top_group(tree.root()))
|
||||||
== Some(group.node())
|
== Some(group.node())
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bring a newly displayed tab into view.
|
// Bring a newly displayed tab into view.
|
||||||
@@ -709,16 +709,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.child(
|
.child(
|
||||||
Tabs::new("tab-bar")
|
Tabs::new("tab-bar")
|
||||||
.px(px(-1.))
|
.px(px(-1.))
|
||||||
// A collapsed group stands in for a closed bottom dock,
|
.h(TAB_BAR_HEIGHT)
|
||||||
// which base gives only its reserved strip. The bar keeps
|
|
||||||
// its own height there rather than the title-bar height,
|
|
||||||
// so it fits instead of being clipped away.
|
|
||||||
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
|
|
||||||
// Only a collapsed (bottom) dock tags its bar, so the
|
|
||||||
// selector is unique when a test measures it.
|
|
||||||
.when(collapsed, |this| {
|
|
||||||
this.debug_selector(|| COLLAPSED_TAB_BAR_SELECTOR.to_string())
|
|
||||||
})
|
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.text_color(cx.theme().tab_foreground)
|
.text_color(cx.theme().tab_foreground)
|
||||||
@@ -772,6 +763,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
|||||||
.and_then(PanelHandle::of)
|
.and_then(PanelHandle::of)
|
||||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||||
)
|
)
|
||||||
|
.child(self.render_toolbar(group, window, cx))
|
||||||
.children(right_dock_button),
|
.children(right_dock_button),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -841,123 +833,3 @@ 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
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,6 +7,7 @@ use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
|
|||||||
|
|
||||||
use crate::TAB_BAR_HEIGHT;
|
use crate::TAB_BAR_HEIGHT;
|
||||||
|
|
||||||
|
/// The standard width of a window control button.
|
||||||
const CONTROL_WIDTH: f32 = 34.;
|
const CONTROL_WIDTH: f32 = 34.;
|
||||||
|
|
||||||
#[derive(IntoElement, Clone)]
|
#[derive(IntoElement, Clone)]
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "metadata"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
signed_state = { path = "../signed_state" }
|
|
||||||
utils = { path = "../utils" }
|
|
||||||
|
|
||||||
nostr-sdk.workspace = true
|
|
||||||
flume.workspace = true
|
|
||||||
futures.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
gpui.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
mod profile;
|
|
||||||
|
|
||||||
use gpui::{App, AppContext};
|
|
||||||
pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore};
|
|
||||||
|
|
||||||
/// Initialize the global profile store.
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
|
||||||
}
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use flume::Sender;
|
|
||||||
use gpui::{
|
|
||||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
|
||||||
};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use signed_state::{Backend, BackendEvent, sync_bootstrap_only};
|
|
||||||
use utils::shorten_pubkey;
|
|
||||||
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
const REQUEST_CHUNK: usize = 100;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Profile {
|
|
||||||
public_key: PublicKey,
|
|
||||||
metadata: Metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Profile {
|
|
||||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
|
||||||
Self {
|
|
||||||
public_key,
|
|
||||||
metadata,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn public_key(&self) -> PublicKey {
|
|
||||||
self.public_key
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn metadata(&self) -> &Metadata {
|
|
||||||
&self.metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
// Falls back to `name`, then a shortened npub.
|
|
||||||
pub fn name(&self) -> SharedString {
|
|
||||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
|
||||||
&& !display_name.is_empty()
|
|
||||||
{
|
|
||||||
return SharedString::from(display_name.trim().to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = self.metadata.name.as_ref()
|
|
||||||
&& !name.is_empty()
|
|
||||||
{
|
|
||||||
return SharedString::from(name.trim().to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
SharedString::from(shorten_pubkey(self.public_key))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn picture(&self) -> Option<SharedString> {
|
|
||||||
self.metadata
|
|
||||||
.picture
|
|
||||||
.as_ref()
|
|
||||||
.filter(|p| !p.is_empty())
|
|
||||||
.map(|p| SharedString::from(p.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The newest profile per author from metadata events.
|
|
||||||
fn latest_profiles(events: impl IntoIterator<Item = Event>) -> Vec<Self> {
|
|
||||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
|
||||||
|
|
||||||
for event in events {
|
|
||||||
match latest.get(&event.pubkey) {
|
|
||||||
Some((created_at, _)) if *created_at >= event.created_at => {}
|
|
||||||
_ => {
|
|
||||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
|
||||||
latest.insert(event.pubkey, (event.created_at, metadata));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
latest
|
|
||||||
.into_iter()
|
|
||||||
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata for these authors may have changed in the local database.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum ProfileEvent {
|
|
||||||
Updated(Vec<PublicKey>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Batches metadata requests into relay syncs feeding the local database.
|
|
||||||
pub struct ProfileStore {
|
|
||||||
in_flight: RefCell<HashSet<PublicKey>>,
|
|
||||||
sender: Sender<PublicKey>,
|
|
||||||
_task: Task<Result<(), anyhow::Error>>,
|
|
||||||
_subscription: Subscription,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlobalProfileStore(Entity<ProfileStore>);
|
|
||||||
|
|
||||||
impl Global for GlobalProfileStore {}
|
|
||||||
|
|
||||||
impl EventEmitter<ProfileEvent> for ProfileStore {}
|
|
||||||
|
|
||||||
impl ProfileStore {
|
|
||||||
pub(crate) fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalProfileStore>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalProfileStore(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
|
||||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
|
||||||
this.emit_updated(authors.clone(), cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
|
||||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match receiver.recv_async().await {
|
|
||||||
Ok(public_key) => {
|
|
||||||
batch.insert(public_key);
|
|
||||||
}
|
|
||||||
Err(_) => return Ok(()),
|
|
||||||
}
|
|
||||||
|
|
||||||
// The channel has no async timeout, race the receive against a timer.
|
|
||||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
if now >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
|
||||||
futures::pin_mut!(timer);
|
|
||||||
|
|
||||||
let recv = receiver.recv_async();
|
|
||||||
futures::pin_mut!(recv);
|
|
||||||
|
|
||||||
match futures::future::select(recv, timer).await {
|
|
||||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
|
||||||
batch.insert(public_key);
|
|
||||||
}
|
|
||||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
|
||||||
futures::future::Either::Right(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let authors: Vec<PublicKey> = batch.drain().collect();
|
|
||||||
|
|
||||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
|
||||||
let opts = SyncOptions::default();
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::Metadata)
|
|
||||||
.authors(chunk.iter().copied());
|
|
||||||
|
|
||||||
if let Err(e) = sync_bootstrap_only(&client, filter, opts).await {
|
|
||||||
log::warn!("profile fetch failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit even when the sync failed, the database may hold newer data.
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.emit_updated(authors, cx);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
in_flight: RefCell::new(HashSet::new()),
|
|
||||||
sender,
|
|
||||||
_task: task,
|
|
||||||
_subscription: subscription,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Queue a batched relay sync for keys without one in flight.
|
|
||||||
pub fn fetch(&self, keys: impl IntoIterator<Item = PublicKey>) {
|
|
||||||
let mut queue = Vec::new();
|
|
||||||
{
|
|
||||||
let mut in_flight = self.in_flight.borrow_mut();
|
|
||||||
|
|
||||||
for key in keys {
|
|
||||||
if in_flight.insert(key) {
|
|
||||||
queue.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for public_key in queue {
|
|
||||||
if let Err(e) = self.sender.send(public_key) {
|
|
||||||
log::warn!("failed to queue profile fetch: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Releases the in-flight keys so future fetches can be queued again.
|
|
||||||
fn emit_updated(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
|
||||||
if authors.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut in_flight = self.in_flight.borrow_mut();
|
|
||||||
for author in &authors {
|
|
||||||
in_flight.remove(author);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.emit(ProfileEvent::Updated(authors));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile cache bound to one panel or screen.
|
|
||||||
pub struct ProfileDirectory {
|
|
||||||
profiles: HashMap<PublicKey, Profile>,
|
|
||||||
pending: RefCell<HashSet<PublicKey>>,
|
|
||||||
tasks: Vec<Task<()>>,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProfileDirectory {
|
|
||||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
|
||||||
let store = ProfileStore::global(cx);
|
|
||||||
|
|
||||||
cx.new(|cx| {
|
|
||||||
let subscription =
|
|
||||||
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
|
||||||
ProfileEvent::Updated(authors) => this.refresh(authors, cx),
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
profiles: HashMap::new(),
|
|
||||||
pending: RefCell::new(HashSet::new()),
|
|
||||||
tasks: Vec::new(),
|
|
||||||
_subscriptions: vec![subscription],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve keys from the local database, queue the rest for a relay sync.
|
|
||||||
pub fn track<K>(&mut self, keys: K, cx: &mut Context<Self>)
|
|
||||||
where
|
|
||||||
K: IntoIterator<Item = PublicKey>,
|
|
||||||
{
|
|
||||||
let missing: Vec<PublicKey> = keys
|
|
||||||
.into_iter()
|
|
||||||
.filter(|key| {
|
|
||||||
!self.profiles.contains_key(key)
|
|
||||||
&& !self.with_pending(|pending| pending.contains(key))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if missing.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.load(missing, true, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The profile for a key, or a placeholder that queues a relay sync.
|
|
||||||
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
|
||||||
if let Some(profile) = self.profiles.get(public_key) {
|
|
||||||
return profile.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.with_pending(|pending| pending.insert(*public_key)) {
|
|
||||||
let store = ProfileStore::global(cx);
|
|
||||||
store.read(cx).fetch([*public_key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Profile::new(*public_key, Metadata::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Refreshes profiles for keys that are already tracked.
|
|
||||||
fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context<Self>) {
|
|
||||||
let tracked: Vec<PublicKey> = authors
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|key| {
|
|
||||||
self.profiles.contains_key(key)
|
|
||||||
|| self.with_pending(|pending| pending.contains(key))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if tracked.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.load(tracked, false, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads profiles from the local database, queueing a relay sync for any missing keys.
|
|
||||||
fn load(&mut self, keys: Vec<PublicKey>, queue_sync: bool, cx: &mut Context<Self>) {
|
|
||||||
// Accept events that arrive while the database query is running.
|
|
||||||
self.with_pending(|pending| {
|
|
||||||
pending.extend(keys.iter().copied());
|
|
||||||
});
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
let query_keys = keys.clone();
|
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
|
||||||
let authors = query_keys.iter().copied();
|
|
||||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
Ok::<_, anyhow::Error>(Profile::latest_profiles(events))
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
|
||||||
let loaded = work.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
let remaining = match loaded {
|
|
||||||
Ok(profiles) => {
|
|
||||||
let found: HashSet<PublicKey> =
|
|
||||||
profiles.iter().map(Profile::public_key).collect();
|
|
||||||
this.insert_loaded(profiles, cx);
|
|
||||||
keys.into_iter()
|
|
||||||
.filter(|key| !found.contains(key))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!("failed to load profiles from the database: {e}");
|
|
||||||
keys
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if queue_sync && !remaining.is_empty() {
|
|
||||||
let store = ProfileStore::global(cx);
|
|
||||||
store.read(cx).fetch(remaining);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inserts database results, replacing anything loaded earlier.
|
|
||||||
fn insert_loaded(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
|
||||||
let mut inserted = false;
|
|
||||||
|
|
||||||
for profile in profiles {
|
|
||||||
let public_key = profile.public_key();
|
|
||||||
self.profiles.insert(public_key, profile);
|
|
||||||
self.with_pending(|pending| pending.remove(&public_key));
|
|
||||||
inserted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if inserted {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_pending<R, T>(&self, then: T) -> R
|
|
||||||
where
|
|
||||||
T: FnOnce(&mut HashSet<PublicKey>) -> R,
|
|
||||||
{
|
|
||||||
let mut pending = self.pending.borrow_mut();
|
|
||||||
then(&mut pending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-12
@@ -2,12 +2,10 @@ use std::path::PathBuf;
|
|||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
/// The application name.
|
/// The application name.
|
||||||
///
|
|
||||||
/// It derives the platform-specific data, config and cache directory paths.
|
/// It derives the platform-specific data, config and cache directory paths.
|
||||||
pub const APP_NAME: &str = "Signed";
|
pub const APP_NAME: &str = "Signed";
|
||||||
|
|
||||||
/// Lowercased form of [`APP_NAME`].
|
/// Lowercased form of [`APP_NAME`].
|
||||||
///
|
|
||||||
/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
|
/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
|
||||||
pub const APP_NAME_LOWERCASE: &str = "signed";
|
pub const APP_NAME_LOWERCASE: &str = "signed";
|
||||||
|
|
||||||
@@ -23,25 +21,25 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
|||||||
/// On Windows, this is `%APPDATA%\Signed`.
|
/// On Windows, this is `%APPDATA%\Signed`.
|
||||||
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
fn home_dir() -> PathBuf {
|
/// Returns the current user's home directory.
|
||||||
|
pub fn home_dir() -> PathBuf {
|
||||||
dirs::home_dir().expect("failed to determine home directory")
|
dirs::home_dir().expect("failed to determine home directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current user's Desktop folder.
|
/// Returns the current user's Desktop folder.
|
||||||
///
|
|
||||||
/// Falls back to the home directory or an empty path when it cannot be determined.
|
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||||
pub fn desktop_dir() -> PathBuf {
|
pub fn desktop_dir() -> PathBuf {
|
||||||
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the current user's Documents folder.
|
/// Returns the current user's Documents folder.
|
||||||
///
|
|
||||||
/// Falls back to the home directory or an empty path when it cannot be determined.
|
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||||
pub fn documents_dir() -> PathBuf {
|
pub fn documents_dir() -> PathBuf {
|
||||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_dir() -> &'static PathBuf {
|
/// Returns the path to the configuration directory.
|
||||||
|
pub fn config_dir() -> &'static PathBuf {
|
||||||
CONFIG_DIR.get_or_init(|| {
|
CONFIG_DIR.get_or_init(|| {
|
||||||
if cfg!(target_os = "windows") {
|
if cfg!(target_os = "windows") {
|
||||||
dirs::config_dir()
|
dirs::config_dir()
|
||||||
@@ -60,7 +58,8 @@ fn config_dir() -> &'static PathBuf {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn data_dir() -> &'static PathBuf {
|
/// Returns the path to the data directory.
|
||||||
|
pub fn data_dir() -> &'static PathBuf {
|
||||||
CURRENT_DATA_DIR.get_or_init(|| {
|
CURRENT_DATA_DIR.get_or_init(|| {
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
home_dir()
|
home_dir()
|
||||||
@@ -90,15 +89,12 @@ pub fn nostr_dir() -> &'static PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the path to the local git clone cache, the grasp mirrors.
|
/// Returns the path to the local git clone cache, the grasp mirrors.
|
||||||
///
|
|
||||||
/// The mirrors are disposable and re-cloned from their grasp server on
|
|
||||||
/// demand, so the cache lives in the OS temp directory for the system to
|
|
||||||
/// reclaim.
|
|
||||||
pub fn repos_dir() -> &'static PathBuf {
|
pub fn repos_dir() -> &'static PathBuf {
|
||||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
REPOS_DIR.get_or_init(|| std::env::temp_dir().join(APP_NAME_LOWERCASE).join("repos"))
|
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the `settings.json` file.
|
||||||
pub fn settings_file() -> &'static PathBuf {
|
pub fn settings_file() -> &'static PathBuf {
|
||||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
|||||||
+112
-43
@@ -2,32 +2,66 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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] = [
|
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||||
"wss://relay.ngit.dev",
|
"wss://relay.ngit.dev",
|
||||||
"wss://gitnostr.com",
|
"wss://gitnostr.com",
|
||||||
"wss://git.shakespeare.diy",
|
"wss://git.shakespeare.diy",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// How the application picks its appearance.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AppearanceMode {
|
pub enum AppearanceMode {
|
||||||
|
/// Follow the system appearance, light or dark, at runtime.
|
||||||
#[default]
|
#[default]
|
||||||
System,
|
System,
|
||||||
|
/// Always use the light theme.
|
||||||
Light,
|
Light,
|
||||||
|
/// Always use the dark theme.
|
||||||
Dark,
|
Dark,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
/// Theme configuration,
|
||||||
#[serde(rename_all = "snake_case")]
|
/// fields mirror the gpui-component `Theme` surface customized at startup.
|
||||||
pub enum EventFetchingStrategy {
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
/// Only the relays declared in the repository announcement.
|
#[serde(default)]
|
||||||
Curated,
|
pub struct ThemeSettings {
|
||||||
/// Repository relays plus every maintainer's NIP-65 relays.
|
/// Name of the light theme in the theme registry.
|
||||||
#[default]
|
pub light_theme: String,
|
||||||
Uncensored,
|
/// Name of the dark theme in the theme registry.
|
||||||
|
pub dark_theme: String,
|
||||||
|
/// The base font size in pixels.
|
||||||
|
pub font_size: f32,
|
||||||
|
/// The monospace font size in pixels.
|
||||||
|
pub mono_font_size: f32,
|
||||||
|
/// Corner radius for general elements in pixels.
|
||||||
|
pub radius: f32,
|
||||||
|
/// Corner radius for large elements, dialogs and notifications, in pixels.
|
||||||
|
pub radius_lg: f32,
|
||||||
|
/// Whether focused controls draw a ring outside their border.
|
||||||
|
pub focus_ring: bool,
|
||||||
|
/// Whether to render shadows.
|
||||||
|
pub shadow: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
light_theme: "Signed Light".into(),
|
||||||
|
dark_theme: "Signed Dark".into(),
|
||||||
|
font_size: 16.0,
|
||||||
|
mono_font_size: 13.0,
|
||||||
|
radius: 2.0,
|
||||||
|
radius_lg: 6.0,
|
||||||
|
focus_ring: false,
|
||||||
|
shadow: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default grasp server settings.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct GraspServersSettings {
|
pub struct GraspServersSettings {
|
||||||
@@ -46,21 +80,23 @@ impl Default for GraspServersSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local repository scanning.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct LocalReposSettings {
|
pub struct LocalReposSettings {
|
||||||
/// The directories scanned for local git repositories,
|
/// The directories scanned for local git repositories,
|
||||||
/// Defaults:
|
/// defaults to the user's Desktop and Documents folders.
|
||||||
///
|
|
||||||
/// - `~/Desktop`
|
|
||||||
/// - `~/Documents`
|
|
||||||
pub scan_paths: Vec<PathBuf>,
|
pub scan_paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_scan_paths() -> Vec<PathBuf> {
|
||||||
|
vec![paths::desktop_dir(), paths::documents_dir()]
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for LocalReposSettings {
|
impl Default for LocalReposSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
scan_paths: vec![paths::desktop_dir(), paths::documents_dir()],
|
scan_paths: default_scan_paths(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +108,7 @@ impl Default for LocalReposSettings {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CheckoutRecord {
|
pub struct CheckoutRecord {
|
||||||
|
/// Local folder of the checkout.
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// Repository address as a string, `30617:<pubkey>:<id>`.
|
/// Repository address as a string, `30617:<pubkey>:<id>`.
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
@@ -79,13 +116,16 @@ pub struct CheckoutRecord {
|
|||||||
pub last_used: u64,
|
pub last_used: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remembered local checkouts, see [`CheckoutRecord`].
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CheckoutsSettings {
|
pub struct CheckoutsSettings {
|
||||||
|
/// The remembered records.
|
||||||
/// The latest use of a path and repo pair replaces the older record.
|
/// The latest use of a path and repo pair replaces the older record.
|
||||||
pub records: Vec<CheckoutRecord>,
|
pub records: Vec<CheckoutRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The create-repository dialog.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CreateRepositorySettings {
|
pub struct CreateRepositorySettings {
|
||||||
@@ -97,11 +137,17 @@ pub struct CreateRepositorySettings {
|
|||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
|
/// How the application picks its appearance.
|
||||||
pub appearance: AppearanceMode,
|
pub appearance: AppearanceMode,
|
||||||
pub event_fetching: EventFetchingStrategy,
|
/// Theme configuration.
|
||||||
|
pub theme: ThemeSettings,
|
||||||
|
/// Default grasp servers.
|
||||||
pub grasp_servers: GraspServersSettings,
|
pub grasp_servers: GraspServersSettings,
|
||||||
|
/// Local repository scanning.
|
||||||
pub local_repos: LocalReposSettings,
|
pub local_repos: LocalReposSettings,
|
||||||
|
/// Remembered local checkouts.
|
||||||
pub checkouts: CheckoutsSettings,
|
pub checkouts: CheckoutsSettings,
|
||||||
|
/// The create-repository dialog.
|
||||||
pub create_repository: CreateRepositorySettings,
|
pub create_repository: CreateRepositorySettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,11 +155,38 @@ pub struct Settings {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn json_roundtrip_preserves_everything() {
|
fn json_roundtrip_preserves_everything() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
appearance: AppearanceMode::Dark,
|
appearance: AppearanceMode::Dark,
|
||||||
event_fetching: EventFetchingStrategy::Curated,
|
theme: ThemeSettings {
|
||||||
|
radius: 8.0,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
create_repository: CreateRepositorySettings {
|
create_repository: CreateRepositorySettings {
|
||||||
default_folder: Some(PathBuf::from("/tmp/repos")),
|
default_folder: Some(PathBuf::from("/tmp/repos")),
|
||||||
},
|
},
|
||||||
@@ -125,41 +198,37 @@ mod tests {
|
|||||||
assert_eq!(parsed, settings);
|
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]
|
#[test]
|
||||||
fn partial_json_merges_with_defaults() {
|
fn partial_json_merges_with_defaults() {
|
||||||
let settings: Settings = serde_json::from_str(
|
let settings: Settings =
|
||||||
r#"{"appearance": "dark", "grasp_servers": {"default_servers": []}}"#,
|
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||||
assert_eq!(settings.grasp_servers.default_servers, Vec::<String>::new());
|
assert_eq!(settings.theme.radius, 4.0);
|
||||||
// Unset fields fall back to their defaults, including the fetching strategy.
|
// The rest of the theme and the other groups keep their defaults.
|
||||||
assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored);
|
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||||
assert_eq!(settings.local_repos, LocalReposSettings::default());
|
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
||||||
assert_eq!(settings.create_repository.default_folder, None);
|
assert_eq!(settings.create_repository.default_folder, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_theme_keys_are_ignored() {
|
fn appearance_serializes_to_snake_case_names() {
|
||||||
let settings: Settings = serde_json::from_str(
|
|
||||||
r#"{"appearance": "light", "theme": {"radius": 8.0}, "tab_bar": {"hide_navigation_buttons": false}}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(settings.appearance, AppearanceMode::Light);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
settings,
|
serde_json::to_string(&AppearanceMode::System).unwrap(),
|
||||||
Settings {
|
"\"system\""
|
||||||
appearance: AppearanceMode::Light,
|
);
|
||||||
..Default::default()
|
assert_eq!(
|
||||||
}
|
serde_json::to_string(&AppearanceMode::Light).unwrap(),
|
||||||
|
"\"light\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&AppearanceMode::Dark).unwrap(),
|
||||||
|
"\"dark\""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn event_fetching_uses_snake_case() {
|
|
||||||
let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap();
|
|
||||||
assert_eq!(json, r#""curated""#);
|
|
||||||
let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap();
|
|
||||||
assert_eq!(parsed, EventFetchingStrategy::Uncensored);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,7 @@ impl SettingsStore {
|
|||||||
cx.global::<GlobalSettingsStore>().0.clone()
|
cx.global::<GlobalSettingsStore>().0.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve the global settings store if one has been installed.
|
/// Install the store as a global.
|
||||||
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
|
|
||||||
cx.try_global::<GlobalSettingsStore>()
|
|
||||||
.map(|store| store.0.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||||
cx.set_global(GlobalSettingsStore(entity));
|
cx.set_global(GlobalSettingsStore(entity));
|
||||||
}
|
}
|
||||||
@@ -108,6 +103,8 @@ impl SettingsStore {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use gpui::{AppContext, TestAppContext};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
@@ -136,6 +133,16 @@ mod tests {
|
|||||||
cleanup(&path);
|
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]
|
#[test]
|
||||||
fn save_and_load_roundtrip() {
|
fn save_and_load_roundtrip() {
|
||||||
let path = temp_settings_path();
|
let path = temp_settings_path();
|
||||||
@@ -153,4 +160,21 @@ mod tests {
|
|||||||
assert_eq!(SettingsStore::load(&path), expected);
|
assert_eq!(SettingsStore::load(&path), expected);
|
||||||
cleanup(&path);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "signed_app"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
assets = { path = "../assets" }
|
|
||||||
metadata = { path = "../metadata" }
|
|
||||||
paths = { path = "../paths" }
|
|
||||||
settings = { path = "../settings" }
|
|
||||||
signed_core = { path = "../signed_core" }
|
|
||||||
signed_git = { path = "../signed_git" }
|
|
||||||
signed_nostr = { path = "../signed_nostr" }
|
|
||||||
signed_state = { path = "../signed_state" }
|
|
||||||
utils = { path = "../utils" }
|
|
||||||
|
|
||||||
gpui.workspace = true
|
|
||||||
gpui-native-kit.workspace = true
|
|
||||||
nostr.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
futures.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
|
|
||||||
Subscription, Window, div,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::icons::IconRole;
|
|
||||||
use gpui_native_kit::layout::dock::{Dock, DockState};
|
|
||||||
use gpui_native_kit::layout::shell::{LayoutState, Shell, SidebarToggles};
|
|
||||||
use gpui_native_kit::theme::{ActiveTheme as _, ThemeVariant, follow_system, set_mode_override};
|
|
||||||
use gpui_native_kit::toast::Toast;
|
|
||||||
use settings::{AppearanceMode, Settings, SettingsStore};
|
|
||||||
use signed_state::{Backend, BackendEvent};
|
|
||||||
|
|
||||||
use crate::dialogs::{self, DialogHost};
|
|
||||||
use crate::views::placeholder::PlaceholderView;
|
|
||||||
use crate::views::sidebar::SidebarView;
|
|
||||||
|
|
||||||
pub struct AppRoot {
|
|
||||||
layout: Entity<LayoutState>,
|
|
||||||
dock: Entity<DockState>,
|
|
||||||
sidebar: Entity<SidebarView>,
|
|
||||||
dialogs: Entity<DialogHost>,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppRoot {
|
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
||||||
let dock = DockState::new(cx);
|
|
||||||
let layout = LayoutState::new(cx);
|
|
||||||
|
|
||||||
layout.update(cx, |layout, cx| {
|
|
||||||
layout.set_sidebar_toggles(SidebarToggles::Hidden, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
dock.update(cx, |dock, cx| {
|
|
||||||
dock.register_panel("welcome", |_, cx| {
|
|
||||||
cx.new(|_| PlaceholderView::new("Welcome"))
|
|
||||||
});
|
|
||||||
dock.register_panel("profile", |_, cx| {
|
|
||||||
cx.new(|_| PlaceholderView::new("Profile"))
|
|
||||||
});
|
|
||||||
dock.register_panel("relays", |_, cx| cx.new(|_| PlaceholderView::new("Relays")));
|
|
||||||
dock.open_panel("welcome", window, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
let sidebar = cx.new(|cx| SidebarView::new(dock.downgrade(), cx));
|
|
||||||
let dialogs = cx.new(DialogHost::new);
|
|
||||||
DialogHost::set_global(dialogs.clone(), cx);
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let settings = SettingsStore::global(cx);
|
|
||||||
|
|
||||||
let mut subscriptions = Vec::new();
|
|
||||||
|
|
||||||
subscriptions.push(
|
|
||||||
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
|
||||||
BackendEvent::PassphraseRequired => dialogs::passphrase::open(cx),
|
|
||||||
BackendEvent::Error(message) => {
|
|
||||||
this.layout.update(cx, |layout, cx| {
|
|
||||||
layout.push_toast(Toast::new(message.clone()).icon(IconRole::Warning), cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&settings, |_this, store, cx| {
|
|
||||||
let settings = store.read(cx).settings().clone();
|
|
||||||
apply_appearance(&settings, cx);
|
|
||||||
}));
|
|
||||||
|
|
||||||
window.defer(cx, move |_window, cx| {
|
|
||||||
if backend.read(cx).passphrase_required() {
|
|
||||||
dialogs::passphrase::open(cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
layout,
|
|
||||||
dock,
|
|
||||||
sidebar,
|
|
||||||
dialogs,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for AppRoot {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let theme = cx.theme();
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.font_family(".SystemUIFont")
|
|
||||||
.text_color(theme.colors.foreground)
|
|
||||||
.child(
|
|
||||||
Shell::new("signed-shell", self.layout.clone())
|
|
||||||
.left_sidebar(self.sidebar.read(cx).build(cx))
|
|
||||||
.center(
|
|
||||||
Dock::new("signed-dock", self.dock.clone())
|
|
||||||
.window_controls(true)
|
|
||||||
.traffic_light_pad(!self.layout.read(cx).is_left_sidebar_visible())
|
|
||||||
.sidebar_toggles(self.layout.clone()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(self.dialogs.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_appearance(settings: &Settings, cx: &mut App) {
|
|
||||||
match settings.appearance {
|
|
||||||
AppearanceMode::System => follow_system(cx),
|
|
||||||
AppearanceMode::Light => set_mode_override(Some(ThemeVariant::Light), cx),
|
|
||||||
AppearanceMode::Dark => set_mode_override(Some(ThemeVariant::Dark), cx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
||||||
SharedString, Styled as _, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::avatar::Avatar;
|
|
||||||
use gpui_native_kit::clipboard::ClipboardButton;
|
|
||||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use metadata::ProfileDirectory;
|
|
||||||
use nostr::prelude::PublicKey;
|
|
||||||
use signed_core::Announcement;
|
|
||||||
use utils::middle_truncate;
|
|
||||||
|
|
||||||
use super::{close_global, open_global};
|
|
||||||
|
|
||||||
pub struct AboutDialog {
|
|
||||||
announcement: Announcement,
|
|
||||||
directory: Entity<ProfileDirectory>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AboutDialog {
|
|
||||||
pub fn new(
|
|
||||||
announcement: Announcement,
|
|
||||||
directory: Entity<ProfileDirectory>,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> Entity<Self> {
|
|
||||||
cx.new(|_| Self {
|
|
||||||
announcement,
|
|
||||||
directory,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for AboutDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let announcement = self.announcement.clone();
|
|
||||||
let directory = self.directory.clone();
|
|
||||||
|
|
||||||
Dialog::new("about-dialog")
|
|
||||||
.open(true)
|
|
||||||
.min_width(px(500.))
|
|
||||||
.on_change(|open, _, cx| {
|
|
||||||
if !open {
|
|
||||||
close_global(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.title("About")
|
|
||||||
.content(move |_, cx| announcement_rows(&announcement, &directory, cx))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn announcement_rows(
|
|
||||||
announcement: &Announcement,
|
|
||||||
directory: &Entity<ProfileDirectory>,
|
|
||||||
cx: &App,
|
|
||||||
) -> AnyElement {
|
|
||||||
let mut rows: Vec<AnyElement> = Vec::new();
|
|
||||||
|
|
||||||
rows.push(row(
|
|
||||||
"Name",
|
|
||||||
text(
|
|
||||||
announcement
|
|
||||||
.name
|
|
||||||
.as_deref()
|
|
||||||
.map(SharedString::from)
|
|
||||||
.unwrap_or_else(|| SharedString::from("-")),
|
|
||||||
),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
rows.push(row(
|
|
||||||
"Description",
|
|
||||||
text(
|
|
||||||
announcement
|
|
||||||
.description
|
|
||||||
.as_deref()
|
|
||||||
.map(SharedString::from)
|
|
||||||
.unwrap_or_else(|| SharedString::from("-")),
|
|
||||||
),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
|
|
||||||
if !announcement.web.is_empty() {
|
|
||||||
rows.push(row(
|
|
||||||
"Web",
|
|
||||||
list(
|
|
||||||
"about-web",
|
|
||||||
announcement.web.iter().map(|url| url.to_string()),
|
|
||||||
cx,
|
|
||||||
),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(euc) = &announcement.euc {
|
|
||||||
rows.push(row(
|
|
||||||
"Earliest Commit",
|
|
||||||
copy_value("about-euc", euc.clone(), cx),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(upstream) = &announcement.upstream {
|
|
||||||
rows.push(row("Upstream", text(upstream.display()), cx));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !announcement.hashtags.is_empty() {
|
|
||||||
rows.push(row(
|
|
||||||
"Hashtags",
|
|
||||||
text(SharedString::from(announcement.hashtags.join(", "))),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !announcement.clone.is_empty() {
|
|
||||||
rows.push(row(
|
|
||||||
"Clone URLs",
|
|
||||||
list(
|
|
||||||
"about-clone",
|
|
||||||
announcement.clone.iter().map(|url| url.to_string()),
|
|
||||||
cx,
|
|
||||||
),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !announcement.relays.is_empty() {
|
|
||||||
rows.push(row(
|
|
||||||
"Grasp Relays",
|
|
||||||
list(
|
|
||||||
"about-relays",
|
|
||||||
announcement.relays.iter().map(|url| url.to_string()),
|
|
||||||
cx,
|
|
||||||
),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !announcement.maintainers.is_empty() {
|
|
||||||
rows.push(row(
|
|
||||||
"Maintainers",
|
|
||||||
maintainers(&announcement.maintainers, directory, cx),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
v_flex().gap_3().w_full().children(rows).into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.min_w_0()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(label),
|
|
||||||
)
|
|
||||||
.child(value)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn text<T>(value: T) -> AnyElement
|
|
||||||
where
|
|
||||||
T: Into<SharedString>,
|
|
||||||
{
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.w_full()
|
|
||||||
.min_w_0()
|
|
||||||
.child(value.into())
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.items_center()
|
|
||||||
.min_w_0()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_family(cx.theme().mono_font)
|
|
||||||
.text_xs()
|
|
||||||
.min_w_0()
|
|
||||||
.child(SharedString::from(value.clone())),
|
|
||||||
)
|
|
||||||
.child(ClipboardButton::new(id, value))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maintainers(
|
|
||||||
maintainers: &[PublicKey],
|
|
||||||
directory: &Entity<ProfileDirectory>,
|
|
||||||
cx: &App,
|
|
||||||
) -> AnyElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.min_w_0()
|
|
||||||
.children(maintainers.iter().map(|pubkey| {
|
|
||||||
let profile = directory.read(cx).profile(pubkey, cx);
|
|
||||||
let name = profile.name();
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.items_center()
|
|
||||||
.min_w_0()
|
|
||||||
.child(
|
|
||||||
Avatar::new(*pubkey)
|
|
||||||
.name(name.clone())
|
|
||||||
.picture(profile.picture())
|
|
||||||
.small(),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.overflow_hidden()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.text_ellipsis()
|
|
||||||
.text_sm()
|
|
||||||
.child(name),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.min_w_0()
|
|
||||||
.children(items.into_iter().enumerate().map(|(ix, item)| {
|
|
||||||
h_flex()
|
|
||||||
.h_8()
|
|
||||||
.px_2()
|
|
||||||
.gap_2()
|
|
||||||
.min_w_0()
|
|
||||||
.bg(cx.theme().colors.secondary)
|
|
||||||
.rounded(cx.theme().radius.control)
|
|
||||||
.text_color(cx.theme().colors.secondary_foreground)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.text_ellipsis()
|
|
||||||
.text_sm()
|
|
||||||
.child(SharedString::from(middle_truncate(&item, 28, 16))),
|
|
||||||
)
|
|
||||||
.child(ClipboardButton::new(
|
|
||||||
SharedString::from(format!("{id}-{ix}")),
|
|
||||||
item,
|
|
||||||
))
|
|
||||||
.into_any_element()
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the read-only repository metadata dialog.
|
|
||||||
pub fn open(announcement: Announcement, directory: Entity<ProfileDirectory>, cx: &mut App) {
|
|
||||||
open_global(AboutDialog::new(announcement, directory, cx), cx);
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _,
|
|
||||||
PathPromptOptions, Render, Styled as _, WeakEntity, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
|
||||||
use gpui_native_kit::common::{h_flex, v_flex};
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::field::Field;
|
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
|
||||||
use gpui_native_kit::input::{Input, InputState};
|
|
||||||
use gpui_native_kit::layout::dock::DockState;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use gpui_native_kit::tooltip::Tooltip;
|
|
||||||
use settings::SettingsStore;
|
|
||||||
use signed_state::{Backend, CheckoutsStore};
|
|
||||||
|
|
||||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
|
||||||
use super::progress::{DialogProgress, error_row, push_steps_row};
|
|
||||||
use super::{close_global, open_global};
|
|
||||||
use crate::views::repo::actions::open_repo_panel;
|
|
||||||
|
|
||||||
/// Progress of the create-repository flow, so async results can be rendered.
|
|
||||||
pub struct CreateRepoDialog {
|
|
||||||
name_input: Entity<InputState>,
|
|
||||||
desc_input: Entity<InputState>,
|
|
||||||
folder_input: Entity<InputState>,
|
|
||||||
relay_input: Entity<InputState>,
|
|
||||||
state: Entity<DialogProgress>,
|
|
||||||
grasp_state: Entity<GraspServersState>,
|
|
||||||
dock: WeakEntity<DockState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CreateRepoDialog {
|
|
||||||
pub fn new(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
|
||||||
let settings = SettingsStore::global(cx);
|
|
||||||
let default_folder = settings
|
|
||||||
.read(cx)
|
|
||||||
.settings()
|
|
||||||
.create_repository
|
|
||||||
.default_folder
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(paths::desktop_dir);
|
|
||||||
let grasp_settings = settings.read(cx).settings().grasp_servers.clone();
|
|
||||||
|
|
||||||
let name_input = InputState::new(cx);
|
|
||||||
let desc_input = InputState::new(cx);
|
|
||||||
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
|
|
||||||
let folder_input = InputState::new_with_text(default_folder.to_string_lossy(), cx);
|
|
||||||
folder_input.update(cx, |state, cx| state.set_read_only(true, cx));
|
|
||||||
let relay_input = InputState::new(cx);
|
|
||||||
let state = cx.new(|_| DialogProgress::default());
|
|
||||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
|
||||||
|
|
||||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
|
||||||
|
|
||||||
cx.new(|_| Self {
|
|
||||||
name_input,
|
|
||||||
desc_input,
|
|
||||||
folder_input,
|
|
||||||
relay_input,
|
|
||||||
state,
|
|
||||||
grasp_state,
|
|
||||||
dock,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.state.read(cx).busy {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = self.name_input.read(cx).value().trim().to_owned();
|
|
||||||
let description = self.desc_input.read(cx).value().trim().to_owned();
|
|
||||||
let folder = PathBuf::from(self.folder_input.read(cx).value().trim());
|
|
||||||
let servers = self.grasp_state.read(cx).grasp_servers.clone();
|
|
||||||
|
|
||||||
if name.is_empty() {
|
|
||||||
self.fail("Repository name is required", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if servers.is_empty() {
|
|
||||||
self.fail("Add at least one grasp server", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
|
||||||
self.fail("Sign in to create a repository", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let addr =
|
|
||||||
signed_core::RepoAddr::new(user, signed_core::RepoAddr::identifier_from_name(&name));
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.begin_push(addr, cx);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = backend.update(cx, |backend, cx| {
|
|
||||||
backend.create_repository(&name, &description, folder, servers, cx)
|
|
||||||
});
|
|
||||||
let state = self.state.clone();
|
|
||||||
let dock = self.dock.clone();
|
|
||||||
let handle = window.window_handle();
|
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| match task.await {
|
|
||||||
Ok((announcement, local_path)) => {
|
|
||||||
cx.update_window(handle, |_, window, cx| {
|
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
|
||||||
checkouts.update(cx, |store, cx| {
|
|
||||||
store.record(local_path.clone(), announcement.addr(), cx);
|
|
||||||
});
|
|
||||||
cx.open_with_system(&local_path);
|
|
||||||
open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx);
|
|
||||||
close_global(cx);
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.fail(error.to_string());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fail(&mut self, message: impl Into<gpui::SharedString>, cx: &mut Context<Self>) {
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.fail(message);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for CreateRepoDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let name_input = self.name_input.clone();
|
|
||||||
let desc_input = self.desc_input.clone();
|
|
||||||
let folder_input = self.folder_input.clone();
|
|
||||||
let relay_input = self.relay_input.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let grasp_state = self.grasp_state.clone();
|
|
||||||
let action_state = state.clone();
|
|
||||||
let this = cx.entity().downgrade();
|
|
||||||
|
|
||||||
Dialog::new("create-repo-dialog")
|
|
||||||
.open(true)
|
|
||||||
.min_width(px(520.))
|
|
||||||
.on_change(|open, _, cx| {
|
|
||||||
if !open {
|
|
||||||
close_global(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.title("Create repository")
|
|
||||||
.content(move |_window, cx| {
|
|
||||||
let progress = state.read(cx);
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child("Publish a new repository to your grasp servers."),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("repo-name", "Repository name")
|
|
||||||
.description("Max 100 characters")
|
|
||||||
.child(Input::new("repo-name-input", name_input.clone())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("repo-description", "Description").child(
|
|
||||||
Input::new("repo-description-input", desc_input.clone()).rows(3),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("repo-folder", "Folder")
|
|
||||||
.description("Where the repository's working copy is created.")
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.items_center()
|
|
||||||
.child(div().flex_1().child(Input::new(
|
|
||||||
"repo-folder-input",
|
|
||||||
folder_input.clone(),
|
|
||||||
)))
|
|
||||||
.child(
|
|
||||||
Button::new("choose-folder")
|
|
||||||
.icon(Icon::named(IconRole::Folder))
|
|
||||||
.variant(ButtonVariant::Ghost)
|
|
||||||
.tooltip(Tooltip::text("Choose folder"))
|
|
||||||
.on_click({
|
|
||||||
let folder_input = folder_input.clone();
|
|
||||||
move |_, window, cx| {
|
|
||||||
choose_folder(&folder_input, window, cx);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
|
|
||||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
|
||||||
el.child(steps)
|
|
||||||
})
|
|
||||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.actions(move |_, cx| {
|
|
||||||
let busy = action_state.read(cx).busy;
|
|
||||||
let this = this.clone();
|
|
||||||
|
|
||||||
let create: AnyElement = Button::new("create-repository")
|
|
||||||
.label("Create repository")
|
|
||||||
.icon(Icon::named(IconRole::Plus))
|
|
||||||
.loading(busy)
|
|
||||||
.disabled(busy)
|
|
||||||
.on_click(move |_, window, cx| {
|
|
||||||
let _ = this.update(cx, |this, cx| this.create(window, cx));
|
|
||||||
})
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
vec![create]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
|
||||||
let store = SettingsStore::global(cx);
|
|
||||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
|
||||||
files: false,
|
|
||||||
directories: true,
|
|
||||||
multiple: false,
|
|
||||||
prompt: Some("Choose folder".into()),
|
|
||||||
});
|
|
||||||
let handle = window.window_handle();
|
|
||||||
let folder_input = folder_input.clone();
|
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
|
||||||
if let Ok(Ok(Some(mut paths))) = prompt.await
|
|
||||||
&& let Some(path) = paths.pop()
|
|
||||||
{
|
|
||||||
if let Err(error) = cx.update_window(handle, |_, window, cx| {
|
|
||||||
let text = path.to_string_lossy().to_string();
|
|
||||||
store.update(cx, |store, cx| {
|
|
||||||
store.edit(
|
|
||||||
|settings| {
|
|
||||||
settings.create_repository.default_folder = Some(path.clone());
|
|
||||||
},
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
folder_input.update(cx, |input, cx| input.set_value(text, window, cx));
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to apply selected folder: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the create-repository form.
|
|
||||||
pub fn open(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) {
|
|
||||||
open_global(CreateRepoDialog::new(dock, window, cx), cx);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, Render,
|
|
||||||
Styled as _, Window, div,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
|
||||||
use gpui_native_kit::common::v_flex;
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_state::Backend;
|
|
||||||
|
|
||||||
use super::{close_global, open_global};
|
|
||||||
|
|
||||||
pub struct ExitDialog;
|
|
||||||
|
|
||||||
impl Render for ExitDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
Dialog::new("exit-dialog")
|
|
||||||
.open(true)
|
|
||||||
.on_change(|open, _, cx| {
|
|
||||||
if !open {
|
|
||||||
close_global(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.title("Exit Signed?")
|
|
||||||
.content(|_, cx| {
|
|
||||||
v_flex()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(
|
|
||||||
"You will be signed out on this device and will need to sign in again on the next session.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.actions(|_, _| {
|
|
||||||
let cancel: AnyElement = Button::new("cancel-exit")
|
|
||||||
.label("Cancel")
|
|
||||||
.variant(ButtonVariant::Secondary)
|
|
||||||
.on_click(|_, _, cx| close_global(cx))
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
let exit: AnyElement = Button::new("confirm-exit")
|
|
||||||
.label("Exit")
|
|
||||||
.variant(ButtonVariant::Destructive)
|
|
||||||
.on_click(|_, _, cx| {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
backend.update(cx, |backend, cx| backend.sign_out(cx));
|
|
||||||
close_global(cx);
|
|
||||||
})
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
vec![cancel, exit]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the sign-out confirmation.
|
|
||||||
pub fn open(cx: &mut App) {
|
|
||||||
open_global(cx.new(|_| ExitDialog), cx);
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, SharedString,
|
|
||||||
Styled as _, Window, div,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
|
||||||
use gpui_native_kit::common::{h_flex, v_flex};
|
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
|
||||||
use gpui_native_kit::input::{Input, InputState};
|
|
||||||
use gpui_native_kit::switch::Switch;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use gpui_native_kit::tooltip::Tooltip;
|
|
||||||
use nostr::prelude::RelayUrl;
|
|
||||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
|
||||||
use signed_state::Backend;
|
|
||||||
|
|
||||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct GraspServersState {
|
|
||||||
/// Set while the user's kind `10317` grasp list loads.
|
|
||||||
pub loading_servers: bool,
|
|
||||||
pub grasp_servers: Vec<RelayUrl>,
|
|
||||||
pub servers_enabled: bool,
|
|
||||||
/// 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 and replaces them.
|
|
||||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
|
||||||
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
|
||||||
DEFAULT_GRASP_SERVERS
|
|
||||||
.iter()
|
|
||||||
.map(|url| (*url).to_owned())
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
settings.default_servers.clone()
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
loading_servers: true,
|
|
||||||
servers_enabled: false,
|
|
||||||
grasp_servers: urls
|
|
||||||
.iter()
|
|
||||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
|
||||||
.collect(),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Grasp servers section shared by the publish dialogs.
|
|
||||||
pub fn grasp_servers_field(
|
|
||||||
state: &Entity<GraspServersState>,
|
|
||||||
relay_input: &Entity<InputState>,
|
|
||||||
cx: &App,
|
|
||||||
) -> AnyElement {
|
|
||||||
let state = state.clone();
|
|
||||||
let relay_input = relay_input.clone();
|
|
||||||
|
|
||||||
let servers = state.read(cx).grasp_servers.clone();
|
|
||||||
let loading_servers = state.read(cx).loading_servers;
|
|
||||||
let servers_enabled = state.read(cx).servers_enabled;
|
|
||||||
let error = state.read(cx).error.clone();
|
|
||||||
|
|
||||||
let add_state = state.clone();
|
|
||||||
let add_input = relay_input.clone();
|
|
||||||
let toggle_state = state.clone();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.justify_between()
|
|
||||||
.items_center()
|
|
||||||
.child(div().text_sm().child("Grasp servers"))
|
|
||||||
.child(
|
|
||||||
Switch::new("grasp-servers-toggle")
|
|
||||||
.checked(servers_enabled)
|
|
||||||
.on_change(move |checked, _, cx| {
|
|
||||||
toggle_state.update(cx, |state, cx| {
|
|
||||||
state.servers_enabled = checked;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when(servers_enabled, |section| {
|
|
||||||
section
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child("Where the repository is hosted"),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex().gap_1().children(
|
|
||||||
servers
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.items_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.child(Input::new("grasp-relay-input", relay_input.clone())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("add-relay")
|
|
||||||
.icon(Icon::named(IconRole::Plus))
|
|
||||||
.variant(ButtonVariant::Ghost)
|
|
||||||
.tooltip(Tooltip::text("Add grasp server"))
|
|
||||||
.on_click(move |_, window, cx| {
|
|
||||||
add_relay(&add_state, &add_input, window, cx);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when(loading_servers, |section| {
|
|
||||||
section.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child("Loading your grasp servers..."),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when_some(error, |section, error| {
|
|
||||||
section.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().ramps.destructive.base)
|
|
||||||
.child(error),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_server_row(
|
|
||||||
ix: usize,
|
|
||||||
relay: &RelayUrl,
|
|
||||||
state: Entity<GraspServersState>,
|
|
||||||
cx: &App,
|
|
||||||
) -> AnyElement {
|
|
||||||
h_flex()
|
|
||||||
.w_full()
|
|
||||||
.gap_1()
|
|
||||||
.items_center()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.h_8()
|
|
||||||
.px_2()
|
|
||||||
.bg(cx.theme().colors.muted)
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.text_sm()
|
|
||||||
.rounded(cx.theme().radius.control)
|
|
||||||
.child(server_host(relay)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new(SharedString::from(format!("remove-relay:{ix}")))
|
|
||||||
.icon(Icon::named(IconRole::Close))
|
|
||||||
.variant(ButtonVariant::Ghost)
|
|
||||||
.tooltip(Tooltip::text("Remove"))
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
if ix < state.grasp_servers.len() {
|
|
||||||
state.grasp_servers.remove(ix);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accepts a bare host as well as a full URL.
|
|
||||||
fn add_relay(
|
|
||||||
state: &Entity<GraspServersState>,
|
|
||||||
input: &Entity<InputState>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
let value = input.read(cx).value().trim().to_owned();
|
|
||||||
if value.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match normalize_server(&value) {
|
|
||||||
Some((_, relay)) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.error = None;
|
|
||||||
if !state.grasp_servers.contains(&relay) {
|
|
||||||
state.grasp_servers.push(relay);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.error = Some(format!("Invalid grasp server URL: {value}").into());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads the user's kind `10317` grasp list from the local database.
|
|
||||||
pub fn load_user_grasp_servers(
|
|
||||||
state: Entity<GraspServersState>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
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 task =
|
|
||||||
cx.background_spawn(
|
|
||||||
async move { signed_state::user_grasp_list_servers(&client, user).await },
|
|
||||||
);
|
|
||||||
let handle = window.window_handle();
|
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
|
||||||
let result = task.await;
|
|
||||||
|
|
||||||
if let Err(error) = cx.update_window(handle, |_, _window, cx| {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.loading_servers = false;
|
|
||||||
if let Ok(servers) = result
|
|
||||||
&& !servers.is_empty()
|
|
||||||
{
|
|
||||||
state.grasp_servers = servers;
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to apply grasp servers: {error}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
|
|
||||||
pub 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 fn server_host(relay: &RelayUrl) -> SharedString {
|
|
||||||
relay
|
|
||||||
.domain()
|
|
||||||
.map(SharedString::from)
|
|
||||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
||||||
Styled as _, Subscription, Task, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::Button;
|
|
||||||
use gpui_native_kit::common::v_flex;
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::field::Field;
|
|
||||||
use gpui_native_kit::input::{Input, InputState, InputStateEvent};
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_state::Backend;
|
|
||||||
|
|
||||||
use super::close_global;
|
|
||||||
use super::progress::{DialogProgress, error_row};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
enum Stage {
|
|
||||||
#[default]
|
|
||||||
Credential,
|
|
||||||
/// An `nsec` was entered; ask for a passphrase to encrypt it with.
|
|
||||||
Encrypt,
|
|
||||||
/// An `ncryptsec` was entered; ask for its passphrase to decrypt it.
|
|
||||||
Decrypt,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The credential entered on the first step.
|
|
||||||
enum Credential {
|
|
||||||
Nsec(String),
|
|
||||||
Ncryptsec(String),
|
|
||||||
Bunker(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Credential {
|
|
||||||
fn parse(input: &str) -> Option<Self> {
|
|
||||||
let input = input.trim();
|
|
||||||
|
|
||||||
if input.starts_with("nsec1") {
|
|
||||||
Some(Self::Nsec(input.to_owned()))
|
|
||||||
} else if input.starts_with("ncryptsec1") {
|
|
||||||
Some(Self::Ncryptsec(input.to_owned()))
|
|
||||||
} else if input.starts_with("bunker://") {
|
|
||||||
Some(Self::Bunker(input.to_owned()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// State of the import dialog, so the current step and async results can be rendered.
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ImportState {
|
|
||||||
stage: Stage,
|
|
||||||
credential: String,
|
|
||||||
progress: DialogProgress,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ImportDialog {
|
|
||||||
secret_input: Entity<InputState>,
|
|
||||||
pass_input: Entity<InputState>,
|
|
||||||
repass_input: Entity<InputState>,
|
|
||||||
state: Entity<ImportState>,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportDialog {
|
|
||||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
|
||||||
let secret_input = InputState::new(cx);
|
|
||||||
let pass_input = InputState::new(cx);
|
|
||||||
pass_input.update(cx, |state, cx| state.set_password(true, cx));
|
|
||||||
let repass_input = InputState::new(cx);
|
|
||||||
repass_input.update(cx, |state, cx| state.set_password(true, cx));
|
|
||||||
let state = cx.new(|_| ImportState::default());
|
|
||||||
|
|
||||||
cx.new(|cx| {
|
|
||||||
let mut subscriptions = Vec::new();
|
|
||||||
|
|
||||||
for input in [&secret_input, &pass_input, &repass_input] {
|
|
||||||
let secret_input = secret_input.clone();
|
|
||||||
let pass_input = pass_input.clone();
|
|
||||||
let repass_input = repass_input.clone();
|
|
||||||
let state = state.clone();
|
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(input, move |_this, _input, event, cx| {
|
|
||||||
if matches!(event, InputStateEvent::Submitted) {
|
|
||||||
submit(&secret_input, &pass_input, &repass_input, &state, cx);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
secret_input,
|
|
||||||
pass_input,
|
|
||||||
repass_input,
|
|
||||||
state,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for ImportDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let secret_input = self.secret_input.clone();
|
|
||||||
let pass_input = self.pass_input.clone();
|
|
||||||
let repass_input = self.repass_input.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let action_state = state.clone();
|
|
||||||
let action_secret = secret_input.clone();
|
|
||||||
let action_pass = pass_input.clone();
|
|
||||||
let action_repass = repass_input.clone();
|
|
||||||
|
|
||||||
Dialog::new("import-dialog")
|
|
||||||
.open(true)
|
|
||||||
.min_width(px(520.))
|
|
||||||
.on_change(|open, _, cx| {
|
|
||||||
if !open {
|
|
||||||
close_global(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.title("Import identity")
|
|
||||||
.content(move |_window, cx| {
|
|
||||||
let state = state.read(cx);
|
|
||||||
let stage = state.stage;
|
|
||||||
let description = match stage {
|
|
||||||
Stage::Credential => {
|
|
||||||
"Paste the secret key or bunker URI of an existing identity."
|
|
||||||
}
|
|
||||||
Stage::Encrypt => "Choose a passphrase to encrypt your key on this device.",
|
|
||||||
Stage::Decrypt => "Enter the passphrase used to encrypt this key.",
|
|
||||||
};
|
|
||||||
|
|
||||||
let form = match stage {
|
|
||||||
Stage::Credential => v_flex().gap_3().child(
|
|
||||||
Field::new("secret-field", "Secret key or bunker URI")
|
|
||||||
.child(Input::new("secret-input", secret_input.clone())),
|
|
||||||
),
|
|
||||||
Stage::Encrypt => v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
Field::new("passphrase-field", "Passphrase")
|
|
||||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("repeat-field", "Repeat passphrase")
|
|
||||||
.child(Input::new("repeat-input", repass_input.clone())),
|
|
||||||
),
|
|
||||||
Stage::Decrypt => v_flex().gap_3().child(
|
|
||||||
Field::new("passphrase-field", "Passphrase")
|
|
||||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(description),
|
|
||||||
)
|
|
||||||
.child(form)
|
|
||||||
.when_some(error_row(&state.progress.error, cx), |el, error| {
|
|
||||||
el.child(error)
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.actions(move |_, cx| {
|
|
||||||
let state = action_state.read(cx);
|
|
||||||
let busy = state.progress.busy;
|
|
||||||
let label = match state.stage {
|
|
||||||
Stage::Credential => "Continue",
|
|
||||||
Stage::Encrypt => "Import identity",
|
|
||||||
Stage::Decrypt => "Unlock",
|
|
||||||
};
|
|
||||||
let secret = action_secret.clone();
|
|
||||||
let pass = action_pass.clone();
|
|
||||||
let repass = action_repass.clone();
|
|
||||||
let state = action_state.clone();
|
|
||||||
|
|
||||||
let import: AnyElement = Button::new("import-identity")
|
|
||||||
.label(label)
|
|
||||||
.loading(busy)
|
|
||||||
.disabled(busy)
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
submit(&secret, &pass, &repass, &state, cx);
|
|
||||||
})
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
vec![import]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn submit(
|
|
||||||
secret_input: &Entity<InputState>,
|
|
||||||
pass_input: &Entity<InputState>,
|
|
||||||
repass_input: &Entity<InputState>,
|
|
||||||
state: &Entity<ImportState>,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
if state.read(cx).progress.busy {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match state.read(cx).stage {
|
|
||||||
Stage::Credential => start_import(secret_input, state, cx),
|
|
||||||
Stage::Encrypt => import_nsec(pass_input, repass_input, state, cx),
|
|
||||||
Stage::Decrypt => import_ncryptsec(pass_input, state, cx),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the entered credential and either moves to the passphrase step or connects to a bunker.
|
|
||||||
fn start_import(secret_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
|
|
||||||
let credential = match Credential::parse(secret_input.read(cx).value()) {
|
|
||||||
Some(credential) => credential,
|
|
||||||
None => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state
|
|
||||||
.progress
|
|
||||||
.fail("Enter an nsec, ncryptsec or bunker:// URI");
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match credential {
|
|
||||||
Credential::Nsec(secret) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.credential = secret;
|
|
||||||
state.stage = Stage::Encrypt;
|
|
||||||
state.progress.error = None;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Credential::Ncryptsec(secret) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.credential = secret;
|
|
||||||
state.stage = Stage::Decrypt;
|
|
||||||
state.progress.error = None;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Credential::Bunker(uri) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.progress.begin();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx));
|
|
||||||
finish_import(task, state, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encrypts the entered `nsec` with the passphrase and signs in with it.
|
|
||||||
fn import_nsec(
|
|
||||||
pass_input: &Entity<InputState>,
|
|
||||||
repass_input: &Entity<InputState>,
|
|
||||||
state: &Entity<ImportState>,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let nsec = state.read(cx).credential.clone();
|
|
||||||
let pass = pass_input.read(cx).value().to_string();
|
|
||||||
let repass = repass_input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
if pass.is_empty() {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.progress.fail("Passphrase must not be empty");
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if pass != repass {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.progress.fail("Passphrases do not match");
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.progress.begin();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx));
|
|
||||||
finish_import(task, state, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it.
|
|
||||||
fn import_ncryptsec(pass_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let ncryptsec = state.read(cx).credential.clone();
|
|
||||||
let pass = pass_input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
if pass.is_empty() {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.progress.fail("Passphrase must not be empty");
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.progress.begin();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let task = backend.update(cx, |backend, cx| {
|
|
||||||
backend.import_ncryptsec(&ncryptsec, &pass, cx)
|
|
||||||
});
|
|
||||||
finish_import(task, state, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the import task to completion, closing the dialog on success.
|
|
||||||
fn finish_import<T: 'static>(
|
|
||||||
task: Task<Result<T, anyhow::Error>>,
|
|
||||||
state: &Entity<ImportState>,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
let state = state.clone();
|
|
||||||
|
|
||||||
cx.spawn(async move |cx| match task.await {
|
|
||||||
Ok(_) => cx.update(close_global),
|
|
||||||
Err(error) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.progress.fail(error.to_string());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
||||||
SharedString, Styled as _, WeakEntity, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::Button;
|
|
||||||
use gpui_native_kit::common::v_flex;
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::field::Field;
|
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
|
||||||
use gpui_native_kit::input::{Input, InputState};
|
|
||||||
use gpui_native_kit::select::{Select, SelectOption};
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use settings::SettingsStore;
|
|
||||||
use signed_core::RepoAddr;
|
|
||||||
use signed_git::Repo;
|
|
||||||
use signed_state::Backend;
|
|
||||||
|
|
||||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
|
||||||
use super::progress::{DialogProgress, error_row, push_steps_row};
|
|
||||||
use super::{close_global, open_global};
|
|
||||||
use crate::views::repo::RepoDetailView;
|
|
||||||
|
|
||||||
pub struct InitRepoDialog {
|
|
||||||
local_path: PathBuf,
|
|
||||||
name_input: Entity<InputState>,
|
|
||||||
desc_input: Entity<InputState>,
|
|
||||||
relay_input: Entity<InputState>,
|
|
||||||
state: Entity<DialogProgress>,
|
|
||||||
grasp_state: Entity<GraspServersState>,
|
|
||||||
branches: Vec<SharedString>,
|
|
||||||
selected_branch: Option<SharedString>,
|
|
||||||
view: WeakEntity<RepoDetailView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InitRepoDialog {
|
|
||||||
pub fn new(
|
|
||||||
local_path: PathBuf,
|
|
||||||
view: WeakEntity<RepoDetailView>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) -> Entity<Self> {
|
|
||||||
let default_name = local_path
|
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let grasp_settings = SettingsStore::global(cx)
|
|
||||||
.read(cx)
|
|
||||||
.settings()
|
|
||||||
.grasp_servers
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
let state = cx.new(|_| DialogProgress::default());
|
|
||||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
|
||||||
let name_input = InputState::new_with_text(default_name, cx);
|
|
||||||
name_input.update(cx, |state, cx| state.set_read_only(true, cx));
|
|
||||||
let desc_input = InputState::new(cx);
|
|
||||||
desc_input.update(cx, |state, cx| state.set_multiline(true, cx));
|
|
||||||
let relay_input = InputState::new(cx);
|
|
||||||
|
|
||||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
|
||||||
|
|
||||||
let dialog = cx.new(|_| Self {
|
|
||||||
local_path,
|
|
||||||
name_input,
|
|
||||||
desc_input,
|
|
||||||
relay_input,
|
|
||||||
state,
|
|
||||||
grasp_state,
|
|
||||||
branches: Vec::new(),
|
|
||||||
selected_branch: None,
|
|
||||||
view,
|
|
||||||
});
|
|
||||||
|
|
||||||
load_branches(dialog.clone(), cx);
|
|
||||||
dialog
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.state.read(cx).busy {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = self.name_input.read(cx).value().trim().to_owned();
|
|
||||||
let description = self.desc_input.read(cx).value().trim().to_owned();
|
|
||||||
let servers = self.grasp_state.read(cx).grasp_servers.clone();
|
|
||||||
let default_branch = self
|
|
||||||
.selected_branch
|
|
||||||
.as_ref()
|
|
||||||
.map(|branch| branch.trim().to_owned())
|
|
||||||
.filter(|branch| !branch.is_empty());
|
|
||||||
|
|
||||||
if name.is_empty() {
|
|
||||||
self.fail("Repository name is required", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if servers.is_empty() {
|
|
||||||
self.fail("Add at least one grasp server", cx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
|
||||||
self.fail("Sign in to publish a repository", cx);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.begin_push(addr, cx);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let local_path = self.local_path.clone();
|
|
||||||
let task = backend.update(cx, |backend, cx| {
|
|
||||||
backend.publish_local_repo(local_path, &name, &description, servers, default_branch, cx)
|
|
||||||
});
|
|
||||||
let state = self.state.clone();
|
|
||||||
let view = self.view.clone();
|
|
||||||
let handle = window.window_handle();
|
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| match task.await {
|
|
||||||
Ok(announcement) => {
|
|
||||||
cx.update_window(handle, |_, _window, cx| {
|
|
||||||
close_global(cx);
|
|
||||||
if let Some(view) = view.upgrade() {
|
|
||||||
view.update(cx, |this, cx| this.apply_announcement(announcement, cx));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.fail(error.to_string());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fail(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.fail(message);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for InitRepoDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let name_input = self.name_input.clone();
|
|
||||||
let desc_input = self.desc_input.clone();
|
|
||||||
let relay_input = self.relay_input.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let grasp_state = self.grasp_state.clone();
|
|
||||||
let action_state = state.clone();
|
|
||||||
let this = cx.entity().downgrade();
|
|
||||||
let folder = self.local_path.display().to_string();
|
|
||||||
let branches = self.branches.clone();
|
|
||||||
let selected_branch = self.selected_branch.clone();
|
|
||||||
|
|
||||||
Dialog::new("init-repo-dialog")
|
|
||||||
.open(true)
|
|
||||||
.min_width(px(520.))
|
|
||||||
.on_change(|open, _, cx| {
|
|
||||||
if !open {
|
|
||||||
close_global(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.title("Initialize repository")
|
|
||||||
.content(move |_, cx| {
|
|
||||||
let progress = state.read(cx);
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child("Publish this local repository to Nostr."),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("init-name", "Repository name")
|
|
||||||
.description("Max 100 characters")
|
|
||||||
.child(Input::new("init-name-input", name_input.clone())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("init-description", "Description").child(
|
|
||||||
Input::new("init-description-input", desc_input.clone()).rows(3),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("init-folder", "Folder")
|
|
||||||
.description("The local repository being published")
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.h_8()
|
|
||||||
.w_full()
|
|
||||||
.px_2()
|
|
||||||
.bg(cx.theme().colors.muted)
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.rounded(cx.theme().radius.control)
|
|
||||||
.child(folder.clone()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("init-branch", "Default branch")
|
|
||||||
.description("The local branch to announce as the default")
|
|
||||||
.child(
|
|
||||||
Select::new("init-branch-select")
|
|
||||||
.options(
|
|
||||||
branches
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(|branch| SelectOption::new(branch.clone(), branch))
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.selected(selected_branch.clone())
|
|
||||||
.placeholder("Select branch"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
|
|
||||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
|
||||||
el.child(steps)
|
|
||||||
})
|
|
||||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.actions(move |_, cx| {
|
|
||||||
let busy = action_state.read(cx).busy;
|
|
||||||
let this = this.clone();
|
|
||||||
|
|
||||||
let init: AnyElement = Button::new("initialize-repository")
|
|
||||||
.label("Initialize")
|
|
||||||
.icon(Icon::named(IconRole::Plus))
|
|
||||||
.loading(busy)
|
|
||||||
.disabled(busy)
|
|
||||||
.on_click(move |_, window, cx| {
|
|
||||||
let _ = this.update(cx, |this, cx| this.init_repository(window, cx));
|
|
||||||
})
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
vec![init]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_branches(dialog: Entity<InitRepoDialog>, cx: &mut App) {
|
|
||||||
let local_path = dialog.read(cx).local_path.clone();
|
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
|
||||||
let loaded = cx
|
|
||||||
.background_spawn(async move {
|
|
||||||
let repo = Repo::open(&local_path)?;
|
|
||||||
let current: Option<SharedString> = repo.current_branch().map(Into::into);
|
|
||||||
Ok::<_, anyhow::Error>((repo.branches()?, current))
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let Ok((branches, current)) = loaded else {
|
|
||||||
log::warn!("failed to list branches for the default branch selector");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
dialog.update(cx, |dialog, cx| {
|
|
||||||
dialog.branches = branches.into_iter().map(Into::into).collect();
|
|
||||||
dialog.selected_branch = current;
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the initialize form for `local_path`.
|
|
||||||
pub fn open(
|
|
||||||
local_path: PathBuf,
|
|
||||||
view: WeakEntity<RepoDetailView>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
open_global(InitRepoDialog::new(local_path, view, window, cx), cx);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
pub mod about;
|
|
||||||
pub mod create_repo;
|
|
||||||
pub mod exit;
|
|
||||||
pub mod grasp_servers;
|
|
||||||
pub mod import;
|
|
||||||
pub mod init_repo;
|
|
||||||
pub mod onboarding;
|
|
||||||
pub mod passphrase;
|
|
||||||
pub mod progress;
|
|
||||||
pub mod reset_remote;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyView, App, Context, Entity, Global, IntoElement, ParentElement as _, Render, Styled as _,
|
|
||||||
Window, div,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct GlobalDialogHost(Entity<DialogHost>);
|
|
||||||
|
|
||||||
impl Global for GlobalDialogHost {}
|
|
||||||
|
|
||||||
/// Owns the modal currently mounted at the window root.
|
|
||||||
pub struct DialogHost {
|
|
||||||
open: Option<AnyView>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DialogHost {
|
|
||||||
pub fn new(_cx: &mut Context<Self>) -> Self {
|
|
||||||
Self { open: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
|
||||||
cx.set_global(GlobalDialogHost(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
|
||||||
cx.global::<GlobalDialogHost>().0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open(&mut self, view: AnyView, cx: &mut Context<Self>) {
|
|
||||||
self.open = Some(view);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.open = None;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaces the mounted modal with `view`.
|
|
||||||
pub fn open_global(view: impl Into<AnyView>, cx: &mut App) {
|
|
||||||
let host = DialogHost::global(cx);
|
|
||||||
host.update(cx, |host, cx| host.open(view.into(), cx));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unmounts the current modal, if any.
|
|
||||||
pub fn close_global(cx: &mut App) {
|
|
||||||
let host = DialogHost::global(cx);
|
|
||||||
host.update(cx, |host, cx| host.close(cx));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the onboarding form.
|
|
||||||
pub fn open_onboarding(cx: &mut App) {
|
|
||||||
open_global(onboarding::OnboardingDialog::new(cx), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the identity import form.
|
|
||||||
pub fn open_import(cx: &mut App) {
|
|
||||||
open_global(import::ImportDialog::new(cx), cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for DialogHost {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.inset_0()
|
|
||||||
.when_some(self.open.clone(), |host, view| host.child(view))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
||||||
Styled as _, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::Button;
|
|
||||||
use gpui_native_kit::common::v_flex;
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::field::Field;
|
|
||||||
use gpui_native_kit::input::{Input, InputState};
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_state::Backend;
|
|
||||||
|
|
||||||
use super::close_global;
|
|
||||||
use super::progress::{DialogProgress, error_row};
|
|
||||||
|
|
||||||
pub struct OnboardingDialog {
|
|
||||||
name_input: Entity<InputState>,
|
|
||||||
pass_input: Entity<InputState>,
|
|
||||||
repass_input: Entity<InputState>,
|
|
||||||
state: Entity<DialogProgress>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OnboardingDialog {
|
|
||||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
|
||||||
let name_input = InputState::new(cx);
|
|
||||||
let pass_input = InputState::new(cx);
|
|
||||||
pass_input.update(cx, |state, cx| state.set_password(true, cx));
|
|
||||||
let repass_input = InputState::new(cx);
|
|
||||||
repass_input.update(cx, |state, cx| state.set_password(true, cx));
|
|
||||||
let state = cx.new(|_| DialogProgress::default());
|
|
||||||
|
|
||||||
cx.new(|_| Self {
|
|
||||||
name_input,
|
|
||||||
pass_input,
|
|
||||||
repass_input,
|
|
||||||
state,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_identity(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.state.read(cx).busy {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = self.name_input.read(cx).value().to_string();
|
|
||||||
let pass = self.pass_input.read(cx).value().to_string();
|
|
||||||
let repass = self.repass_input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
if pass != repass {
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.fail("Passphrases do not match");
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.begin();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let task = backend.update(cx, |backend, cx| backend.create_identity(&name, &pass, cx));
|
|
||||||
let state = self.state.clone();
|
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| match task.await {
|
|
||||||
Ok(_) => cx.update(close_global),
|
|
||||||
Err(error) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.fail(error.to_string());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for OnboardingDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let name_input = self.name_input.clone();
|
|
||||||
let pass_input = self.pass_input.clone();
|
|
||||||
let repass_input = self.repass_input.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let action_state = state.clone();
|
|
||||||
let this = cx.entity().downgrade();
|
|
||||||
|
|
||||||
Dialog::new("onboarding-dialog")
|
|
||||||
.open(true)
|
|
||||||
.min_width(px(520.))
|
|
||||||
.on_change(|open, _, cx| {
|
|
||||||
if !open {
|
|
||||||
close_global(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.title("Create identity")
|
|
||||||
.content(move |_window, cx| {
|
|
||||||
let progress = state.read(cx);
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child("Set up your Signed identity to get started."),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("name-field", "Name")
|
|
||||||
.description("Max 255 characters")
|
|
||||||
.child(Input::new("name-input", name_input.clone())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("passphrase-field", "Passphrase")
|
|
||||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("repeat-field", "Repeat passphrase")
|
|
||||||
.child(Input::new("repeat-input", repass_input.clone())),
|
|
||||||
)
|
|
||||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.actions(move |_, cx| {
|
|
||||||
let busy = action_state.read(cx).busy;
|
|
||||||
let this = this.clone();
|
|
||||||
|
|
||||||
let create: AnyElement = Button::new("create-identity")
|
|
||||||
.label("Create new identity")
|
|
||||||
.loading(busy)
|
|
||||||
.disabled(busy)
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
let _ = this.update(cx, |this, cx| this.create_identity(cx));
|
|
||||||
})
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
vec![create]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
use assets::CustomIconName;
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
||||||
Styled as _, Subscription, Window, div,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::Button;
|
|
||||||
use gpui_native_kit::common::v_flex;
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::field::Field;
|
|
||||||
use gpui_native_kit::icons::Icon;
|
|
||||||
use gpui_native_kit::input::{Input, InputState, InputStateEvent};
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_state::Backend;
|
|
||||||
|
|
||||||
use super::progress::{DialogProgress, error_row, push_steps_row};
|
|
||||||
use super::{close_global, open_global};
|
|
||||||
|
|
||||||
pub struct PassphraseDialog {
|
|
||||||
pass_input: Entity<InputState>,
|
|
||||||
state: Entity<DialogProgress>,
|
|
||||||
_enter_subscription: Subscription,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PassphraseDialog {
|
|
||||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
|
||||||
let pass_input = InputState::new(cx);
|
|
||||||
pass_input.update(cx, |state, cx| state.set_password(true, cx));
|
|
||||||
let state = cx.new(|_| DialogProgress::default());
|
|
||||||
|
|
||||||
cx.new(|cx| {
|
|
||||||
let enter_subscription =
|
|
||||||
cx.subscribe(&pass_input, |this: &mut Self, _input, event, cx| {
|
|
||||||
if matches!(event, InputStateEvent::Submitted) {
|
|
||||||
this.unlock(cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
pass_input,
|
|
||||||
state,
|
|
||||||
_enter_subscription: enter_subscription,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unlock(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.state.read(cx).busy {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pass = self.pass_input.read(cx).value().to_string();
|
|
||||||
|
|
||||||
if pass.is_empty() {
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.fail("Passphrase must not be empty");
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.state.update(cx, |state, cx| {
|
|
||||||
state.begin();
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
|
||||||
let state = self.state.clone();
|
|
||||||
|
|
||||||
cx.spawn(async move |_this, cx| match task.await {
|
|
||||||
Ok(_) => {
|
|
||||||
cx.update(close_global);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.fail(error.to_string());
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for PassphraseDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let pass_input = self.pass_input.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let action_state = state.clone();
|
|
||||||
let this = cx.entity().downgrade();
|
|
||||||
|
|
||||||
Dialog::new("passphrase-dialog")
|
|
||||||
.open(true)
|
|
||||||
.on_change(|_open, _, _| {})
|
|
||||||
.title("Unlock your identity")
|
|
||||||
.content(move |_window, cx| {
|
|
||||||
let progress = state.read(cx);
|
|
||||||
v_flex()
|
|
||||||
.gap_3()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child("Enter the passphrase used to encrypt this identity."),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Field::new("passphrase-field", "Passphrase")
|
|
||||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
|
||||||
)
|
|
||||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
|
||||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
|
||||||
el.child(steps)
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.actions(move |_, cx| {
|
|
||||||
let busy = action_state.read(cx).busy;
|
|
||||||
let this = this.clone();
|
|
||||||
|
|
||||||
let unlock: AnyElement = Button::new("unlock")
|
|
||||||
.label("Unlock")
|
|
||||||
.icon(Icon::named(CustomIconName::Unlock))
|
|
||||||
.loading(busy)
|
|
||||||
.disabled(busy)
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
let _ = this.update(cx, |this, cx| this.unlock(cx));
|
|
||||||
})
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
vec![unlock]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the dialog; used by the backend passphrase event and the startup fallback.
|
|
||||||
pub fn open(cx: &mut App) {
|
|
||||||
open_global(PassphraseDialog::new(cx), cx);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
AnyElement, App, Context, IntoElement as _, ParentElement as _, SharedString, Styled as _,
|
|
||||||
Subscription, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::common::{h_flex, v_flex};
|
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
|
||||||
use gpui_native_kit::spinner::Spinner;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_core::RepoAddr;
|
|
||||||
use signed_state::{Backend, BackendEvent, PushStepStatus};
|
|
||||||
|
|
||||||
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct DialogProgress {
|
|
||||||
pub busy: bool,
|
|
||||||
pub error: Option<SharedString>,
|
|
||||||
// The repository whose push checklist is shown; `None` outside a publish.
|
|
||||||
push_addr: Option<RepoAddr>,
|
|
||||||
pub push_steps: Vec<PushStepStatus>,
|
|
||||||
_push_subscription: Option<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DialogProgress {
|
|
||||||
/// Marks an action as started, disabling the form and clearing the previous error.
|
|
||||||
pub fn begin(&mut self) {
|
|
||||||
self.busy = true;
|
|
||||||
self.error = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts a publish for the repository at `addr` and listens for its push steps.
|
|
||||||
pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context<Self>) {
|
|
||||||
self.begin();
|
|
||||||
self.push_addr = Some(addr);
|
|
||||||
self.push_steps.clear();
|
|
||||||
|
|
||||||
if self._push_subscription.is_none() {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| {
|
|
||||||
let BackendEvent::PushProgress { addr, status } = event else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if this.push_addr.as_ref() != Some(addr) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match this
|
|
||||||
.push_steps
|
|
||||||
.iter_mut()
|
|
||||||
.find(|step| step.step() == status.step())
|
|
||||||
{
|
|
||||||
Some(step) => *step = status.clone(),
|
|
||||||
None => this.push_steps.push(status.clone()),
|
|
||||||
}
|
|
||||||
|
|
||||||
cx.notify();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks an action as failed, enabling the form and showing `message`.
|
|
||||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
|
||||||
self.busy = false;
|
|
||||||
self.error = Some(message.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shared error line under a dialog form, `None` when there is no error.
|
|
||||||
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
|
||||||
error.as_ref().map(|message| {
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().ramps.destructive.base)
|
|
||||||
.child(message.clone())
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The push checklist of a running publish, `None` before any step is reported.
|
|
||||||
pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option<AnyElement> {
|
|
||||||
if steps.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows = steps
|
|
||||||
.iter()
|
|
||||||
.map(|status| {
|
|
||||||
let label = SharedString::from(status.step().label());
|
|
||||||
|
|
||||||
match status {
|
|
||||||
PushStepStatus::Running(_) => h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(Spinner::new("push-step"))
|
|
||||||
.child(label)
|
|
||||||
.into_any_element(),
|
|
||||||
PushStepStatus::Done(_) => h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.foreground)
|
|
||||||
.child(
|
|
||||||
Icon::named(IconRole::Check)
|
|
||||||
.size(px(14.))
|
|
||||||
.color(cx.theme().colors.foreground),
|
|
||||||
)
|
|
||||||
.child(label)
|
|
||||||
.into_any_element(),
|
|
||||||
PushStepStatus::Failed(_, reason) => h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().ramps.destructive.base)
|
|
||||||
.child(
|
|
||||||
Icon::named(IconRole::Close)
|
|
||||||
.size(px(14.))
|
|
||||||
.color(cx.theme().ramps.destructive.base),
|
|
||||||
)
|
|
||||||
.child(SharedString::from(format!(
|
|
||||||
"{}: {reason}",
|
|
||||||
status.step().label()
|
|
||||||
)))
|
|
||||||
.into_any_element(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Some(v_flex().gap_1().children(rows).into_any_element())
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
||||||
Styled as _, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
|
||||||
use gpui_native_kit::common::v_flex;
|
|
||||||
use gpui_native_kit::dialog::Dialog;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_state::RepoStore;
|
|
||||||
|
|
||||||
use super::{close_global, open_global};
|
|
||||||
|
|
||||||
pub struct ResetRemoteDialog {
|
|
||||||
store: Entity<RepoStore>,
|
|
||||||
deleted: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResetRemoteDialog {
|
|
||||||
pub fn new(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) -> Entity<Self> {
|
|
||||||
cx.new(|_| Self { store, deleted })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for ResetRemoteDialog {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let deleted = self.deleted.clone();
|
|
||||||
let store = self.store.clone();
|
|
||||||
|
|
||||||
Dialog::new("reset-remote-dialog")
|
|
||||||
.open(true)
|
|
||||||
.min_width(px(480.))
|
|
||||||
.on_change(|open, _, cx| {
|
|
||||||
if !open {
|
|
||||||
close_global(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.title("Reset remote to local")
|
|
||||||
.content(move |_, cx| {
|
|
||||||
v_flex()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(
|
|
||||||
"Grasp servers are overwritten to match the local mirror. Refs that do not exist locally are deleted from the servers. This cannot be undone.",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(if deleted.is_empty() {
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child("No server refs will be deleted.")
|
|
||||||
.into_any_element()
|
|
||||||
} else {
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.children(deleted.iter().map(|name| {
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.font_family(cx.theme().mono_font)
|
|
||||||
.child(name.clone())
|
|
||||||
}))
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.actions(move |_, _| {
|
|
||||||
let store = store.clone();
|
|
||||||
let cancel: AnyElement = Button::new("cancel-reset-remote")
|
|
||||||
.label("Cancel")
|
|
||||||
.variant(ButtonVariant::Secondary)
|
|
||||||
.on_click(|_, _, cx| close_global(cx))
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
let confirm: AnyElement = Button::new("confirm-reset-remote")
|
|
||||||
.label("Reset remote")
|
|
||||||
.variant(ButtonVariant::Destructive)
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
let task = store.update(cx, |store, cx| store.reset_repository(cx));
|
|
||||||
cx.spawn(async move |_cx| {
|
|
||||||
if let Err(error) = task.await {
|
|
||||||
log::error!("failed to reset remote: {error:#}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
close_global(cx);
|
|
||||||
})
|
|
||||||
.into_any_element();
|
|
||||||
|
|
||||||
vec![cancel, confirm]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the reset confirmation listing the refs the reset deletes.
|
|
||||||
pub fn open(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) {
|
|
||||||
open_global(ResetRemoteDialog::new(store, deleted, cx), cx);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
mod app;
|
|
||||||
mod dialogs;
|
|
||||||
mod panels;
|
|
||||||
mod views;
|
|
||||||
|
|
||||||
pub use app::{AppRoot, apply_appearance};
|
|
||||||
|
|
||||||
use gpui::{App, AppContext as _, Entity, Window};
|
|
||||||
|
|
||||||
pub fn root(window: &mut Window, cx: &mut App) -> Entity<AppRoot> {
|
|
||||||
cx.new(|cx| AppRoot::new(window, cx))
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
use gpui::{App, Entity, SharedString, WeakEntity, Window};
|
|
||||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
|
||||||
|
|
||||||
/// Registers a panel builder under `id` and opens it in the dock.
|
|
||||||
pub fn open<P, F>(
|
|
||||||
dock: &WeakEntity<DockState>,
|
|
||||||
id: impl Into<SharedString>,
|
|
||||||
build: F,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) where
|
|
||||||
P: Panel + 'static,
|
|
||||||
F: Fn(&mut Window, &mut App) -> Entity<P> + 'static,
|
|
||||||
{
|
|
||||||
let id = id.into();
|
|
||||||
|
|
||||||
if let Err(error) = dock.update(cx, |dock, cx| {
|
|
||||||
dock.register_panel(id.clone(), build);
|
|
||||||
dock.open_panel(&id, window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to open panel {id}: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub mod placeholder;
|
|
||||||
pub mod repo;
|
|
||||||
pub mod sidebar;
|
|
||||||
pub mod tree;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
use gpui::{
|
|
||||||
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::empty_state::EmptyState;
|
|
||||||
use gpui_native_kit::layout::dock::Panel;
|
|
||||||
|
|
||||||
/// A tab that stands in for a screen that is not ported yet.
|
|
||||||
pub struct PlaceholderView {
|
|
||||||
title: SharedString,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaceholderView {
|
|
||||||
pub fn new(title: impl Into<SharedString>) -> Self {
|
|
||||||
Self {
|
|
||||||
title: title.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for PlaceholderView {
|
|
||||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
|
||||||
Some(self.title.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for PlaceholderView {
|
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
div().size_full().child(EmptyState::new(self.title.clone()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
use gpui::{App, AppContext as _, SharedString, WeakEntity, Window};
|
|
||||||
use gpui_native_kit::layout::dock::DockState;
|
|
||||||
use signed_core::{Announcement, RepoAddr};
|
|
||||||
|
|
||||||
use crate::panels;
|
|
||||||
use crate::views::repo::RepoDetailView;
|
|
||||||
|
|
||||||
/// Opens a repository as a dock panel, keyed by its address.
|
|
||||||
pub fn open_repo_panel(
|
|
||||||
dock: &WeakEntity<DockState>,
|
|
||||||
addr: &RepoAddr,
|
|
||||||
hint: Option<&Announcement>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
let id = SharedString::from(format!("repo:{addr}"));
|
|
||||||
let addr = addr.clone();
|
|
||||||
let hint = hint.cloned();
|
|
||||||
let dock_for_build = dock.clone();
|
|
||||||
|
|
||||||
panels::open(
|
|
||||||
dock,
|
|
||||||
id,
|
|
||||||
move |window, cx| {
|
|
||||||
let addr = addr.clone();
|
|
||||||
let hint = hint.clone();
|
|
||||||
cx.new(|cx| RepoDetailView::new(dock_for_build.clone(), addr, hint, window, cx))
|
|
||||||
},
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use signed_state::{CheckoutStatus, CheckoutSyncStatus};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(super) struct Banners {
|
|
||||||
dismissed: HashSet<(PathBuf, String)>,
|
|
||||||
sync_dismissed: HashSet<PathBuf>,
|
|
||||||
ready_requested: bool,
|
|
||||||
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 sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
|
|
||||||
self.sync_dismissed.contains(&checkout.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
|
|
||||||
self.sync_dismissed.insert(checkout.path.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,661 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Axis, Context, Entity, IntoElement, ParentElement as _,
|
|
||||||
Render, ScrollStrategy, SharedString, Styled as _, Task, UniformListScrollHandle, Window, div,
|
|
||||||
px, uniform_list,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
|
||||||
use gpui_native_kit::clipboard::ClipboardButton;
|
|
||||||
use gpui_native_kit::common::{h_flex, v_flex};
|
|
||||||
use gpui_native_kit::data::tree::{Tree, TreeItem};
|
|
||||||
use gpui_native_kit::empty_state::EmptyState;
|
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
|
||||||
use gpui_native_kit::layout::dock::Panel;
|
|
||||||
use gpui_native_kit::layout::resizable::{Resizable, ResizableState};
|
|
||||||
use gpui_native_kit::scrollbar::Scrollbar;
|
|
||||||
use gpui_native_kit::spinner::Spinner;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use metadata::ProfileDirectory;
|
|
||||||
use signed_git::{
|
|
||||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
|
||||||
};
|
|
||||||
use signed_state::RepoStore;
|
|
||||||
use utils::relative_time_secs;
|
|
||||||
|
|
||||||
use crate::views::tree::{TreeRow, build_tree_items, collect_expanded, flatten};
|
|
||||||
|
|
||||||
pub struct DiffPane {
|
|
||||||
diff: Option<CommitDiff>,
|
|
||||||
seeds: Vec<crate::views::tree::TreeItemSeed>,
|
|
||||||
expanded: HashSet<String>,
|
|
||||||
rows: Vec<TreeRow>,
|
|
||||||
selected_file: Option<SharedString>,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
/// Rows of the selected file's diff, hunk headers and lines.
|
|
||||||
diff_rows: Vec<DiffRow>,
|
|
||||||
scroll_handle: UniformListScrollHandle,
|
|
||||||
split: Entity<ResizableState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiffPane {
|
|
||||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
|
||||||
Self {
|
|
||||||
diff: None,
|
|
||||||
seeds: Vec::new(),
|
|
||||||
expanded: HashSet::new(),
|
|
||||||
rows: Vec::new(),
|
|
||||||
selected_file: None,
|
|
||||||
selected_index: None,
|
|
||||||
diff_rows: Vec::new(),
|
|
||||||
scroll_handle: UniformListScrollHandle::new(),
|
|
||||||
split: {
|
|
||||||
let split = ResizableState::new(cx);
|
|
||||||
split.update(cx, |split, cx| split.set_sizes([px(260.)], cx));
|
|
||||||
split
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn diff(&self) -> Option<&CommitDiff> {
|
|
||||||
self.diff.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
|
|
||||||
let mut paths: Vec<PathBuf> = diff
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.map(|file| PathBuf::from(&file.path))
|
|
||||||
.collect();
|
|
||||||
paths.sort();
|
|
||||||
self.seeds = build_tree_items(&paths);
|
|
||||||
self.expanded = collect_expanded(&self.seeds);
|
|
||||||
self.rows = flatten(&self.seeds, &self.expanded);
|
|
||||||
|
|
||||||
let first = diff
|
|
||||||
.files
|
|
||||||
.first()
|
|
||||||
.map(|file| SharedString::from(file.path.as_str()));
|
|
||||||
|
|
||||||
self.selected_file = first.clone();
|
|
||||||
self.selected_index = first
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
|
||||||
self.diff = Some(diff);
|
|
||||||
|
|
||||||
if let Some(path) = first {
|
|
||||||
self.set_diff_rows(path.as_ref());
|
|
||||||
} else {
|
|
||||||
self.diff_rows.clear();
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
|
||||||
self.selected_file = Some(path.into());
|
|
||||||
self.selected_index = self.rows.iter().position(|row| row.id == path);
|
|
||||||
self.set_diff_rows(path);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_diff_rows(&mut self, path: &str) {
|
|
||||||
let Some(diff) = self.diff.as_ref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
self.diff_rows = diff_rows(file);
|
|
||||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_folder(&mut self, id: &str, cx: &mut Context<Self>) {
|
|
||||||
if !self.expanded.remove(id) {
|
|
||||||
self.expanded.insert(id.to_owned());
|
|
||||||
}
|
|
||||||
self.rows = flatten(&self.seeds, &self.expanded);
|
|
||||||
self.selected_index = self
|
|
||||||
.selected_file
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
|
||||||
let rows = self.rows.clone();
|
|
||||||
let selected = self.selected_index;
|
|
||||||
let view = cx.entity().downgrade();
|
|
||||||
let change_view = view.clone();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.h_full()
|
|
||||||
.min_h_0()
|
|
||||||
.p_2()
|
|
||||||
.child(
|
|
||||||
Tree::new("commit-diff-tree")
|
|
||||||
.selected(selected)
|
|
||||||
.items(move |_, _| {
|
|
||||||
rows.iter()
|
|
||||||
.map(|row| {
|
|
||||||
let item = TreeItem::new(row.id.clone(), row.label.clone())
|
|
||||||
.depth(row.depth)
|
|
||||||
.expandable(row.expandable)
|
|
||||||
.expanded(row.expanded);
|
|
||||||
|
|
||||||
if row.expandable {
|
|
||||||
item.icon(Icon::named(IconRole::Folder))
|
|
||||||
} else {
|
|
||||||
item
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.on_change(move |index, window, cx| {
|
|
||||||
if let Some(index) = index
|
|
||||||
&& let Err(error) = change_view.update(cx, |this, cx| {
|
|
||||||
this.activate_row(index, window, cx);
|
|
||||||
})
|
|
||||||
{
|
|
||||||
log::warn!("failed to activate diff tree row: {error}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_disclosure({
|
|
||||||
let view = view;
|
|
||||||
move |index, _, cx| {
|
|
||||||
if let Err(error) = view.update(cx, |this, cx| {
|
|
||||||
let id = this.rows.get(index).map(|row| row.id.clone());
|
|
||||||
if let Some(id) = id {
|
|
||||||
this.toggle_folder(&id, cx);
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to toggle diff folder: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate_row(&mut self, index: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Some(row) = self.rows.get(index) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let id = row.id.clone();
|
|
||||||
let expandable = row.expandable;
|
|
||||||
self.selected_index = Some(index);
|
|
||||||
|
|
||||||
if expandable {
|
|
||||||
self.toggle_folder(&id, cx);
|
|
||||||
} else {
|
|
||||||
self.select_file(&id, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
|
||||||
let Some(diff) = self.diff.as_ref() else {
|
|
||||||
return placeholder("No changes");
|
|
||||||
};
|
|
||||||
let Some(path) = self.selected_file.clone() else {
|
|
||||||
return if diff.files.is_empty() {
|
|
||||||
placeholder("No files changed")
|
|
||||||
} else {
|
|
||||||
placeholder("Select a file")
|
|
||||||
};
|
|
||||||
};
|
|
||||||
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
|
||||||
return placeholder("File not found");
|
|
||||||
};
|
|
||||||
self.render_file_diff(file, cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement {
|
|
||||||
let status_label = match file.status {
|
|
||||||
DiffStatus::Added => "A",
|
|
||||||
DiffStatus::Modified => "M",
|
|
||||||
DiffStatus::Deleted => "D",
|
|
||||||
DiffStatus::Renamed => "R",
|
|
||||||
DiffStatus::Copied => "C",
|
|
||||||
};
|
|
||||||
let status_color = match file.status {
|
|
||||||
DiffStatus::Added => cx.theme().ramps.success.base,
|
|
||||||
DiffStatus::Modified => cx.theme().ramps.info.base,
|
|
||||||
DiffStatus::Deleted => cx.theme().ramps.destructive.base,
|
|
||||||
DiffStatus::Renamed | DiffStatus::Copied => cx.theme().colors.muted_foreground,
|
|
||||||
};
|
|
||||||
let title = match &file.old_path {
|
|
||||||
Some(old) => format!("{old} → {}", file.path),
|
|
||||||
None => file.path.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let body: AnyElement = if file.binary {
|
|
||||||
placeholder("Diff not available")
|
|
||||||
} else if file.hunks.is_empty() {
|
|
||||||
placeholder("No content changes")
|
|
||||||
} else {
|
|
||||||
let hunks = file.hunks.clone();
|
|
||||||
let rows = self.diff_rows.clone();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.relative()
|
|
||||||
.child(
|
|
||||||
uniform_list("commit-diff-rows", rows.len(), move |range, _, cx| {
|
|
||||||
range
|
|
||||||
.map(|ix| render_diff_row(&hunks, rows[ix], cx))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.track_scroll(&self.scroll_handle)
|
|
||||||
.size_full(),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.right_0()
|
|
||||||
.bottom_0()
|
|
||||||
.child(Scrollbar::vertical(
|
|
||||||
"commit-diff-scroll",
|
|
||||||
self.scroll_handle.clone(),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
};
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.h_full()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.px_3()
|
|
||||||
.h_9()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.text_color(status_color)
|
|
||||||
.child(status_label),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.text_xs()
|
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.text_ellipsis()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.child(title),
|
|
||||||
)
|
|
||||||
.when(!file.binary, |this| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_xs()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().ramps.success.base)
|
|
||||||
.child(format!("+{}", file.insertions)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().ramps.destructive.base)
|
|
||||||
.child(format!("-{}", file.deletions)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(div().flex_1().min_h_0().child(body))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for DiffPane {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let tree = self.render_tree_column(cx);
|
|
||||||
let detail = self.render_detail_column(cx);
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.size_full()
|
|
||||||
.min_h_0()
|
|
||||||
.bg(cx.theme().colors.background)
|
|
||||||
.child(
|
|
||||||
Resizable::new("commit-diff-split", self.split.clone())
|
|
||||||
.axis(Axis::Horizontal)
|
|
||||||
.pane_with_min(tree, px(120.))
|
|
||||||
.pane_with_min(detail, px(240.)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct CommitDiffView {
|
|
||||||
store: Entity<RepoStore>,
|
|
||||||
worktree: PathBuf,
|
|
||||||
repo_name: SharedString,
|
|
||||||
commit: FileCommit,
|
|
||||||
/// The diff is being computed on a background task.
|
|
||||||
loading: bool,
|
|
||||||
error: Option<SharedString>,
|
|
||||||
pane: Entity<DiffPane>,
|
|
||||||
/// Profile for the repository owner in the tab title.
|
|
||||||
directory: Entity<ProfileDirectory>,
|
|
||||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommitDiffView {
|
|
||||||
pub fn new(
|
|
||||||
store: Entity<RepoStore>,
|
|
||||||
worktree: PathBuf,
|
|
||||||
repo_name: SharedString,
|
|
||||||
commit_id: String,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> Self {
|
|
||||||
let pane = cx.new(DiffPane::new);
|
|
||||||
let directory = ProfileDirectory::new(cx);
|
|
||||||
|
|
||||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
|
||||||
directory.update(cx, |directory, cx| {
|
|
||||||
directory.track([owner], cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let weak = cx.entity().downgrade();
|
|
||||||
window.defer(cx, move |window, cx| {
|
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.load(window, cx)) {
|
|
||||||
log::warn!("failed to load commit diff: {error}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Self {
|
|
||||||
store,
|
|
||||||
worktree,
|
|
||||||
repo_name,
|
|
||||||
commit: FileCommit {
|
|
||||||
id: commit_id,
|
|
||||||
summary: String::new(),
|
|
||||||
description: None,
|
|
||||||
author: String::new(),
|
|
||||||
time: 0,
|
|
||||||
},
|
|
||||||
loading: true,
|
|
||||||
error: None,
|
|
||||||
pane,
|
|
||||||
directory,
|
|
||||||
tasks: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.loading = true;
|
|
||||||
self.error = None;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
let worktree = self.worktree.clone();
|
|
||||||
let id = self.commit.id.clone();
|
|
||||||
|
|
||||||
let task: Task<Result<(), anyhow::Error>> = cx.spawn_in(window, async move |this, cx| {
|
|
||||||
let commit = cx
|
|
||||||
.background_spawn({
|
|
||||||
let worktree = worktree.clone();
|
|
||||||
let id = id.clone();
|
|
||||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let diff = cx
|
|
||||||
.background_spawn({
|
|
||||||
let worktree = worktree.clone();
|
|
||||||
let id = id.clone();
|
|
||||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
this.update_in(cx, |this, _window, cx| {
|
|
||||||
this.loading = false;
|
|
||||||
if let Ok(Some(commit)) = commit {
|
|
||||||
this.commit = commit;
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
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| {
|
|
||||||
(
|
|
||||||
diff.files.len(),
|
|
||||||
diff.files.iter().map(|file| file.insertions).sum(),
|
|
||||||
diff.files.iter().map(|file| file.deletions).sum(),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.flex_none()
|
|
||||||
.px_4()
|
|
||||||
.py_3()
|
|
||||||
.w_full()
|
|
||||||
.gap_2()
|
|
||||||
.border_b_1()
|
|
||||||
.border_color(cx.theme().colors.border)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.text_ellipsis()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.child(commit.summary.clone()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_sm()
|
|
||||||
.child(SharedString::from(format!("{} committed", commit.author)))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(SharedString::from(&commit.id))
|
|
||||||
.child(ClipboardButton::new("commit-id", &commit.id)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(relative_time_secs(commit.time)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when_some(commit.description.as_ref(), |this, description| {
|
|
||||||
this.child(div().text_sm().child(SharedString::from(description)))
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.text_xs()
|
|
||||||
.child(Badge::new("commit-files", format!("{files} files changed")))
|
|
||||||
.when(insertions > 0, |this| {
|
|
||||||
this.child(
|
|
||||||
Badge::new("commit-insertions", format!("+ {insertions}"))
|
|
||||||
.variant(BadgeVariant::Success),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(deletions > 0, |this| {
|
|
||||||
this.child(
|
|
||||||
Badge::new("commit-deletions", format!("- {deletions}"))
|
|
||||||
.variant(BadgeVariant::Danger),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Panel for CommitDiffView {
|
|
||||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
|
||||||
Some(SharedString::from(format!(
|
|
||||||
"{}/{}",
|
|
||||||
self.repo_name, self.commit.id
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
|
||||||
Some(super::repo_tab_avatar(
|
|
||||||
self.store.read(cx),
|
|
||||||
&self.directory,
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for CommitDiffView {
|
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let body: AnyElement = if self.loading {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(Spinner::new("commit-diff-loading"))
|
|
||||||
.into_any_element()
|
|
||||||
} else if let Some(error) = self.error.clone() {
|
|
||||||
placeholder(&error)
|
|
||||||
} else {
|
|
||||||
self.pane.clone().into_any_element()
|
|
||||||
};
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.min_h_0()
|
|
||||||
.child(self.render_header(cx))
|
|
||||||
.child(div().flex_1().min_h_0().child(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const GUTTER_WIDTH: f32 = 44.;
|
|
||||||
pub const DIFF_ROW_HEIGHT: f32 = 20.;
|
|
||||||
pub const COMMIT_ROW_HEIGHT: f32 = 56.;
|
|
||||||
|
|
||||||
#[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)
|
|
||||||
.text_xs()
|
|
||||||
.bg(cx.theme().colors.muted)
|
|
||||||
.border_y_1()
|
|
||||||
.border_color(cx.theme().colors.border)
|
|
||||||
.text_color(cx.theme().colors.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().ramps.success.base.opacity(0.2)),
|
|
||||||
DiffLineKind::Deletion => Some(cx.theme().ramps.destructive.base.opacity(0.2)),
|
|
||||||
DiffLineKind::Context => None,
|
|
||||||
};
|
|
||||||
let gutter = cx.theme().colors.muted_foreground;
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.w_full()
|
|
||||||
.h(px(DIFF_ROW_HEIGHT))
|
|
||||||
.font_family(cx.theme().mono_font)
|
|
||||||
.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().colors.foreground)
|
|
||||||
.child(line.text.clone()),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn placeholder(message: impl Into<SharedString>) -> AnyElement {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(EmptyState::new(message))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
@@ -1,809 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
|
||||||
use std::path::{Component, Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
|
||||||
SharedString, Styled as _, Task, WeakEntity, Window, div, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::common::{h_flex, v_flex};
|
|
||||||
use gpui_native_kit::data::tree::{Tree, TreeItem};
|
|
||||||
use gpui_native_kit::editor::code::CodeViewState;
|
|
||||||
use gpui_native_kit::editor::highlight::Language;
|
|
||||||
use gpui_native_kit::editor::markdown::MarkdownViewState;
|
|
||||||
use gpui_native_kit::empty_state::EmptyState;
|
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
|
||||||
use gpui_native_kit::spinner::Spinner;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_git::{FileCommit, Repo, WorktreeSnapshot};
|
|
||||||
|
|
||||||
use crate::views::tree::{TreeItemSeed, TreeRow, flatten};
|
|
||||||
|
|
||||||
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<MarkdownViewState>,
|
|
||||||
/// 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<CodeViewState>,
|
|
||||||
/// Hash of the source, so the same document is not re-parsed on a refresh.
|
|
||||||
source_hash: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RepoFilesView {
|
|
||||||
seeds: Vec<TreeItemSeed>,
|
|
||||||
expanded: HashSet<String>,
|
|
||||||
rows: Vec<TreeRow>,
|
|
||||||
selected_index: Option<usize>,
|
|
||||||
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 fn new(cx: &mut Context<Self>) -> Self {
|
|
||||||
let _ = cx;
|
|
||||||
Self {
|
|
||||||
seeds: Vec::new(),
|
|
||||||
expanded: HashSet::new(),
|
|
||||||
rows: Vec::new(),
|
|
||||||
selected_index: None,
|
|
||||||
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 fn set_worktree(&mut self, path: PathBuf) {
|
|
||||||
self.worktree = Some(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply_entries(
|
|
||||||
&mut self,
|
|
||||||
tree: Vec<TreeItemSeed>,
|
|
||||||
paths: Vec<String>,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
self.worktree_paths = paths;
|
|
||||||
self.seeds = tree;
|
|
||||||
let folder_ids = folder_ids(&self.seeds);
|
|
||||||
self.expanded.retain(|id| folder_ids.contains(id));
|
|
||||||
self.rebuild_rows();
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_rows(&mut self) {
|
|
||||||
self.rows = flatten(&self.seeds, &self.expanded);
|
|
||||||
self.selected_index = self
|
|
||||||
.selected_file
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_folder(&mut self, id: &str) {
|
|
||||||
if !self.expanded.remove(id) {
|
|
||||||
self.expanded.insert(id.to_owned());
|
|
||||||
}
|
|
||||||
self.rebuild_rows();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Point the README pane at `path`/`bytes`, or clear it when absent.
|
|
||||||
pub 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 fn clear_previews(&mut self) {
|
|
||||||
self.selected_file = None;
|
|
||||||
self.selected_index = 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.
|
|
||||||
pub 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 activate_row(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Some(row) = self.rows.get(index) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let id = row.id.clone();
|
|
||||||
let expandable = row.expandable;
|
|
||||||
|
|
||||||
self.selected_index = Some(index);
|
|
||||||
|
|
||||||
if expandable {
|
|
||||||
self.toggle_folder(&id);
|
|
||||||
} else {
|
|
||||||
self.open_file(&id, window, cx);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_tree_column(
|
|
||||||
&self,
|
|
||||||
rows: Vec<TreeRow>,
|
|
||||||
selected: Option<usize>,
|
|
||||||
view: WeakEntity<Self>,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let change_view = view.clone();
|
|
||||||
let disclosure_view = view;
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.h_full()
|
|
||||||
.w(px(TREE_WIDTH))
|
|
||||||
.p_2()
|
|
||||||
.flex_none()
|
|
||||||
.border_r_1()
|
|
||||||
.border_color(cx.theme().colors.border)
|
|
||||||
.child(
|
|
||||||
div().flex_1().min_h_0().child(
|
|
||||||
Tree::new("repo-tree")
|
|
||||||
.selected(selected)
|
|
||||||
.items(move |_, _| {
|
|
||||||
rows.iter()
|
|
||||||
.map(|row| {
|
|
||||||
let item = TreeItem::new(row.id.clone(), row.label.clone())
|
|
||||||
.depth(row.depth)
|
|
||||||
.expandable(row.expandable)
|
|
||||||
.expanded(row.expanded);
|
|
||||||
|
|
||||||
if row.expandable {
|
|
||||||
item.icon(Icon::named(IconRole::Folder))
|
|
||||||
} else {
|
|
||||||
item
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.on_change({
|
|
||||||
let view = change_view;
|
|
||||||
move |index, window, cx| {
|
|
||||||
if let Some(index) = index
|
|
||||||
&& let Err(error) = view.update(cx, |this, cx| {
|
|
||||||
this.activate_row(index, window, cx);
|
|
||||||
})
|
|
||||||
{
|
|
||||||
log::warn!("failed to activate tree row: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_disclosure({
|
|
||||||
let view = disclosure_view;
|
|
||||||
move |index, _, cx| {
|
|
||||||
if let Err(error) = view.update(cx, |this, cx| {
|
|
||||||
let id = this.rows.get(index).map(|row| row.id.clone());
|
|
||||||
if let Some(id) = id {
|
|
||||||
this.toggle_folder(&id);
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to toggle folder: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()))
|
|
||||||
} else {
|
|
||||||
self.code_element(path.as_ref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(FileContent::Binary) => placeholder("Binary file - preview not supported"),
|
|
||||||
Some(FileContent::TooLarge) => placeholder("File is too large to preview"),
|
|
||||||
Some(FileContent::Failed(message)) => placeholder(message),
|
|
||||||
None => preview_spinner(),
|
|
||||||
}
|
|
||||||
} else if self.readme_name.is_some() {
|
|
||||||
self.markdown_element(None)
|
|
||||||
} else {
|
|
||||||
placeholder("No README found")
|
|
||||||
};
|
|
||||||
|
|
||||||
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().colors.muted)
|
|
||||||
.border_b_1()
|
|
||||||
.border_color(cx.theme().colors.border)
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
.text_ellipsis()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.child(pane_title),
|
|
||||||
)
|
|
||||||
.when_some(commit, |this, commit| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_family(cx.theme().mono_font)
|
|
||||||
.child(commit.id.clone()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.max_w(px(250.))
|
|
||||||
.text_xs()
|
|
||||||
.text_ellipsis()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.child(commit.summary.clone()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(div().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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(md) = self.md.take()
|
|
||||||
&& md.path == path
|
|
||||||
{
|
|
||||||
md.state.update(cx, |state, cx| {
|
|
||||||
state.set_source(text, cx);
|
|
||||||
});
|
|
||||||
self.md = Some(MarkdownView {
|
|
||||||
path,
|
|
||||||
state: md.state,
|
|
||||||
source_hash: hash,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = cx.new(|_| MarkdownViewState::new("repo-readme", text));
|
|
||||||
self.md = Some(MarkdownView {
|
|
||||||
path,
|
|
||||||
state,
|
|
||||||
source_hash: hash,
|
|
||||||
});
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn markdown_element(&self, path: Option<&str>) -> 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
div()
|
|
||||||
.p_4()
|
|
||||||
.size_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(md.state.clone())
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_code(
|
|
||||||
&mut self,
|
|
||||||
path: SharedString,
|
|
||||||
text: &str,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let hash = source_hash(text);
|
|
||||||
let language = code_language(path.as_ref());
|
|
||||||
|
|
||||||
if let Some(code) = &self.code
|
|
||||||
&& code.path == path
|
|
||||||
&& code.source_hash == hash
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(code) = self.code.take()
|
|
||||||
&& code.path == path
|
|
||||||
{
|
|
||||||
code.state.update(cx, |state, cx| {
|
|
||||||
state.set_source(text, language, cx);
|
|
||||||
});
|
|
||||||
self.code = Some(CodeView {
|
|
||||||
path,
|
|
||||||
state: code.state,
|
|
||||||
source_hash: hash,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = cx.new(|cx| CodeViewState::new("repo-code", text, language, cx));
|
|
||||||
let _ = window;
|
|
||||||
self.code = Some(CodeView {
|
|
||||||
path,
|
|
||||||
state,
|
|
||||||
source_hash: hash,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn code_element(&self, path: &str) -> AnyElement {
|
|
||||||
let Some(code) = &self.code else {
|
|
||||||
return preview_spinner();
|
|
||||||
};
|
|
||||||
if code.path.as_ref() != path {
|
|
||||||
return preview_spinner();
|
|
||||||
}
|
|
||||||
|
|
||||||
div()
|
|
||||||
.size_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(code.state.clone())
|
|
||||||
.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 Some(path) = self.file_order.pop_front() else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
|
|
||||||
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().and_then(|md| md.path.as_deref()) == 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 {
|
|
||||||
Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&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 rows = self.rows.clone();
|
|
||||||
let selected = self.selected_index;
|
|
||||||
let view = cx.entity().downgrade();
|
|
||||||
let pane_title = self.pane_title();
|
|
||||||
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.w_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(self.render_tree_column(rows, selected, view, cx))
|
|
||||||
.child(self.render_content_column(pane_title, cx))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn folder_ids(seeds: &[TreeItemSeed]) -> HashSet<String> {
|
|
||||||
fn push(seed: &TreeItemSeed, ids: &mut HashSet<String>) {
|
|
||||||
if !seed.children.is_empty() {
|
|
||||||
ids.insert(seed.id.clone());
|
|
||||||
for child in &seed.children {
|
|
||||||
push(child, ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ids = HashSet::new();
|
|
||||||
for seed in seeds {
|
|
||||||
push(seed, &mut ids);
|
|
||||||
}
|
|
||||||
ids
|
|
||||||
}
|
|
||||||
|
|
||||||
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("repo-preview-spinner"))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn placeholder(message: impl Into<SharedString>) -> AnyElement {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(EmptyState::new(message))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The kit grammar for a path; only four languages are supported.
|
|
||||||
fn code_language(path: &str) -> Language {
|
|
||||||
match Path::new(path)
|
|
||||||
.extension()
|
|
||||||
.and_then(|ext| ext.to_str())
|
|
||||||
.map(|ext| ext.to_ascii_lowercase())
|
|
||||||
.as_deref()
|
|
||||||
{
|
|
||||||
Some("rs") => Language::Rust,
|
|
||||||
Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript,
|
|
||||||
Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript,
|
|
||||||
Some("py") => Language::Python,
|
|
||||||
_ => Language::Plain,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
|
||||||
use gpui::{
|
|
||||||
AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
|
||||||
Render, SharedString, StatefulInteractiveElement as _, Styled as _, Task,
|
|
||||||
UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::common::{h_flex, v_flex};
|
|
||||||
use gpui_native_kit::empty_state::EmptyState;
|
|
||||||
use gpui_native_kit::layout::dock::DockState;
|
|
||||||
use gpui_native_kit::scrollbar::Scrollbar;
|
|
||||||
use gpui_native_kit::spinner::Spinner;
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use signed_git::{CommitList, FileCommit, Repo};
|
|
||||||
use signed_state::RepoStore;
|
|
||||||
use utils::relative_time_secs;
|
|
||||||
|
|
||||||
use super::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView};
|
|
||||||
use super::repo_display_name;
|
|
||||||
use crate::panels;
|
|
||||||
|
|
||||||
pub struct RepoHistoryView {
|
|
||||||
store: Entity<RepoStore>,
|
|
||||||
dock: WeakEntity<DockState>,
|
|
||||||
worktree: Option<PathBuf>,
|
|
||||||
all_commits: Option<CommitList>,
|
|
||||||
loading_all_commits: bool,
|
|
||||||
scroll_handle: UniformListScrollHandle,
|
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RepoHistoryView {
|
|
||||||
pub fn new(store: Entity<RepoStore>, dock: WeakEntity<DockState>) -> Self {
|
|
||||||
Self {
|
|
||||||
store,
|
|
||||||
dock,
|
|
||||||
worktree: None,
|
|
||||||
all_commits: None,
|
|
||||||
loading_all_commits: false,
|
|
||||||
scroll_handle: UniformListScrollHandle::new(),
|
|
||||||
tasks: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_worktree(&mut self, path: Option<PathBuf>) {
|
|
||||||
self.worktree = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of commits reachable from HEAD, for the Commits tab label.
|
|
||||||
pub fn commit_count(&self) -> Option<usize> {
|
|
||||||
self.all_commits.as_ref().map(|list| list.total)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop the current list and walk HEAD again.
|
|
||||||
pub 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 {
|
|
||||||
Repo::open_cached(&worktree).and_then(|repo| repo.all_commits())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
if let Ok(list) = result {
|
|
||||||
this.all_commits = Some(list);
|
|
||||||
}
|
|
||||||
this.loading_all_commits = false;
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_commit_diff(
|
|
||||||
&mut self,
|
|
||||||
commit_id: &str,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) {
|
|
||||||
let Some(worktree) = self.worktree.clone() 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 commit_id = commit_id.to_owned();
|
|
||||||
let id = match store.read(cx).addr() {
|
|
||||||
Some(addr) => SharedString::from(format!("commit:{addr}:{commit_id}")),
|
|
||||||
None => SharedString::from(format!("commit:{}:{}", worktree.display(), commit_id)),
|
|
||||||
};
|
|
||||||
let dock = self.dock.clone();
|
|
||||||
|
|
||||||
panels::open(
|
|
||||||
&dock,
|
|
||||||
id,
|
|
||||||
move |window, cx| {
|
|
||||||
cx.new(|cx| {
|
|
||||||
CommitDiffView::new(
|
|
||||||
store.clone(),
|
|
||||||
worktree.clone(),
|
|
||||||
repo_name.clone(),
|
|
||||||
commit_id.clone(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
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("commits-loading"))
|
|
||||||
.into_any_element()
|
|
||||||
} else {
|
|
||||||
empty_state("Failed to load commits")
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if list.commits.is_empty() {
|
|
||||||
return empty_state("No commits found");
|
|
||||||
}
|
|
||||||
|
|
||||||
let commits = list.commits.clone();
|
|
||||||
let shown = commits.len();
|
|
||||||
let total = list.total;
|
|
||||||
let view = cx.entity().downgrade();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.relative()
|
|
||||||
.flex_1()
|
|
||||||
.w_full()
|
|
||||||
.min_h_0()
|
|
||||||
.child(
|
|
||||||
uniform_list("commits", shown, move |range, _, cx| {
|
|
||||||
range
|
|
||||||
.map(|ix| {
|
|
||||||
let commit = commits[ix].clone();
|
|
||||||
let id = commit.id.clone();
|
|
||||||
let view = view.clone();
|
|
||||||
commit_row(
|
|
||||||
ix,
|
|
||||||
&commit,
|
|
||||||
move |window, cx| {
|
|
||||||
if let Err(error) = view.update(cx, |this, cx| {
|
|
||||||
this.open_commit_diff(&id, window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to open commit diff: {error}");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.track_scroll(&self.scroll_handle)
|
|
||||||
.size_full(),
|
|
||||||
)
|
|
||||||
.when(shown < total, |this| {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.py_2()
|
|
||||||
.w_full()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().colors.muted_foreground)
|
|
||||||
.child(format!("Showing {shown} of {total} commits")),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.absolute()
|
|
||||||
.top_0()
|
|
||||||
.left_0()
|
|
||||||
.right_0()
|
|
||||||
.bottom_0()
|
|
||||||
.child(Scrollbar::vertical(
|
|
||||||
"commits-scroll",
|
|
||||||
self.scroll_handle.clone(),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_state(message: &'static str) -> gpui::AnyElement {
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.child(EmptyState::new(message))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One commit row shared by the repository history and pull-request commit list.
|
|
||||||
pub fn commit_row(
|
|
||||||
ix: usize,
|
|
||||||
commit: &FileCommit,
|
|
||||||
on_click: impl Fn(&mut Window, &mut gpui::App) + 'static,
|
|
||||||
cx: &gpui::App,
|
|
||||||
) -> gpui::AnyElement {
|
|
||||||
h_flex()
|
|
||||||
.id(ix)
|
|
||||||
.px_4()
|
|
||||||
.h(px(COMMIT_ROW_HEIGHT))
|
|
||||||
.w_full()
|
|
||||||
.gap_3()
|
|
||||||
.border_b_1()
|
|
||||||
.border_color(cx.theme().colors.border)
|
|
||||||
.hover(|this| this.bg(cx.theme().colors.muted_hover))
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.flex_1()
|
|
||||||
.min_w_0()
|
|
||||||
.gap_1()
|
|
||||||
.justify_center()
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_family(cx.theme().mono_font)
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().colors.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().colors.muted_foreground)
|
|
||||||
.child(commit.author.clone())
|
|
||||||
.child(relative_time_secs(commit.time)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.on_click(move |_, window, cx| on_click(window, cx))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,139 +0,0 @@
|
|||||||
use gpui::{App, AppContext as _, Entity, SharedString, WeakEntity, Window};
|
|
||||||
use gpui_native_kit::combobox::{Combobox, ComboboxState};
|
|
||||||
|
|
||||||
use super::RepoDetailView;
|
|
||||||
|
|
||||||
pub(super) struct RefSwitcher {
|
|
||||||
pub(super) branch_select: Entity<ComboboxState<SharedString>>,
|
|
||||||
pub(super) tag_select: Entity<ComboboxState<SharedString>>,
|
|
||||||
ref_branches: Vec<SharedString>,
|
|
||||||
ref_tags: Vec<SharedString>,
|
|
||||||
pub(super) switching_ref: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RefSwitcher {
|
|
||||||
pub(super) fn new(view: WeakEntity<RepoDetailView>, window: &mut Window, cx: &mut App) -> Self {
|
|
||||||
let branch_view = view.clone();
|
|
||||||
let branch_select = cx.new(|cx| {
|
|
||||||
ComboboxState::new(
|
|
||||||
Combobox::new("branch-select", "Branch", Vec::new())
|
|
||||||
.placeholder("Branch")
|
|
||||||
.on_change(move |value, window, cx| {
|
|
||||||
if let Some(name) = value {
|
|
||||||
if let Err(error) = branch_view.update(cx, |this, cx| {
|
|
||||||
this.switch_ref(super::RefKind::Branch, name, window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to switch branch: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let tag_view = view;
|
|
||||||
let tag_select = cx.new(|cx| {
|
|
||||||
ComboboxState::new(
|
|
||||||
Combobox::new("tag-select", "Tag", Vec::new())
|
|
||||||
.placeholder("Tag")
|
|
||||||
.on_change(move |value, window, cx| {
|
|
||||||
if let Some(name) = value {
|
|
||||||
if let Err(error) = tag_view.update(cx, |this, cx| {
|
|
||||||
this.switch_ref(super::RefKind::Tag, name, window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to switch tag: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
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<SharedString>>,
|
|
||||||
previous: &Option<SharedString>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
select.update(cx, |state, cx| {
|
|
||||||
state.set_selected(previous.clone(), window, cx)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_selector(
|
|
||||||
select: &Entity<ComboboxState<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() != Some(value));
|
|
||||||
|
|
||||||
if !items_changed && !selection_changed {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
select.update(cx, |state, cx| {
|
|
||||||
if items_changed {
|
|
||||||
state.set_options(
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.map(|item| (item.clone(), item.clone()))
|
|
||||||
.collect(),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(value) = selected
|
|
||||||
&& (items_changed || selection_changed)
|
|
||||||
{
|
|
||||||
state.set_selected(Some(value), window, cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*cached = items;
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
@@ -1,616 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use gpui::{
|
|
||||||
AnyElement, App, AppContext as _, Context, FontWeight, IntoElement, ObjectFit,
|
|
||||||
ParentElement as _, SharedString, Styled as _, StyledImage as _, Subscription, WeakEntity,
|
|
||||||
Window, div, img, px,
|
|
||||||
};
|
|
||||||
use gpui_native_kit::avatar::Avatar;
|
|
||||||
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
|
||||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
|
||||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
|
||||||
use gpui_native_kit::data::sidebar::{Sidebar, SidebarEntry};
|
|
||||||
use gpui_native_kit::icons::{Icon, IconRole};
|
|
||||||
use gpui_native_kit::layout::dock::DockState;
|
|
||||||
use gpui_native_kit::menu::{Menu, MenuItem};
|
|
||||||
use gpui_native_kit::theme::ActiveTheme as _;
|
|
||||||
use gpui_native_kit::tooltip::Tooltip;
|
|
||||||
use metadata::{Profile, ProfileDirectory};
|
|
||||||
use nostr::prelude::PublicKey;
|
|
||||||
use signed_core::{Announcement, RepoAddr};
|
|
||||||
use signed_state::{
|
|
||||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
|
||||||
ResolvedLocalRepo, SyncStatusDirectory,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::dialogs;
|
|
||||||
|
|
||||||
pub struct SidebarView {
|
|
||||||
weak: WeakEntity<Self>,
|
|
||||||
dock: WeakEntity<DockState>,
|
|
||||||
banner: SharedString,
|
|
||||||
scanning: bool,
|
|
||||||
/// User's announced repositories.
|
|
||||||
announcements: Arc<Vec<Announcement>>,
|
|
||||||
/// Local repositories found by the scan that are not announced yet.
|
|
||||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
|
||||||
/// Unpushed commit counts per announced repository.
|
|
||||||
unpushed: HashMap<RepoAddr, usize>,
|
|
||||||
/// Profiles rendered by the sidebar.
|
|
||||||
directory: gpui::Entity<ProfileDirectory>,
|
|
||||||
/// Sync statuses for the announced repositories.
|
|
||||||
sync_directory: gpui::Entity<SyncStatusDirectory>,
|
|
||||||
user_menu_open: bool,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarView {
|
|
||||||
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> Self {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let repos = RepoListStore::global(cx);
|
|
||||||
let local = LocalReposStore::global(cx);
|
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
|
||||||
|
|
||||||
let mut subscriptions = Vec::new();
|
|
||||||
|
|
||||||
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
|
|
||||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
|
||||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
|
||||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
|
||||||
|
|
||||||
if signer_required {
|
|
||||||
this.banner = pick_banner();
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
if this.refresh(cx) || signer_changed {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
|
||||||
if this.refresh(cx) {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
|
||||||
if this.refresh(cx) {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
Self {
|
|
||||||
weak: cx.entity().downgrade(),
|
|
||||||
dock,
|
|
||||||
banner: pick_banner(),
|
|
||||||
announcements: Arc::new(Vec::new()),
|
|
||||||
local_repos: Arc::new(Vec::new()),
|
|
||||||
scanning: false,
|
|
||||||
unpushed: HashMap::new(),
|
|
||||||
directory: ProfileDirectory::new(cx),
|
|
||||||
sync_directory: SyncStatusDirectory::new(cx),
|
|
||||||
user_menu_open: false,
|
|
||||||
_subscriptions: subscriptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let user = backend.read(cx).current_user();
|
|
||||||
|
|
||||||
let (announcements, local_repos, scanning) = {
|
|
||||||
let repo_list = RepoListStore::global(cx).read(cx);
|
|
||||||
|
|
||||||
let announcements = user
|
|
||||||
.as_ref()
|
|
||||||
.map(|user| repo_list.announcements_of(user))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let local = LocalReposStore::global(cx).read(cx);
|
|
||||||
let local_repos =
|
|
||||||
LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements);
|
|
||||||
|
|
||||||
(announcements, local_repos, local.scanning)
|
|
||||||
};
|
|
||||||
|
|
||||||
let announcements_changed = *self.announcements != announcements;
|
|
||||||
let local_changed = *self.local_repos != local_repos;
|
|
||||||
let scanning_changed = self.scanning != scanning;
|
|
||||||
|
|
||||||
self.announcements = Arc::new(announcements);
|
|
||||||
self.local_repos = Arc::new(local_repos);
|
|
||||||
self.scanning = scanning;
|
|
||||||
|
|
||||||
let mut profiles: Vec<PublicKey> = user.into_iter().collect();
|
|
||||||
let owners: Vec<PublicKey> = self
|
|
||||||
.local_repos
|
|
||||||
.iter()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
entry
|
|
||||||
.nip34
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|binding| binding.owner)
|
|
||||||
.filter(|owner| !profiles.contains(owner))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
profiles.extend(owners);
|
|
||||||
|
|
||||||
self.directory.update(cx, |directory, cx| {
|
|
||||||
directory.track(profiles, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
let addrs: Vec<RepoAddr> = self
|
|
||||||
.announcements
|
|
||||||
.iter()
|
|
||||||
.map(|announcement| announcement.addr())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
self.sync_directory.update(cx, |directory, cx| {
|
|
||||||
directory.track(addrs, cx);
|
|
||||||
});
|
|
||||||
|
|
||||||
if announcements_changed {
|
|
||||||
self.request_push_watches(cx);
|
|
||||||
self.unpushed.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
announcements_changed || local_changed || scanning_changed
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
|
||||||
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.read(cx).unpushed(&addr);
|
|
||||||
if count > 0 {
|
|
||||||
unpushed.insert(addr, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if unpushed == self.unpushed {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.unpushed = unpushed;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
|
||||||
checkouts.update(cx, |checkouts, cx| {
|
|
||||||
for announcement in self.announcements.iter() {
|
|
||||||
checkouts.request_push_statuses(&announcement.addr(), cx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders the sidebar for the current identity state.
|
|
||||||
pub fn build(&self, cx: &App) -> Sidebar {
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
|
|
||||||
let profile = backend
|
|
||||||
.read(cx)
|
|
||||||
.current_user()
|
|
||||||
.map(|public_key| self.directory.read(cx).profile(&public_key, cx));
|
|
||||||
|
|
||||||
let Some(profile) = profile else {
|
|
||||||
return Sidebar::new("signed-sidebar").content(self.render_sign_in(cx));
|
|
||||||
};
|
|
||||||
|
|
||||||
let weak = self.weak.clone();
|
|
||||||
let announcements = self.announcements.clone();
|
|
||||||
let local_repos = self.local_repos.clone();
|
|
||||||
let unpushed = self.unpushed.clone();
|
|
||||||
let directory = self.directory.clone();
|
|
||||||
let sync_directory = self.sync_directory.clone();
|
|
||||||
|
|
||||||
Sidebar::new("signed-sidebar")
|
|
||||||
.transparent(true)
|
|
||||||
.header(self.render_user(&profile, self.scanning, cx))
|
|
||||||
.entries(move |_, cx| {
|
|
||||||
let mut entries = vec![SidebarEntry::header("All Repositories")];
|
|
||||||
|
|
||||||
for announcement in announcements.iter() {
|
|
||||||
let owner = directory.read(cx).profile(&announcement.owner, cx);
|
|
||||||
let name = announcement
|
|
||||||
.name
|
|
||||||
.as_deref()
|
|
||||||
.map(SharedString::from)
|
|
||||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
|
||||||
let addr = announcement.addr();
|
|
||||||
let mut row = SidebarEntry::row(
|
|
||||||
SharedString::from(format!("repo:{}", announcement.id)),
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
.prefix(
|
|
||||||
Avatar::new(announcement.owner)
|
|
||||||
.name(owner.name())
|
|
||||||
.picture(owner.picture())
|
|
||||||
.small(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let count = unpushed.get(&addr).copied().unwrap_or(0);
|
|
||||||
let unsynced = sync_directory.read(cx).unsynced(&addr, cx);
|
|
||||||
|
|
||||||
if count > 0 || unsynced > 0 {
|
|
||||||
let mut badges = h_flex().gap_1();
|
|
||||||
if count > 0 {
|
|
||||||
badges = badges.child(
|
|
||||||
Badge::new(
|
|
||||||
SharedString::from(format!("unpushed:{addr}")),
|
|
||||||
count.to_string(),
|
|
||||||
)
|
|
||||||
.variant(BadgeVariant::Danger),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if unsynced > 0 {
|
|
||||||
badges = badges.child(
|
|
||||||
Badge::new(
|
|
||||||
SharedString::from(format!("unsynced:{addr}")),
|
|
||||||
unsynced.to_string(),
|
|
||||||
)
|
|
||||||
.variant(BadgeVariant::Accent),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
row = row.suffix(badges);
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in local_repos.iter() {
|
|
||||||
let suffix: AnyElement = match entry.nip34.as_ref() {
|
|
||||||
Some(binding) => {
|
|
||||||
let (label, note) = match binding.kind {
|
|
||||||
Nip34Kind::Initialized => {
|
|
||||||
let platform = local_platform(binding).unwrap_or("Grasp");
|
|
||||||
(platform, "Initialized for NIP-34")
|
|
||||||
}
|
|
||||||
Nip34Kind::Cloned => ("Cloned", "Cloned but not initialized"),
|
|
||||||
Nip34Kind::ToolingOnly => ("Tooling", "Grasp tooling only"),
|
|
||||||
};
|
|
||||||
Badge::new(
|
|
||||||
SharedString::from(format!("local-badge:{}", entry.path.display())),
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
.tooltip(Tooltip::text(note))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
None => Badge::new(
|
|
||||||
SharedString::from(format!(
|
|
||||||
"local-unpublished:{}",
|
|
||||||
entry.path.display()
|
|
||||||
)),
|
|
||||||
"Unpublished",
|
|
||||||
)
|
|
||||||
.variant(BadgeVariant::Warning)
|
|
||||||
.into_any_element(),
|
|
||||||
};
|
|
||||||
|
|
||||||
entries.push(
|
|
||||||
SidebarEntry::row(
|
|
||||||
SharedString::from(format!("local-repo:{}", entry.path.display())),
|
|
||||||
entry.name(),
|
|
||||||
)
|
|
||||||
.prefix(local_avatar(entry, &directory, cx))
|
|
||||||
.suffix(suffix),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
entries
|
|
||||||
})
|
|
||||||
.on_change(move |index, window, cx| {
|
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.activate(index, window, cx)) {
|
|
||||||
log::warn!("failed to activate sidebar row: {error}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate(&mut self, index: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
let Some(index) = index else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(row) = index.checked_sub(1) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if row < self.announcements.len() {
|
|
||||||
let announcement = self.announcements[row].clone();
|
|
||||||
let addr = announcement.addr();
|
|
||||||
crate::views::repo::actions::open_repo_panel(
|
|
||||||
&self.dock,
|
|
||||||
&addr,
|
|
||||||
Some(&announcement),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let local_ix = row - self.announcements.len();
|
|
||||||
if let Some(entry) = self.local_repos.get(local_ix).cloned() {
|
|
||||||
self.open_local_entry(entry, window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A local repository opens as the announced repository when its binding matches one.
|
|
||||||
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 {
|
|
||||||
let id = SharedString::from(format!("repo:{}", announcement.addr()));
|
|
||||||
let dock = self.dock.clone();
|
|
||||||
let dock_for_build = dock.clone();
|
|
||||||
crate::panels::open(
|
|
||||||
&dock,
|
|
||||||
id,
|
|
||||||
move |window, cx| {
|
|
||||||
cx.new(|cx| {
|
|
||||||
crate::views::repo::RepoDetailView::new_local_announced(
|
|
||||||
dock_for_build.clone(),
|
|
||||||
announcement.clone(),
|
|
||||||
path.clone(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = SharedString::from(format!("repo-local:{}", path.display()));
|
|
||||||
let dock = self.dock.clone();
|
|
||||||
let dock_for_build = dock.clone();
|
|
||||||
crate::panels::open(
|
|
||||||
&dock,
|
|
||||||
id,
|
|
||||||
move |window, cx| {
|
|
||||||
cx.new(|cx| {
|
|
||||||
crate::views::repo::RepoDetailView::new_local(
|
|
||||||
dock_for_build.clone(),
|
|
||||||
path.clone(),
|
|
||||||
nip34.clone(),
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if let Err(error) = self
|
|
||||||
.dock
|
|
||||||
.update(cx, |dock, cx| dock.open_panel(id, window, cx))
|
|
||||||
{
|
|
||||||
log::warn!("failed to open panel {id}: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_user(&self, profile: &Profile, scanning: bool, cx: &App) -> impl IntoElement {
|
|
||||||
let name = profile.name();
|
|
||||||
let picture = profile.picture();
|
|
||||||
let public_key = profile.public_key();
|
|
||||||
let weak = self.weak.clone();
|
|
||||||
let weak_for_open = self.weak.clone();
|
|
||||||
let muted = cx.theme().colors.muted_foreground;
|
|
||||||
let dock = self.dock.clone();
|
|
||||||
|
|
||||||
let menu = Menu::new("user-menu")
|
|
||||||
.open(self.user_menu_open)
|
|
||||||
.on_change({
|
|
||||||
let weak = weak_for_open.clone();
|
|
||||||
move |open, _, cx| {
|
|
||||||
if let Err(error) = weak.update(cx, |this, cx| {
|
|
||||||
this.user_menu_open = open;
|
|
||||||
cx.notify();
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to update user menu: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.trigger({
|
|
||||||
let name = name.clone();
|
|
||||||
let picture = picture.clone();
|
|
||||||
move |_, _| {
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.items_center()
|
|
||||||
.child(
|
|
||||||
Avatar::new(public_key)
|
|
||||||
.name(name.clone())
|
|
||||||
.picture(picture.clone()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_weight(FontWeight::SEMIBOLD)
|
|
||||||
.child(name.clone()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Icon::named(IconRole::ChevronDown)
|
|
||||||
.size(px(12.))
|
|
||||||
.color(muted),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.items(move |_, _| {
|
|
||||||
let profile_weak = weak.clone();
|
|
||||||
let relays_weak = weak.clone();
|
|
||||||
|
|
||||||
vec![
|
|
||||||
MenuItem::new("View Profile").handler(move |window, cx| {
|
|
||||||
if let Err(error) = profile_weak.update(cx, |this, cx| {
|
|
||||||
this.open_panel("profile", window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to open profile: {error}");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
MenuItem::new("View Relays").handler(move |window, cx| {
|
|
||||||
if let Err(error) = relays_weak.update(cx, |this, cx| {
|
|
||||||
this.open_panel("relays", window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to open relays: {error}");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
MenuItem::separator(),
|
|
||||||
MenuItem::new("Exit")
|
|
||||||
.destructive(true)
|
|
||||||
.handler(|_, cx| dialogs::exit::open(cx)),
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
h_flex().w_full().justify_between().child(menu).child(
|
|
||||||
h_flex()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
Button::new("rescan-repos")
|
|
||||||
.icon(Icon::named(IconRole::Loader))
|
|
||||||
.variant(ButtonVariant::Ghost)
|
|
||||||
.loading(scanning)
|
|
||||||
.disabled(scanning)
|
|
||||||
.tooltip(Tooltip::text("Rescan for local repositories"))
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
let local_repos = LocalReposStore::global(cx);
|
|
||||||
local_repos.update(cx, |store, cx| store.rescan(cx));
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("add-repo")
|
|
||||||
.icon(Icon::named(IconRole::Plus))
|
|
||||||
.variant(ButtonVariant::Ghost)
|
|
||||||
.tooltip(Tooltip::text("Create repository"))
|
|
||||||
.on_click({
|
|
||||||
let dock = dock.clone();
|
|
||||||
move |_, window, cx| {
|
|
||||||
dialogs::create_repo::open(dock.clone(), window, cx);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shown while no identity is signed in.
|
|
||||||
fn render_sign_in(&self, _cx: &App) -> impl IntoElement {
|
|
||||||
let weak = self.weak.clone();
|
|
||||||
let import_weak = self.weak.clone();
|
|
||||||
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.relative()
|
|
||||||
.child(
|
|
||||||
div().absolute().inset_0().child(
|
|
||||||
img(self.banner.clone())
|
|
||||||
.size_full()
|
|
||||||
.object_fit(ObjectFit::Cover),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
.justify_end()
|
|
||||||
.p_4()
|
|
||||||
.gap_4()
|
|
||||||
.child(img("backgrounds/headline.png").max_w_48())
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.gap_1()
|
|
||||||
.w_full()
|
|
||||||
.child(Button::new("onboarding").label("Join now").on_click(
|
|
||||||
move |_, window, cx| {
|
|
||||||
if let Err(error) = weak.update(cx, |this, cx| {
|
|
||||||
this.open_panel("welcome", window, cx);
|
|
||||||
crate::dialogs::open_onboarding(cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to open onboarding: {error}");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.child(
|
|
||||||
Button::new("import")
|
|
||||||
.label("Import identity")
|
|
||||||
.variant(ButtonVariant::Secondary)
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
if let Err(error) = import_weak
|
|
||||||
.update(cx, |_, cx| crate::dialogs::open_import(cx))
|
|
||||||
{
|
|
||||||
log::warn!("failed to open import: {error}");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pick_banner() -> SharedString {
|
|
||||||
let num = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|duration| duration.subsec_nanos())
|
|
||||||
.unwrap_or(1)
|
|
||||||
% 3
|
|
||||||
+ 1;
|
|
||||||
format!("backgrounds/banner{num}.jpg").into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bound owner's avatar, falling back to the path's pixel avatar when unowned.
|
|
||||||
fn local_avatar(
|
|
||||||
entry: &ResolvedLocalRepo,
|
|
||||||
directory: &gpui::Entity<ProfileDirectory>,
|
|
||||||
cx: &App,
|
|
||||||
) -> AnyElement {
|
|
||||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
|
||||||
return Avatar::new(entry.path.to_string_lossy())
|
|
||||||
.small()
|
|
||||||
.into_any_element();
|
|
||||||
};
|
|
||||||
|
|
||||||
let profile = directory.read(cx).profile(&owner, cx);
|
|
||||||
|
|
||||||
Avatar::new(owner)
|
|
||||||
.name(profile.name())
|
|
||||||
.picture(profile.picture())
|
|
||||||
.small()
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub struct TreeItemSeed {
|
|
||||||
/// Path of the node, relative to the worktree root.
|
|
||||||
pub id: String,
|
|
||||||
pub label: String,
|
|
||||||
pub children: Vec<TreeItemSeed>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One visible row of the flattened tree.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TreeRow {
|
|
||||||
pub id: String,
|
|
||||||
pub label: String,
|
|
||||||
pub depth: usize,
|
|
||||||
pub expandable: bool,
|
|
||||||
pub expanded: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flattens the seeds into the rows a flat tree renders, honoring `expanded`.
|
|
||||||
pub fn flatten(seeds: &[TreeItemSeed], expanded: &HashSet<String>) -> Vec<TreeRow> {
|
|
||||||
fn push(
|
|
||||||
seed: &TreeItemSeed,
|
|
||||||
depth: usize,
|
|
||||||
expanded: &HashSet<String>,
|
|
||||||
rows: &mut Vec<TreeRow>,
|
|
||||||
) {
|
|
||||||
let is_expanded = expanded.contains(&seed.id);
|
|
||||||
rows.push(TreeRow {
|
|
||||||
id: seed.id.clone(),
|
|
||||||
label: seed.label.clone(),
|
|
||||||
depth,
|
|
||||||
expandable: !seed.children.is_empty(),
|
|
||||||
expanded: is_expanded,
|
|
||||||
});
|
|
||||||
|
|
||||||
if is_expanded {
|
|
||||||
for child in &seed.children {
|
|
||||||
push(child, depth + 1, expanded, rows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
for seed in seeds {
|
|
||||||
push(seed, 0, expanded, &mut rows);
|
|
||||||
}
|
|
||||||
rows
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every folder id, for starting with the tree fully expanded.
|
|
||||||
pub fn collect_expanded(seeds: &[TreeItemSeed]) -> HashSet<String> {
|
|
||||||
fn push(seed: &TreeItemSeed, expanded: &mut HashSet<String>) {
|
|
||||||
if !seed.children.is_empty() {
|
|
||||||
expanded.insert(seed.id.clone());
|
|
||||||
for child in &seed.children {
|
|
||||||
push(child, expanded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut expanded = HashSet::new();
|
|
||||||
for seed in seeds {
|
|
||||||
push(seed, &mut expanded);
|
|
||||||
}
|
|
||||||
expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
|
||||||
pub 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.
|
|
||||||
pub 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::*;
|
|
||||||
|
|
||||||
fn ids(rows: &[TreeRow]) -> Vec<(&str, usize)> {
|
|
||||||
rows.iter()
|
|
||||||
.map(|row| (row.id.as_str(), row.depth))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn collapsed_folders_hide_children() {
|
|
||||||
let seeds = build_tree_items(&[
|
|
||||||
PathBuf::from("src"),
|
|
||||||
PathBuf::from("src/lib.rs"),
|
|
||||||
PathBuf::from("README.md"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let rows = flatten(&seeds, &HashSet::new());
|
|
||||||
|
|
||||||
assert_eq!(ids(&rows), vec![("src", 0), ("README.md", 0)]);
|
|
||||||
assert!(rows[0].expandable);
|
|
||||||
assert!(!rows[0].expanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expanding_a_folder_shows_children_in_order() {
|
|
||||||
let seeds = build_tree_items(&[
|
|
||||||
PathBuf::from("src"),
|
|
||||||
PathBuf::from("src/lib.rs"),
|
|
||||||
PathBuf::from("src/views/mod.rs"),
|
|
||||||
PathBuf::from("README.md"),
|
|
||||||
]);
|
|
||||||
let expanded = HashSet::from(["src".to_owned(), "src/views".to_owned()]);
|
|
||||||
|
|
||||||
let rows = flatten(&seeds, &expanded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
ids(&rows),
|
|
||||||
vec![
|
|
||||||
("src", 0),
|
|
||||||
("src/lib.rs", 1),
|
|
||||||
("src/views", 1),
|
|
||||||
("src/views/mod.rs", 2),
|
|
||||||
("README.md", 0),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn indices_map_to_row_ids() {
|
|
||||||
let seeds = build_tree_items(&[
|
|
||||||
PathBuf::from("docs"),
|
|
||||||
PathBuf::from("docs/guide.md"),
|
|
||||||
PathBuf::from("README.md"),
|
|
||||||
]);
|
|
||||||
let expanded = HashSet::from(["docs".to_owned()]);
|
|
||||||
let rows = flatten(&seeds, &expanded);
|
|
||||||
|
|
||||||
assert_eq!(rows[0].id, "docs");
|
|
||||||
assert_eq!(rows[1].id, "docs/guide.md");
|
|
||||||
assert_eq!(rows[2].id, "README.md");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,5 @@ edition.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
gpui.workspace = true
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
utils = { path = "../utils" }
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
use std::fmt;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
||||||
#[serde(transparent)]
|
///
|
||||||
pub struct RepoAddr(Coordinate);
|
/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this,
|
||||||
|
/// the alias reuses the SDK type while keeping repository-specific vocabulary.
|
||||||
|
pub type RepoAddr = Coordinate;
|
||||||
|
|
||||||
impl RepoAddr {
|
/// Build the address of a NIP-34 repository announcement.
|
||||||
pub fn new(owner: PublicKey, identifier: impl Into<String>) -> Self {
|
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
||||||
Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier))
|
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a repository identifier from a display name
|
||||||
pub fn identifier_from_name(name: &str) -> String {
|
pub fn identifier_from_name(name: &str) -> String {
|
||||||
name.chars()
|
name.chars()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
@@ -25,74 +24,16 @@ impl RepoAddr {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn kind(&self) -> Kind {
|
#[cfg(test)]
|
||||||
self.0.kind
|
mod tests {
|
||||||
}
|
use super::*;
|
||||||
|
|
||||||
pub fn public_key(&self) -> PublicKey {
|
#[test]
|
||||||
self.0.public_key
|
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");
|
||||||
pub fn identifier(&self) -> &str {
|
assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
|
||||||
&self.0.identifier
|
assert_eq!(identifier_from_name("a/b"), "a/b");
|
||||||
}
|
assert_eq!(identifier_from_name("Café"), "Caf-");
|
||||||
|
|
||||||
pub fn coordinate(&self) -> &Coordinate {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn announcement_filter(&self) -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::GitRepoAnnouncement)
|
|
||||||
.author(self.public_key())
|
|
||||||
.identifier(self.identifier())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state_filter(&self) -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::RepoState)
|
|
||||||
.author(self.public_key())
|
|
||||||
.identifier(self.identifier())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn activity_filter(&self) -> Filter {
|
|
||||||
Filter::new()
|
|
||||||
.kinds(crate::filters::ACTIVITY_KINDS)
|
|
||||||
.coordinate(&self.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deletion_filters(&self) -> Vec<Filter> {
|
|
||||||
vec![
|
|
||||||
Filter::new()
|
|
||||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
|
||||||
.author(self.public_key()),
|
|
||||||
Filter::new().kind(Kind::EventDeletion).coordinate(&self.0),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for RepoAddr {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
fmt::Display::fmt(&self.0, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for RepoAddr {
|
|
||||||
type Err = <Coordinate as FromStr>::Err;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
Coordinate::from_str(s).map(RepoAddr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Coordinate> for RepoAddr {
|
|
||||||
fn from(coordinate: Coordinate) -> Self {
|
|
||||||
RepoAddr(coordinate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<RepoAddr> for Coordinate {
|
|
||||||
fn from(addr: RepoAddr) -> Self {
|
|
||||||
addr.0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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,492 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
use crate::RepoAddr;
|
|
||||||
use crate::upstream::Upstream;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Announcement {
|
|
||||||
pub event_id: EventId,
|
|
||||||
pub id: String,
|
|
||||||
pub owner: PublicKey,
|
|
||||||
pub created_at: Timestamp,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub web: Vec<Url>,
|
|
||||||
pub clone: Vec<Url>,
|
|
||||||
pub relays: Vec<RelayUrl>,
|
|
||||||
pub euc: Option<String>,
|
|
||||||
pub maintainers: Vec<PublicKey>,
|
|
||||||
pub upstream: Option<Upstream>,
|
|
||||||
pub hashtags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Announcement {
|
|
||||||
/// Lists forks of the base, the user's own first.
|
|
||||||
pub fn forks_in<'a>(
|
|
||||||
announcements: &'a [Announcement],
|
|
||||||
base: &RepoAddr,
|
|
||||||
base_euc: Option<&str>,
|
|
||||||
user: Option<PublicKey>,
|
|
||||||
) -> Vec<&'a Announcement> {
|
|
||||||
let (mut own, mut others) = (Vec::new(), Vec::new());
|
|
||||||
for announcement in announcements {
|
|
||||||
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if Some(announcement.owner) == user {
|
|
||||||
own.push(announcement);
|
|
||||||
} else {
|
|
||||||
others.push(announcement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
own.into_iter().chain(others).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a kind 30617 event, rejecting other kinds and missing `d` tags.
|
|
||||||
pub fn from_event(event: &Event) -> Option<Self> {
|
|
||||||
if event.kind != Kind::GitRepoAnnouncement {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = event.tags.identifier()?;
|
|
||||||
|
|
||||||
let mut hashtags: Vec<String> = Vec::new();
|
|
||||||
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
|
|
||||||
|
|
||||||
let mut name: Option<String> = None;
|
|
||||||
let mut description: Option<String> = None;
|
|
||||||
let mut web: Vec<Url> = Vec::new();
|
|
||||||
let mut clone: Vec<Url> = Vec::new();
|
|
||||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
|
||||||
let mut euc: Option<String> = None;
|
|
||||||
let mut maintainers: Vec<PublicKey> = Vec::new();
|
|
||||||
let mut upstream: Option<Upstream> = None;
|
|
||||||
|
|
||||||
for tag in event.tags.iter() {
|
|
||||||
match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Name(value)) => name = Some(value),
|
|
||||||
Ok(Nip34Tag::Description(value)) => description = Some(value),
|
|
||||||
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
|
|
||||||
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
|
|
||||||
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
|
|
||||||
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
|
|
||||||
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if upstream.is_none() && tag.kind() == "u" {
|
|
||||||
let values = tag.as_slice();
|
|
||||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
|
||||||
if !raw.is_empty() {
|
|
||||||
upstream = Some(Upstream::parse(raw));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(Self {
|
|
||||||
event_id: event.id,
|
|
||||||
owner: event.pubkey,
|
|
||||||
created_at: event.created_at,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
web,
|
|
||||||
clone,
|
|
||||||
relays,
|
|
||||||
euc,
|
|
||||||
maintainers,
|
|
||||||
upstream,
|
|
||||||
hashtags,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the owner and identifier as a repository address.
|
|
||||||
pub fn addr(&self) -> RepoAddr {
|
|
||||||
RepoAddr::new(self.owner, self.id.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the name, defaulting to "Untitled".
|
|
||||||
pub fn name(&self) -> String {
|
|
||||||
self.name.clone().unwrap_or("Untitled".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matches a `u` tag pointing at the base or a shared earliest unique commit.
|
|
||||||
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
|
||||||
if self.addr() == *base {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the description, defaulting to "No description".
|
|
||||||
pub fn description(&self) -> String {
|
|
||||||
self.description
|
|
||||||
.clone()
|
|
||||||
.unwrap_or("No description".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork.
|
|
||||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
|
||||||
let mut maintainers = self.maintainers.clone();
|
|
||||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
|
||||||
maintainers.push(self.owner);
|
|
||||||
}
|
|
||||||
maintainers
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns deduplicated `git clone` commands for the advertised URLs.
|
|
||||||
pub fn clone_urls(&self) -> Vec<String> {
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
self.clone
|
|
||||||
.iter()
|
|
||||||
.map(|url| format!("git clone {url}"))
|
|
||||||
.filter(|command| seen.insert(command.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
|
|
||||||
|
|
||||||
/// Returns the test signer keys.
|
|
||||||
fn keys() -> Keys {
|
|
||||||
Keys::new(
|
|
||||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
|
||||||
.expect("valid secret key"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a signed repo announcement event from raw tags.
|
|
||||||
fn announcement_event(tags: &[&[&str]]) -> Event {
|
|
||||||
let tags: Vec<Tag> = tags
|
|
||||||
.iter()
|
|
||||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
||||||
.tags(tags)
|
|
||||||
.finalize(&keys())
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses an event carrying every supported tag.
|
|
||||||
#[test]
|
|
||||||
fn parses_full_announcement() {
|
|
||||||
let event = announcement_event(&[
|
|
||||||
&["d", "my-repo"],
|
|
||||||
&["name", "My Repo"],
|
|
||||||
&["description", "A test repository"],
|
|
||||||
&["web", "https://example.com/repo"],
|
|
||||||
&["clone", "https://example.com/repo.git"],
|
|
||||||
&["relays", "wss://relay.example.com"],
|
|
||||||
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
|
||||||
&["maintainers", MAINTAINER_HEX],
|
|
||||||
&["t", "rust"],
|
|
||||||
&["t", "nostr"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
|
||||||
|
|
||||||
assert_eq!(announcement.owner, keys().public_key());
|
|
||||||
assert_eq!(announcement.id, "my-repo");
|
|
||||||
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
|
|
||||||
assert_eq!(
|
|
||||||
announcement.description.as_deref(),
|
|
||||||
Some("A test repository")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
announcement.web,
|
|
||||||
vec![Url::parse("https://example.com/repo").unwrap()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
announcement.clone,
|
|
||||||
vec![Url::parse("https://example.com/repo.git").unwrap()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
announcement.relays,
|
|
||||||
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
announcement.euc.as_deref(),
|
|
||||||
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
announcement.maintainers,
|
|
||||||
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
|
||||||
);
|
|
||||||
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Skips malformed clone, relay, and maintainer values.
|
|
||||||
#[test]
|
|
||||||
fn drops_malformed_values() {
|
|
||||||
let event = announcement_event(&[
|
|
||||||
&["d", "my-repo"],
|
|
||||||
&["clone", "not a url"],
|
|
||||||
&["relays", "wss://good.example.com"],
|
|
||||||
&["maintainers", "not-a-pubkey"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
|
||||||
|
|
||||||
assert!(announcement.clone.is_empty());
|
|
||||||
assert_eq!(
|
|
||||||
announcement.relays,
|
|
||||||
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
|
|
||||||
);
|
|
||||||
assert!(announcement.maintainers.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the `u` tag into an upstream coordinate.
|
|
||||||
#[test]
|
|
||||||
fn parses_upstream_tag() {
|
|
||||||
let event = announcement_event(&[
|
|
||||||
&["d", "my-fork"],
|
|
||||||
&[
|
|
||||||
"u",
|
|
||||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
|
|
||||||
"wss://relay.example.com",
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
|
||||||
let upstream = announcement.upstream.expect("parses the u tag");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
upstream.addr,
|
|
||||||
Some(RepoAddr::new(
|
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
|
||||||
"upstream"
|
|
||||||
))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
upstream.raw,
|
|
||||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
upstream.display().to_string(),
|
|
||||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matches a fork through its `u` tag coordinate.
|
|
||||||
#[test]
|
|
||||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
|
||||||
let base = RepoAddr::new(
|
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
|
||||||
"upstream",
|
|
||||||
);
|
|
||||||
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
|
||||||
let fork = Announcement::from_event(&event).expect("parses");
|
|
||||||
|
|
||||||
assert!(fork.is_fork_of(&base, None));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matches forks sharing the base's earliest unique commit.
|
|
||||||
#[test]
|
|
||||||
fn is_fork_of_matches_a_shared_euc() {
|
|
||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
|
||||||
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
|
||||||
let base = Announcement::from_event(&base_event).expect("parses");
|
|
||||||
let base_addr = base.addr();
|
|
||||||
|
|
||||||
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
|
||||||
let fork = Announcement::from_event(&fork_event).expect("parses");
|
|
||||||
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
|
||||||
|
|
||||||
let other_event = announcement_event(&[
|
|
||||||
&["d", "other"],
|
|
||||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
|
||||||
]);
|
|
||||||
let other = Announcement::from_event(&other_event).expect("parses");
|
|
||||||
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
|
||||||
|
|
||||||
assert!(!fork.is_fork_of(&base_addr, None));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matches permanent forks whose `u` tag points at the base despite a diverged EUC.
|
|
||||||
#[test]
|
|
||||||
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
|
||||||
let base = RepoAddr::new(
|
|
||||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
|
||||||
"upstream",
|
|
||||||
);
|
|
||||||
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
|
||||||
let event = announcement_event(&[
|
|
||||||
&["d", "my-fork"],
|
|
||||||
&["u", &base.to_string()],
|
|
||||||
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
|
||||||
]);
|
|
||||||
let fork = Announcement::from_event(&event).expect("parses");
|
|
||||||
|
|
||||||
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Includes the owner among maintainers for primary repositories.
|
|
||||||
#[test]
|
|
||||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
|
||||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
|
||||||
let maintainers = announcement.effective_maintainers();
|
|
||||||
|
|
||||||
assert_eq!(maintainers.len(), 2);
|
|
||||||
assert!(maintainers.contains(&announcement.owner));
|
|
||||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Excludes the owner from maintainers for subordinate forks.
|
|
||||||
#[test]
|
|
||||||
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
|
|
||||||
let event = announcement_event(&[
|
|
||||||
&["d", "my-fork"],
|
|
||||||
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
|
|
||||||
&["maintainers", MAINTAINER_HEX],
|
|
||||||
]);
|
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event).expect("parses");
|
|
||||||
let maintainers = announcement.effective_maintainers();
|
|
||||||
|
|
||||||
assert!(!maintainers.contains(&announcement.owner));
|
|
||||||
assert_eq!(
|
|
||||||
maintainers,
|
|
||||||
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const OWNER_KEYS: [&str; 3] = [
|
|
||||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
|
||||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
|
||||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Builds an announcement event signed by the given owner key.
|
|
||||||
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
|
|
||||||
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
|
|
||||||
let tags: Vec<Tag> = tags
|
|
||||||
.iter()
|
|
||||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
|
||||||
.collect();
|
|
||||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
||||||
.tags(tags)
|
|
||||||
.finalize(&keys)
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds one announcement owned by the owner key at the given index.
|
|
||||||
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
|
|
||||||
vec![
|
|
||||||
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
|
|
||||||
.expect("parses"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Places the user's own forks before everyone else's.
|
|
||||||
#[test]
|
|
||||||
fn fork_candidates_orders_own_forks_first() {
|
|
||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
|
||||||
let clone = "https://grasp.example/npub1x/my-fork.git";
|
|
||||||
|
|
||||||
let base_addr = RepoAddr::new(
|
|
||||||
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
|
|
||||||
"upstream",
|
|
||||||
);
|
|
||||||
let all = vec![
|
|
||||||
owned_announcements(
|
|
||||||
2,
|
|
||||||
&[
|
|
||||||
&["d", "other-project"],
|
|
||||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
owned_announcements(
|
|
||||||
1,
|
|
||||||
&[&["d", "my-fork"], &["r", euc, "euc"], &["clone", clone]],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
owned_announcements(
|
|
||||||
2,
|
|
||||||
&[
|
|
||||||
&["d", "their-fork"],
|
|
||||||
&["u", &base_addr.to_string()],
|
|
||||||
&["clone", clone],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey");
|
|
||||||
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user));
|
|
||||||
|
|
||||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
|
||||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Excludes the base itself, unrelated repos, and repos without clone URLs.
|
|
||||||
#[test]
|
|
||||||
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
|
||||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
|
||||||
let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey");
|
|
||||||
let base_addr = RepoAddr::new(base_owner, "upstream");
|
|
||||||
|
|
||||||
let mut all = vec![
|
|
||||||
owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]])
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
owned_announcements(1, &[&["d", "no-clone-fork"], &["r", euc, "euc"]])
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
owned_announcements(
|
|
||||||
2,
|
|
||||||
&[
|
|
||||||
&["d", "other"],
|
|
||||||
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
owned_announcements(
|
|
||||||
2,
|
|
||||||
&[
|
|
||||||
&["d", "mirror"],
|
|
||||||
&["r", euc, "euc"],
|
|
||||||
&["clone", "https://grasp.example/x/mirror.git"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner));
|
|
||||||
assert_eq!(forks.len(), 1);
|
|
||||||
assert_eq!(forks[0].id, "mirror");
|
|
||||||
|
|
||||||
all.push(
|
|
||||||
owned_announcements(
|
|
||||||
2,
|
|
||||||
&[
|
|
||||||
&["d", "u-fork"],
|
|
||||||
&["u", &base_addr.to_string()],
|
|
||||||
&["clone", "https://grasp.example/x/u-fork.git"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.pop()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let forks = Announcement::forks_in(&all, &base_addr, None, Some(base_owner));
|
|
||||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
|
||||||
assert_eq!(ids, vec!["u-fork"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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,18 +2,26 @@ use std::collections::HashSet;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
||||||
|
/// built from the kind-5 and kind-62 events in the local database.
|
||||||
|
///
|
||||||
|
/// Deleted events are hidden before they reach the UI.
|
||||||
|
///
|
||||||
|
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
||||||
pub struct Deletions {
|
pub struct Deletions {
|
||||||
|
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
||||||
ids: HashSet<(EventId, PublicKey)>,
|
ids: HashSet<(EventId, PublicKey)>,
|
||||||
|
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
||||||
|
///
|
||||||
|
/// All versions of the addressable event up to `cutoff` are deleted.
|
||||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||||
|
/// `(author, cutoff)` from kind-62 vanish requests.
|
||||||
vanished: Vec<(PublicKey, Timestamp)>,
|
vanished: Vec<(PublicKey, Timestamp)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deletions {
|
impl Deletions {
|
||||||
/// Builds a [`Deletions`] instance from a collection of events.
|
/// Build the deletion index from raw kind-5 and kind-62 events.
|
||||||
pub fn from_events<E>(events: E) -> Self
|
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
|
||||||
where
|
|
||||||
E: IntoIterator<Item = Event>,
|
|
||||||
{
|
|
||||||
let mut ids = HashSet::new();
|
let mut ids = HashSet::new();
|
||||||
let mut coords = Vec::new();
|
let mut coords = Vec::new();
|
||||||
let mut vanished = Vec::new();
|
let mut vanished = Vec::new();
|
||||||
@@ -28,6 +36,8 @@ impl Deletions {
|
|||||||
.map(|c| (c, event.pubkey, event.created_at)),
|
.map(|c| (c, event.pubkey, event.created_at)),
|
||||||
);
|
);
|
||||||
} else if event.kind == Kind::RequestToVanish {
|
} else if event.kind == Kind::RequestToVanish {
|
||||||
|
// Client-side we can't verify which relay the request targeted.
|
||||||
|
// Any vanish request is then honored for the author's events.
|
||||||
vanished.push((event.pubkey, event.created_at));
|
vanished.push((event.pubkey, event.created_at));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +49,10 @@ impl Deletions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns whether the given event is deleted.
|
/// Whether the event is covered by a valid deletion or vanish request.
|
||||||
|
/// A request is valid when its author matches the deleted event's author, per NIP-09.
|
||||||
|
///
|
||||||
|
/// Addressable events are deleted up to the request's `created_at`.
|
||||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||||
if self
|
if self
|
||||||
.vanished
|
.vanished
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
use crate::RepoAddr;
|
||||||
|
|
||||||
|
/// Kinds that make up the activity of a repository.
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
Kind::Comment,
|
Kind::Comment,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -14,46 +17,34 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
|||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
const NOTIFICATION_KINDS: [Kind; 8] = [
|
/// Latest announcement event for a repository.
|
||||||
Kind::GitIssue,
|
pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||||
Kind::GitPullRequest,
|
Filter::new()
|
||||||
Kind::GitPatch,
|
.kind(Kind::GitRepoAnnouncement)
|
||||||
Kind::GitPullRequestUpdate,
|
.author(addr.public_key)
|
||||||
Kind::GitStatusOpen,
|
.identifier(addr.identifier.clone())
|
||||||
Kind::GitStatusApplied,
|
|
||||||
Kind::GitStatusClosed,
|
|
||||||
Kind::GitStatusDraft,
|
|
||||||
];
|
|
||||||
|
|
||||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
|
||||||
Kind::GitIssue,
|
|
||||||
Kind::GitPatch,
|
|
||||||
Kind::GitPullRequest,
|
|
||||||
Kind::GitRepoAnnouncement,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn is_repo_kind(kind: Kind) -> bool {
|
|
||||||
kind == Kind::GitRepoAnnouncement
|
|
||||||
|| kind == Kind::RepoState
|
|
||||||
|| kind == Kind::EventDeletion
|
|
||||||
|| kind == Kind::RequestToVanish
|
|
||||||
|| ACTIVITY_KINDS.contains(&kind)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
/// Latest state event for a repository, carrying refs and HEAD.
|
||||||
event
|
pub fn state(addr: &RepoAddr) -> Filter {
|
||||||
.tags
|
Filter::new()
|
||||||
.iter()
|
.kind(Kind::RepoState)
|
||||||
.find(|tag| tag.kind() == name)
|
.author(addr.public_key)
|
||||||
.and_then(|tag| tag.content())
|
.identifier(addr.identifier.clone())
|
||||||
.and_then(|value| value.parse::<Kind>().ok())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Filters;
|
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
||||||
|
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
||||||
impl Filters {
|
/// The `a` tag is optional on status events per NIP-34.
|
||||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
/// Statuses published without it are not matched here.
|
||||||
|
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||||
|
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||||
|
/// They are matched via the `#e` tag. One filter covers all roots.
|
||||||
|
///
|
||||||
|
/// A negentropy sync reconciles them in a single session, not one per root.
|
||||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([
|
.kinds([
|
||||||
@@ -65,54 +56,34 @@ impl Filters {
|
|||||||
.events(roots)
|
.events(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover notes and NIP-32 label events referencing any of the given root events.
|
||||||
|
/// These are kinds 1624 and 1985, matched via the `#e` tag.
|
||||||
|
///
|
||||||
|
/// Because they carry no repository `a` tag, they are fetched by root like comments.
|
||||||
|
///
|
||||||
|
/// Batched, like [`statuses_for`].
|
||||||
|
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
||||||
|
.events(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user's grasp list, kind `10317`.
|
||||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::GitUserGraspList)
|
.kind(Kind::GitUserGraspList)
|
||||||
.author(public_key)
|
.author(public_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NIP-51: code (people who produce NIP-34 events) follow list
|
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||||
pub fn git_authors(public_key: PublicKey) -> Filter {
|
/// The roots are issues, patches and PRs.
|
||||||
Filter::new().kind(Kind::Custom(10017)).author(public_key)
|
///
|
||||||
}
|
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||||
|
|
||||||
/// NIP-51: NIP-34 followed repositories
|
|
||||||
pub fn git_repos(public_key: PublicKey) -> Filter {
|
|
||||||
Filter::new().kind(Kind::Custom(10018)).author(public_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replaceable events, so the latest of each kind is all we need.
|
|
||||||
pub fn user_metadata(public_key: PublicKey) -> Vec<Filter> {
|
|
||||||
vec![
|
|
||||||
Self::grasp_list(public_key).limit(1),
|
|
||||||
Self::git_authors(public_key).limit(1),
|
|
||||||
Self::git_repos(public_key).limit(1),
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::ContactList)
|
|
||||||
.author(public_key)
|
|
||||||
.limit(1),
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::Metadata)
|
|
||||||
.author(public_key)
|
|
||||||
.limit(1),
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::MuteList)
|
|
||||||
.author(public_key)
|
|
||||||
.limit(1),
|
|
||||||
Filter::new()
|
|
||||||
.kind(Kind::BlossomServerList)
|
|
||||||
.author(public_key)
|
|
||||||
.limit(1),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||||
|
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![
|
vec![
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::Comment)
|
.kind(Kind::Comment)
|
||||||
@@ -123,106 +94,50 @@ impl Filters {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn notification_comments(me: PublicKey) -> Filter {
|
/// All repositories announced by an author.
|
||||||
|
pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kind(Kind::Comment)
|
.kind(Kind::GitRepoAnnouncement)
|
||||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
.author(public_key)
|
||||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
|
||||||
vec![
|
|
||||||
Self::notification_comments(me),
|
|
||||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
|
||||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All repository announcements, for global discovery.
|
||||||
pub fn all_announcements() -> Filter {
|
pub fn all_announcements() -> Filter {
|
||||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all_states() -> Filter {
|
/// How far back deletion requests are fetched and stored.
|
||||||
Filter::new().kind(Kind::RepoState)
|
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||||
}
|
|
||||||
|
|
||||||
|
/// `now` minus [`DELETIONS_LOOKBACK`].
|
||||||
|
/// Quantized to whole days so identical filters hash the same.
|
||||||
|
///
|
||||||
|
/// This lets the backend's sync dedup match identical filters.
|
||||||
fn deletions_since() -> Timestamp {
|
fn deletions_since() -> Timestamp {
|
||||||
let now = Timestamp::now().as_secs();
|
let now = Timestamp::now().as_secs();
|
||||||
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
|
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
||||||
|
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||||
|
///
|
||||||
|
/// Deletion requests must be known before any other event is shown.
|
||||||
pub fn deletions() -> Filter {
|
pub fn deletions() -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
.since(Self::deletions_since())
|
.since(deletions_since())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_git_comment(event: &Event) -> bool {
|
/// Deletion events relevant to a single repository.
|
||||||
event.kind == Kind::Comment
|
///
|
||||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
/// Requests authored by the repository owner.
|
||||||
}
|
///
|
||||||
|
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||||
pub(crate) fn is_git_status(event: &Event) -> bool {
|
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
vec![
|
||||||
}
|
Filter::new()
|
||||||
|
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||||
#[cfg(test)]
|
.author(addr.public_key),
|
||||||
mod tests {
|
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
||||||
use super::*;
|
]
|
||||||
use crate::GitEvent;
|
|
||||||
|
|
||||||
fn keys(seed: u8) -> Keys {
|
|
||||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
|
||||||
hex.push_str(&format!("{seed:02x}"));
|
|
||||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>) -> Event {
|
|
||||||
EventBuilder::new(kind, "")
|
|
||||||
.tags(tags)
|
|
||||||
.finalize(author)
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kind_tag(name: &str, kind: Kind) -> Tag {
|
|
||||||
Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn comment_activity_depends_on_the_uppercase_k_tag() {
|
|
||||||
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
|
|
||||||
let on_repo = signed(
|
|
||||||
&keys(1),
|
|
||||||
Kind::Comment,
|
|
||||||
vec![kind_tag("K", Kind::GitRepoAnnouncement)],
|
|
||||||
);
|
|
||||||
let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]);
|
|
||||||
|
|
||||||
assert!(on_git.is_git_activity());
|
|
||||||
assert!(on_repo.is_git_activity());
|
|
||||||
assert!(!on_note.is_git_activity());
|
|
||||||
assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_activity_depends_on_the_lowercase_k_tag() {
|
|
||||||
let status = signed(
|
|
||||||
&keys(1),
|
|
||||||
Kind::GitStatusClosed,
|
|
||||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
|
||||||
);
|
|
||||||
let unrelated = signed(
|
|
||||||
&keys(1),
|
|
||||||
Kind::GitStatusClosed,
|
|
||||||
vec![kind_tag("k", Kind::Metadata)],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(status.is_git_activity());
|
|
||||||
assert!(!unrelated.is_git_activity());
|
|
||||||
assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
pub trait GitEvent {
|
|
||||||
/// Returns the NIP-34 subject or the first non-empty content line.
|
|
||||||
fn activity_subject(&self) -> String;
|
|
||||||
|
|
||||||
/// Returns the current commit id.
|
|
||||||
fn current_commit(&self) -> Option<String>;
|
|
||||||
|
|
||||||
/// Returns the merge base commit id.
|
|
||||||
fn merge_base(&self) -> Option<String>;
|
|
||||||
|
|
||||||
/// Returns the advertised clone URLs.
|
|
||||||
fn clone_urls(&self) -> Option<Vec<Url>>;
|
|
||||||
|
|
||||||
/// Returns the branch name.
|
|
||||||
fn branch_name(&self) -> Option<String>;
|
|
||||||
|
|
||||||
/// Returns whether the event is git activity.
|
|
||||||
fn is_git_activity(&self) -> bool;
|
|
||||||
|
|
||||||
/// Returns whether an `e` or `E` tag points at the root event.
|
|
||||||
fn references_root(&self, root: &EventId) -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitEvent for Event {
|
|
||||||
/// Reads the subject tag, then the first content line, then "Untitled".
|
|
||||||
fn activity_subject(&self) -> String {
|
|
||||||
let subject = self
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
|
|
||||||
subject
|
|
||||||
.or_else(|| {
|
|
||||||
self.content
|
|
||||||
.lines()
|
|
||||||
.map(str::trim)
|
|
||||||
.find(|line| !line.is_empty())
|
|
||||||
.map(|value| value.to_string())
|
|
||||||
})
|
|
||||||
.unwrap_or("Untitled".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the `commit` tag.
|
|
||||||
fn current_commit(&self) -> Option<String> {
|
|
||||||
self.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the `merge-base` tag.
|
|
||||||
fn merge_base(&self) -> Option<String> {
|
|
||||||
self.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the `clone` tag.
|
|
||||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
|
||||||
self.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the `branch-name` tag.
|
|
||||||
fn branch_name(&self) -> Option<String> {
|
|
||||||
self.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Classifies git issue, patch, pull request, comment, and status kinds.
|
|
||||||
fn is_git_activity(&self) -> bool {
|
|
||||||
match self.kind {
|
|
||||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
|
||||||
Kind::Comment => crate::filters::is_git_comment(self),
|
|
||||||
Kind::GitStatusOpen
|
|
||||||
| Kind::GitStatusApplied
|
|
||||||
| Kind::GitStatusClosed
|
|
||||||
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compares `e` and `E` tag contents against the root id.
|
|
||||||
fn references_root(&self, root: &EventId) -> bool {
|
|
||||||
let root = root.to_hex();
|
|
||||||
self.tags
|
|
||||||
.iter()
|
|
||||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
|
||||||
/// Delegates to the referenced event.
|
|
||||||
fn activity_subject(&self) -> String {
|
|
||||||
(*self).activity_subject()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates to the referenced event.
|
|
||||||
fn current_commit(&self) -> Option<String> {
|
|
||||||
(*self).current_commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates to the referenced event.
|
|
||||||
fn merge_base(&self) -> Option<String> {
|
|
||||||
(*self).merge_base()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates to the referenced event.
|
|
||||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
|
||||||
(*self).clone_urls()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates to the referenced event.
|
|
||||||
fn branch_name(&self) -> Option<String> {
|
|
||||||
(*self).branch_name()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates to the referenced event.
|
|
||||||
fn is_git_activity(&self) -> bool {
|
|
||||||
(*self).is_git_activity()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delegates to the referenced event.
|
|
||||||
fn references_root(&self, root: &EventId) -> bool {
|
|
||||||
(*self).references_root(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use nostr::prelude::*;
|
|
||||||
use utils::sort_newest_first;
|
|
||||||
|
|
||||||
use super::{InboxItem, InboxReadState, ThreadResolver};
|
|
||||||
use crate::RepoAddr;
|
|
||||||
|
|
||||||
/// Groups events into inbox threads, merging the user's own events.
|
|
||||||
pub fn group<E, O, L>(
|
|
||||||
events: E,
|
|
||||||
own: O,
|
|
||||||
me: PublicKey,
|
|
||||||
state: &InboxReadState,
|
|
||||||
lookup: &L,
|
|
||||||
) -> Vec<InboxItem>
|
|
||||||
where
|
|
||||||
E: IntoIterator<Item = Event>,
|
|
||||||
O: IntoIterator<Item = Event>,
|
|
||||||
L: Fn(EventId) -> Option<Event>,
|
|
||||||
{
|
|
||||||
let resolver = ThreadResolver::new(lookup);
|
|
||||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
|
||||||
for event in events {
|
|
||||||
if event.pubkey == me {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(root) = resolver.notification_root(&event) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
groups.entry(root).or_default().push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
|
||||||
for event in own {
|
|
||||||
let root = resolver.notification_root(&event).unwrap_or(event.id);
|
|
||||||
own_groups.entry(root).or_default().push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
|
||||||
roots.sort();
|
|
||||||
roots.dedup();
|
|
||||||
|
|
||||||
let mut items: Vec<InboxItem> = roots
|
|
||||||
.into_iter()
|
|
||||||
.map(|root| {
|
|
||||||
let mut events = groups.remove(&root).unwrap_or_default();
|
|
||||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
|
||||||
sort_newest_first(&mut events);
|
|
||||||
sort_newest_first(&mut own_events);
|
|
||||||
|
|
||||||
let root_event = lookup(root);
|
|
||||||
|
|
||||||
let mut item = InboxItem {
|
|
||||||
root,
|
|
||||||
address: root_event
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|event| event.tags.coordinates().next())
|
|
||||||
.map(RepoAddr::from),
|
|
||||||
root_event,
|
|
||||||
events,
|
|
||||||
own_events,
|
|
||||||
unread_ids: Vec::new(),
|
|
||||||
archived: false,
|
|
||||||
};
|
|
||||||
item.apply_state(state);
|
|
||||||
item
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
items.sort_by(|a, b| {
|
|
||||||
b.latest_activity()
|
|
||||||
.cmp(&a.latest_activity())
|
|
||||||
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
|
||||||
});
|
|
||||||
|
|
||||||
items
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Derives deterministic signer keys from a seed byte.
|
|
||||||
fn keys(seed: u8) -> Keys {
|
|
||||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
|
||||||
hex.push_str(&format!("{seed:02x}"));
|
|
||||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a signed event with a fixed timestamp.
|
|
||||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
|
||||||
EventBuilder::new(kind, "")
|
|
||||||
.tags(tags)
|
|
||||||
.custom_created_at(Timestamp::from_secs(created_at))
|
|
||||||
.finalize(author)
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds an uppercase `E` tag for the event.
|
|
||||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
|
||||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds an event lookup closure over a slice.
|
|
||||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
|
||||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a git issue event with a subject tag.
|
|
||||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
|
||||||
signed(
|
|
||||||
author,
|
|
||||||
Kind::GitIssue,
|
|
||||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
|
||||||
at,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merges the user's own events into the matching thread.
|
|
||||||
#[test]
|
|
||||||
fn group_merges_own_events_into_the_matching_thread() {
|
|
||||||
let me = keys(1);
|
|
||||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
|
||||||
let mine = signed(
|
|
||||||
&me,
|
|
||||||
Kind::Comment,
|
|
||||||
vec![
|
|
||||||
uppercase_e_tag(&issue),
|
|
||||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
|
||||||
],
|
|
||||||
150,
|
|
||||||
);
|
|
||||||
let reply = signed(
|
|
||||||
&keys(2),
|
|
||||||
Kind::Comment,
|
|
||||||
vec![
|
|
||||||
uppercase_e_tag(&issue),
|
|
||||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
|
||||||
],
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
|
|
||||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
|
||||||
let items = group(
|
|
||||||
[reply.clone()],
|
|
||||||
[issue.clone(), mine.clone()],
|
|
||||||
me.public_key(),
|
|
||||||
&InboxReadState::default(),
|
|
||||||
&lookup(&context),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(items.len(), 1);
|
|
||||||
assert_eq!(items[0].root, issue.id);
|
|
||||||
assert_eq!(
|
|
||||||
items[0].root_event.as_ref().map(|event| event.id),
|
|
||||||
Some(issue.id)
|
|
||||||
);
|
|
||||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
|
||||||
assert_eq!(items[0].title(), "Add retry logic");
|
|
||||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
|
||||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
|
||||||
assert_eq!(
|
|
||||||
items[0]
|
|
||||||
.timeline(5)
|
|
||||||
.iter()
|
|
||||||
.map(|event| event.id)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![issue.id, mine.id, reply.id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
use super::InboxReadState;
|
|
||||||
use crate::{GitEvent, RepoAddr};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct InboxItem {
|
|
||||||
pub root: EventId,
|
|
||||||
pub root_event: Option<Event>,
|
|
||||||
pub address: Option<RepoAddr>,
|
|
||||||
pub events: Vec<Event>,
|
|
||||||
pub own_events: Vec<Event>,
|
|
||||||
pub unread_ids: Vec<EventId>,
|
|
||||||
pub archived: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InboxItem {
|
|
||||||
/// Returns the thread title from the root, own events, or events.
|
|
||||||
pub fn title(&self) -> String {
|
|
||||||
self.root_event
|
|
||||||
.as_ref()
|
|
||||||
.or_else(|| self.own_events.first())
|
|
||||||
.or_else(|| self.events.first())
|
|
||||||
.map(|event| event.activity_subject())
|
|
||||||
.unwrap_or_else(|| "Untitled".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the kind of the thread's primary event.
|
|
||||||
pub fn kind(&self) -> Option<Kind> {
|
|
||||||
self.root_event
|
|
||||||
.as_ref()
|
|
||||||
.or_else(|| self.own_events.first())
|
|
||||||
.or_else(|| self.events.first())
|
|
||||||
.map(|event| event.kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the newest timestamp among the root, own, and other events.
|
|
||||||
pub fn latest_activity(&self) -> Timestamp {
|
|
||||||
self.root_event
|
|
||||||
.as_ref()
|
|
||||||
.into_iter()
|
|
||||||
.chain(self.own_events.first())
|
|
||||||
.chain(self.events.first())
|
|
||||||
.map(|event| event.created_at)
|
|
||||||
.max()
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a chronological timeline capped at `limit` events, root first.
|
|
||||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
|
||||||
let mut seen: HashSet<EventId> = HashSet::new();
|
|
||||||
let mut events: Vec<Event> = Vec::new();
|
|
||||||
|
|
||||||
if let Some(root) = &self.root_event {
|
|
||||||
seen.insert(root.id);
|
|
||||||
events.push(root.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rest: Vec<Event> = self
|
|
||||||
.own_events
|
|
||||||
.iter()
|
|
||||||
.chain(self.events.iter())
|
|
||||||
.filter(|event| seen.insert(event.id))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
rest.sort_by(|a, b| {
|
|
||||||
b.created_at
|
|
||||||
.cmp(&a.created_at)
|
|
||||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
|
||||||
});
|
|
||||||
rest.truncate(limit.saturating_sub(events.len()));
|
|
||||||
events.extend(rest);
|
|
||||||
|
|
||||||
events.sort_by_key(|event| event.created_at);
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether the thread has unread events and is not archived.
|
|
||||||
pub fn is_unread(&self) -> bool {
|
|
||||||
!self.archived && !self.unread_ids.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recomputes unread ids and the archived flag from the read state.
|
|
||||||
pub fn apply_state(&mut self, state: &InboxReadState) {
|
|
||||||
self.unread_ids = self
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.filter(|event| !state.is_read(event))
|
|
||||||
.map(|event| event.id)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
self.archived =
|
|
||||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
mod group;
|
|
||||||
mod item;
|
|
||||||
mod read_state;
|
|
||||||
mod resolver;
|
|
||||||
|
|
||||||
pub use group::group;
|
|
||||||
pub use item::InboxItem;
|
|
||||||
pub use read_state::InboxReadState;
|
|
||||||
pub use resolver::ThreadResolver;
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use nostr::prelude::*;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
|
||||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct InboxReadState {
|
|
||||||
#[serde(default)]
|
|
||||||
pub read_before: Timestamp,
|
|
||||||
#[serde(default)]
|
|
||||||
pub read_ids: HashSet<EventId>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub archived_before: Timestamp,
|
|
||||||
#[serde(default)]
|
|
||||||
pub archived_ids: HashSet<EventId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InboxReadState {
|
|
||||||
/// Returns whether the event falls under the read high-water mark or id set.
|
|
||||||
pub fn is_read(&self, event: &Event) -> bool {
|
|
||||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns whether the event falls under the archived high-water mark or id set.
|
|
||||||
pub fn is_archived(&self, event: &Event) -> bool {
|
|
||||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records the event as read when it is past the current cutoff.
|
|
||||||
pub fn mark_read(&mut self, event: &Event) {
|
|
||||||
if event.created_at > self.read_before {
|
|
||||||
self.read_ids.insert(event.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records the event as archived when it is past the current cutoff.
|
|
||||||
pub fn mark_archived(&mut self, event: &Event) {
|
|
||||||
if event.created_at > self.archived_before {
|
|
||||||
self.archived_ids.insert(event.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks all events from other authors within the window as read.
|
|
||||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
|
||||||
let cutoff = now - MARK_ALL_WINDOW;
|
|
||||||
self.read_before = cutoff;
|
|
||||||
self.read_ids = all
|
|
||||||
.iter()
|
|
||||||
.filter(|event| event.pubkey != me && event.created_at > cutoff)
|
|
||||||
.map(|event| event.id)
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advances the read cutoff as far as possible, then prunes the id set.
|
|
||||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
|
||||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
|
||||||
self.read_before = cutoff;
|
|
||||||
prune_ids(&mut self.read_ids, all, cutoff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advances the archived cutoff as far as possible, then prunes the id set.
|
|
||||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
|
||||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
|
||||||
self.is_archived(event)
|
|
||||||
});
|
|
||||||
self.archived_before = cutoff;
|
|
||||||
prune_ids(&mut self.archived_ids, all, cutoff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Computes the newest cutoff that leaves unmarked events uncovered.
|
|
||||||
fn advance_cutoff<M>(
|
|
||||||
all: &[Event],
|
|
||||||
me: PublicKey,
|
|
||||||
now: Timestamp,
|
|
||||||
current: Timestamp,
|
|
||||||
is_marked: M,
|
|
||||||
) -> Timestamp
|
|
||||||
where
|
|
||||||
M: Fn(&Event) -> bool,
|
|
||||||
{
|
|
||||||
let fallback = now - ADVANCE_WINDOW;
|
|
||||||
|
|
||||||
let oldest = all
|
|
||||||
.iter()
|
|
||||||
.filter(|event| event.pubkey != me && !is_marked(event))
|
|
||||||
.map(|event| event.created_at)
|
|
||||||
.min();
|
|
||||||
|
|
||||||
let candidate = match oldest {
|
|
||||||
Some(at) if at < fallback => at - 1,
|
|
||||||
_ => fallback,
|
|
||||||
};
|
|
||||||
|
|
||||||
candidate.max(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drops ids whose events predate the cutoff.
|
|
||||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
|
||||||
let created_at: HashMap<EventId, Timestamp> = all
|
|
||||||
.iter()
|
|
||||||
.map(|event| (event.id, event.created_at))
|
|
||||||
.collect();
|
|
||||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
pub struct ThreadResolver<'a, L: ?Sized> {
|
|
||||||
lookup: &'a L,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, L> ThreadResolver<'a, L>
|
|
||||||
where
|
|
||||||
L: Fn(EventId) -> Option<Event> + ?Sized,
|
|
||||||
{
|
|
||||||
/// Creates a resolver over the given event lookup.
|
|
||||||
pub fn new(lookup: &'a L) -> Self {
|
|
||||||
Self { lookup }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps a git event kind to the thread root it notifies about.
|
|
||||||
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
|
||||||
match event.kind {
|
|
||||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
|
||||||
Kind::GitPatch => Some(match self.first_e_id(event) {
|
|
||||||
Some(parent) => self.resolve_thread_root(parent),
|
|
||||||
None => event.id,
|
|
||||||
}),
|
|
||||||
Kind::Comment => match nip22::extract_root(event) {
|
|
||||||
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
Kind::GitPullRequestUpdate => self
|
|
||||||
.first_uppercase_e_id(event)
|
|
||||||
.map(|root| self.resolve_thread_root(root)),
|
|
||||||
Kind::GitStatusOpen
|
|
||||||
| Kind::GitStatusApplied
|
|
||||||
| Kind::GitStatusClosed
|
|
||||||
| Kind::GitStatusDraft => self
|
|
||||||
.nip10_root_id(event)
|
|
||||||
.map(|root| self.resolve_thread_root(root)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Follows parent pointers until a git issue or pull request root is reached.
|
|
||||||
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
let mut root = id;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if !seen.insert(root) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(event) = (self.lookup)(root) else {
|
|
||||||
return root;
|
|
||||||
};
|
|
||||||
|
|
||||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.parent_id(&event) {
|
|
||||||
Some(parent) => root = parent,
|
|
||||||
None => return root,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mirrors gitworkshop's `getParentId` parent resolution.
|
|
||||||
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
|
||||||
for marker in ["reply", "root"] {
|
|
||||||
if let Some(id) = event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| self.e_tag_with_marker(tag, marker))
|
|
||||||
{
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
|
||||||
if tag.kind() != "e" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let slice = tag.as_slice();
|
|
||||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
|
||||||
|
|
||||||
if is_mention {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
}) {
|
|
||||||
return Some(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.first_uppercase_e_id(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the NIP-10 `root` marker, falling back to the first `e` tag.
|
|
||||||
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
|
||||||
.or_else(|| self.first_e_id(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the first lowercase `e` tag id.
|
|
||||||
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
|
||||||
self.first_tag_id(event, "e")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the first uppercase `E` tag id.
|
|
||||||
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
|
||||||
self.first_tag_id(event, "E")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the first event id from tags of the given kind.
|
|
||||||
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
|
|
||||||
event.tags.iter().find_map(|tag| {
|
|
||||||
if tag.kind() != name {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a four-value `e` tag carrying the given marker.
|
|
||||||
fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option<EventId> {
|
|
||||||
let slice = tag.as_slice();
|
|
||||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Derives deterministic signer keys from a seed byte.
|
|
||||||
fn keys(seed: u8) -> Keys {
|
|
||||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
|
||||||
hex.push_str(&format!("{seed:02x}"));
|
|
||||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a signed event with a fixed timestamp.
|
|
||||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
|
||||||
EventBuilder::new(kind, "")
|
|
||||||
.tags(tags)
|
|
||||||
.custom_created_at(Timestamp::from_secs(created_at))
|
|
||||||
.finalize(author)
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a plain lowercase `e` tag for the event.
|
|
||||||
fn e_tag(event: &Event) -> Tag {
|
|
||||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a four-value `e` tag with the given marker.
|
|
||||||
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
|
|
||||||
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
|
|
||||||
.expect("valid e tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds an uppercase `E` tag for the event.
|
|
||||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
|
||||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds an event lookup closure over a slice.
|
|
||||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
|
||||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a git issue event.
|
|
||||||
fn issue(author: &Keys, at: u64) -> Event {
|
|
||||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves a comment to its uppercase `E` root.
|
|
||||||
#[test]
|
|
||||||
fn comment_resolves_to_its_uppercase_root() {
|
|
||||||
let issue = issue(&keys(1), 100);
|
|
||||||
let comment = signed(
|
|
||||||
&keys(2),
|
|
||||||
Kind::Comment,
|
|
||||||
vec![
|
|
||||||
uppercase_e_tag(&issue),
|
|
||||||
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
|
||||||
],
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
let events = [issue.clone(), comment.clone()];
|
|
||||||
assert_eq!(
|
|
||||||
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
|
||||||
Some(issue.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves a child patch to its root patch.
|
|
||||||
#[test]
|
|
||||||
fn child_patch_resolves_to_the_root_patch() {
|
|
||||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
|
||||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
|
||||||
let events = [root_patch.clone(), child_patch.clone()];
|
|
||||||
assert_eq!(
|
|
||||||
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
|
||||||
Some(root_patch.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves a status through its `root` marker tag.
|
|
||||||
#[test]
|
|
||||||
fn status_resolves_via_the_root_marker() {
|
|
||||||
let issue = issue(&keys(1), 100);
|
|
||||||
let status = signed(
|
|
||||||
&keys(2),
|
|
||||||
Kind::GitStatusClosed,
|
|
||||||
vec![marked_e_tag(&issue, "root")],
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
let events = [issue.clone(), status.clone()];
|
|
||||||
assert_eq!(
|
|
||||||
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
|
||||||
Some(issue.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Follows a nested comment chain to the issue root.
|
|
||||||
#[test]
|
|
||||||
fn nested_comment_chain_follows_to_the_root() {
|
|
||||||
let issue = issue(&keys(1), 100);
|
|
||||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
|
||||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
|
||||||
let events = [issue.clone(), reply, nested.clone()];
|
|
||||||
assert_eq!(
|
|
||||||
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
|
||||||
Some(issue.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
pub mod addr;
|
pub mod addr;
|
||||||
pub mod announcement;
|
pub mod annotations;
|
||||||
|
pub mod clone_url;
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod git_event;
|
pub mod model;
|
||||||
pub mod inbox;
|
pub mod state;
|
||||||
pub mod pull_request;
|
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod upstream;
|
|
||||||
|
|
||||||
pub use addr::RepoAddr;
|
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||||
pub use announcement::Announcement;
|
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||||
|
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use filters::Filters;
|
pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
|
||||||
pub use git_event::GitEvent;
|
pub use state::{build_state, parse_state};
|
||||||
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
|
pub use status::{RepoStatus, references_root, resolve_status};
|
||||||
pub use pull_request::PullRequest;
|
|
||||||
pub use status::RepoStatus;
|
|
||||||
pub use upstream::Upstream;
|
|
||||||
|
|||||||
@@ -0,0 +1,727 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use gpui::SharedString;
|
||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
use crate::RepoAddr;
|
||||||
|
|
||||||
|
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Announcement {
|
||||||
|
/// ID of the announcement event itself.
|
||||||
|
pub event_id: EventId,
|
||||||
|
/// Repository ID, the `d` tag.
|
||||||
|
pub id: String,
|
||||||
|
/// Author of the announcement event.
|
||||||
|
pub owner: PublicKey,
|
||||||
|
/// When the announcement was published, used for latest-wins resolution.
|
||||||
|
pub created_at: Timestamp,
|
||||||
|
pub name: Option<SharedString>,
|
||||||
|
pub description: Option<SharedString>,
|
||||||
|
/// Webpage URLs for browsing.
|
||||||
|
pub web: Vec<Url>,
|
||||||
|
/// URLs for `git clone`.
|
||||||
|
pub clone: Vec<Url>,
|
||||||
|
/// Relays the repository monitors for patches and issues.
|
||||||
|
pub relays: Vec<RelayUrl>,
|
||||||
|
/// Earliest unique commit ID, the `r` tag with `euc` marker.
|
||||||
|
pub euc: Option<String>,
|
||||||
|
/// Other recognized maintainers.
|
||||||
|
pub maintainers: Vec<PublicKey>,
|
||||||
|
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
|
||||||
|
pub upstream: Option<Upstream>,
|
||||||
|
/// Hashtags labelling the repository, the `t` tags.
|
||||||
|
pub hashtags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `u` tag of a fork announcement, per NIP-34.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Upstream {
|
||||||
|
/// Raw first value of the `u` tag, a coordinate or git URL.
|
||||||
|
pub raw: String,
|
||||||
|
/// 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 {
|
||||||
|
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||||
|
let addr = coordinate
|
||||||
|
.parse::<Coordinate>()
|
||||||
|
.ok()
|
||||||
|
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
|
||||||
|
Self {
|
||||||
|
raw: raw.to_owned(),
|
||||||
|
addr,
|
||||||
|
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text for display.
|
||||||
|
pub fn display(&self) -> SharedString {
|
||||||
|
match &self.addr {
|
||||||
|
Some(addr) => SharedString::from(addr.to_string()),
|
||||||
|
None => SharedString::from(self.raw.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subject of a NIP-34 issue or pull request event.
|
||||||
|
/// Taken from the `subject` tag, else the first non-empty line of the content.
|
||||||
|
pub fn activity_subject(event: &Event) -> SharedString {
|
||||||
|
let subject = event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
subject
|
||||||
|
.map(SharedString::from)
|
||||||
|
.or_else(|| {
|
||||||
|
event
|
||||||
|
.content
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.map(SharedString::from)
|
||||||
|
})
|
||||||
|
.unwrap_or(SharedString::from("Untitled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The patch set of a pull request.
|
||||||
|
///
|
||||||
|
/// Returns an empty list when no patch event can be linked to the PR.
|
||||||
|
pub fn pull_request_patches<'a>(
|
||||||
|
pr: &Event,
|
||||||
|
patches: impl IntoIterator<Item = &'a Event>,
|
||||||
|
) -> Vec<&'a Event> {
|
||||||
|
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||||
|
|
||||||
|
// The PR references its root patch via an `e` tag.
|
||||||
|
// Follow the NIP-10 reply chain forward from there.
|
||||||
|
// Each patch replies to the previous one, and among several replies the newest wins.
|
||||||
|
if let Some(root_id) = pr.tags.event_ids().next()
|
||||||
|
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||||
|
{
|
||||||
|
return forward_series(root, &patches);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PR has no `e` tag.
|
||||||
|
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
|
||||||
|
// Walk the reply chain backward to the root.
|
||||||
|
let Some(tip) = current_commit_of(pr) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(last) = patches
|
||||||
|
.iter()
|
||||||
|
.filter(|patch| patch_produces_commit(patch, &tip))
|
||||||
|
.max_by_key(|patch| patch.created_at)
|
||||||
|
.copied()
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut series = vec![last];
|
||||||
|
loop {
|
||||||
|
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(prev) = patches
|
||||||
|
.iter()
|
||||||
|
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||||
|
.copied()
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
series.push(prev);
|
||||||
|
}
|
||||||
|
series.reverse();
|
||||||
|
series
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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());
|
||||||
|
if series.is_empty() {
|
||||||
|
return pr.content.clone();
|
||||||
|
}
|
||||||
|
series
|
||||||
|
.iter()
|
||||||
|
.map(|patch| patch.content.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
||||||
|
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||||
|
let mut series = vec![root];
|
||||||
|
loop {
|
||||||
|
let next = patches
|
||||||
|
.iter()
|
||||||
|
.filter(|patch| !series.contains(patch))
|
||||||
|
.filter(|patch| {
|
||||||
|
patch
|
||||||
|
.tags
|
||||||
|
.event_ids()
|
||||||
|
.any(|id| id == series.last().unwrap().id)
|
||||||
|
})
|
||||||
|
.max_by_key(|patch| patch.created_at);
|
||||||
|
let Some(next) = next else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
series.push(next);
|
||||||
|
}
|
||||||
|
series
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
||||||
|
fn current_commit_of(event: &Event) -> Option<String> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
||||||
|
///
|
||||||
|
/// It lets clients find existing patches for a specific commit.
|
||||||
|
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||||
|
patch
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Announcement {
|
||||||
|
/// Parse a kind `30617` event.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||||
|
pub fn from_event(event: &Event) -> Option<Self> {
|
||||||
|
if event.kind != Kind::GitRepoAnnouncement {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = event.tags.identifier()?;
|
||||||
|
|
||||||
|
let mut hashtags: Vec<String> = Vec::new();
|
||||||
|
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
|
||||||
|
|
||||||
|
let mut name: Option<SharedString> = None;
|
||||||
|
let mut description: Option<SharedString> = None;
|
||||||
|
let mut web: Vec<Url> = Vec::new();
|
||||||
|
let mut clone: Vec<Url> = Vec::new();
|
||||||
|
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||||
|
let mut euc: Option<String> = None;
|
||||||
|
let mut maintainers: Vec<PublicKey> = Vec::new();
|
||||||
|
let mut upstream: Option<Upstream> = None;
|
||||||
|
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Name(value)) => name = Some(value.into()),
|
||||||
|
Ok(Nip34Tag::Description(value)) => description = Some(value.into()),
|
||||||
|
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
|
||||||
|
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
|
||||||
|
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
|
||||||
|
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
|
||||||
|
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
|
||||||
|
// Only the first `u` tag is used.
|
||||||
|
if upstream.is_none() && tag.kind() == "u" {
|
||||||
|
let values = tag.as_slice();
|
||||||
|
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||||
|
if !raw.is_empty() {
|
||||||
|
upstream = Some(Upstream::parse(raw, values.get(2).map(String::as_str)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
event_id: event.id,
|
||||||
|
owner: event.pubkey,
|
||||||
|
created_at: event.created_at,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
web,
|
||||||
|
clone,
|
||||||
|
relays,
|
||||||
|
euc,
|
||||||
|
maintainers,
|
||||||
|
upstream,
|
||||||
|
hashtags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The repository address of this announcement.
|
||||||
|
pub fn addr(&self) -> crate::RepoAddr {
|
||||||
|
crate::repo_addr(self.owner, self.id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this announcement is a fork of the repository at `base`.
|
||||||
|
/// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged.
|
||||||
|
///
|
||||||
|
/// Or it shares `base`'s earliest unique commit and is not the base itself.
|
||||||
|
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||||
|
if self.addr() == *base {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if self.upstream.as_ref().and_then(|u| u.addr.as_ref()) == Some(base) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The description of the repository, or a default if none is provided.
|
||||||
|
pub fn description(&self) -> SharedString {
|
||||||
|
self.description
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(SharedString::from("No description"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective maintainers of this repository,
|
||||||
|
/// the announced `maintainers` plus the announcement author.
|
||||||
|
///
|
||||||
|
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
|
||||||
|
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||||
|
let mut maintainers = self.maintainers.clone();
|
||||||
|
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||||
|
maintainers.push(self.owner);
|
||||||
|
}
|
||||||
|
maintainers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `git clone` URLs for this repository, deduplicated.
|
||||||
|
pub fn clone_urls(&self) -> Vec<SharedString> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
self.clone
|
||||||
|
.iter()
|
||||||
|
.map(|url| SharedString::from(format!("git clone {url}")))
|
||||||
|
.filter(|command| seen.insert(command.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
|
||||||
|
|
||||||
|
fn keys() -> Keys {
|
||||||
|
Keys::new(
|
||||||
|
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||||
|
.expect("valid secret key"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a signed kind `30617` event from raw tag values.
|
||||||
|
fn announcement_event(tags: &[&[&str]]) -> Event {
|
||||||
|
let tags: Vec<Tag> = tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||||
|
.tags(tags)
|
||||||
|
.finalize(&keys())
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_full_announcement() {
|
||||||
|
let event = announcement_event(&[
|
||||||
|
&["d", "my-repo"],
|
||||||
|
&["name", "My Repo"],
|
||||||
|
&["description", "A test repository"],
|
||||||
|
&["web", "https://example.com/repo"],
|
||||||
|
&["clone", "https://example.com/repo.git"],
|
||||||
|
&["relays", "wss://relay.example.com"],
|
||||||
|
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||||
|
&["maintainers", MAINTAINER_HEX],
|
||||||
|
&["t", "rust"],
|
||||||
|
&["t", "nostr"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
assert_eq!(announcement.owner, keys().public_key());
|
||||||
|
assert_eq!(announcement.id, "my-repo");
|
||||||
|
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
|
||||||
|
assert_eq!(
|
||||||
|
announcement.description.as_deref(),
|
||||||
|
Some("A test repository")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
announcement.web,
|
||||||
|
vec![Url::parse("https://example.com/repo").unwrap()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
announcement.clone,
|
||||||
|
vec![Url::parse("https://example.com/repo.git").unwrap()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
announcement.relays,
|
||||||
|
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
announcement.euc.as_deref(),
|
||||||
|
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
announcement.maintainers,
|
||||||
|
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
||||||
|
);
|
||||||
|
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(&[
|
||||||
|
&["d", "my-repo"],
|
||||||
|
&["clone", "not a url"],
|
||||||
|
&["relays", "wss://good.example.com"],
|
||||||
|
&["maintainers", "not-a-pubkey"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
// An invalid URL keeps the whole clone tag from being parsed.
|
||||||
|
assert!(announcement.clone.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
announcement.relays,
|
||||||
|
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
|
||||||
|
);
|
||||||
|
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(&[
|
||||||
|
&["d", "my-fork"],
|
||||||
|
&[
|
||||||
|
"u",
|
||||||
|
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
|
||||||
|
"wss://relay.example.com",
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
let upstream = announcement.upstream.expect("parses the u tag");
|
||||||
|
|
||||||
|
// The coordinate part resolves to a repository address.
|
||||||
|
// The raw value keeps the `|git-url` suffix.
|
||||||
|
assert_eq!(
|
||||||
|
upstream.addr,
|
||||||
|
Some(crate::repo_addr(
|
||||||
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
|
"upstream"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
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.
|
||||||
|
let base = crate::repo_addr(
|
||||||
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
|
"upstream",
|
||||||
|
);
|
||||||
|
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
||||||
|
let fork = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
// A `u` tag pointing at the base address marks a fork.
|
||||||
|
// This holds even when neither side announces an EUC.
|
||||||
|
assert!(fork.is_fork_of(&base, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_fork_of_matches_a_shared_euc() {
|
||||||
|
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
// The base repo has no `u` tag. It announces the family EUC.
|
||||||
|
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||||
|
let base = Announcement::from_event(&base_event).expect("parses");
|
||||||
|
let base_addr = base.addr();
|
||||||
|
|
||||||
|
// A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC.
|
||||||
|
// Clients of the family can then find it.
|
||||||
|
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
||||||
|
let fork = Announcement::from_event(&fork_event).expect("parses");
|
||||||
|
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||||
|
|
||||||
|
// An unrelated repository with a different EUC is not a fork.
|
||||||
|
let other_event = announcement_event(&[
|
||||||
|
&["d", "other"],
|
||||||
|
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||||
|
]);
|
||||||
|
let other = Announcement::from_event(&other_event).expect("parses");
|
||||||
|
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||||
|
|
||||||
|
// Without a base EUC there is nothing to compare against.
|
||||||
|
assert!(!fork.is_fork_of(&base_addr, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||||
|
// A permanent fork re-announces its EUC, the first commit after the fork.
|
||||||
|
// Only the `u` tag still relates it to the base.
|
||||||
|
let base = crate::repo_addr(
|
||||||
|
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||||
|
"upstream",
|
||||||
|
);
|
||||||
|
let base_euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
let event = announcement_event(&[
|
||||||
|
&["d", "my-fork"],
|
||||||
|
&["u", &base.to_string()],
|
||||||
|
&["r", "cc231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||||
|
]);
|
||||||
|
let fork = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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]]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
|
// The owner asserts themselves as a maintainer of the primary project, per NIP-34.
|
||||||
|
// Announced co-maintainers are included too.
|
||||||
|
assert_eq!(maintainers.len(), 2);
|
||||||
|
assert!(maintainers.contains(&announcement.owner));
|
||||||
|
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
|
||||||
|
let event = announcement_event(&[
|
||||||
|
&["d", "my-fork"],
|
||||||
|
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
|
||||||
|
&["maintainers", MAINTAINER_HEX],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
|
// A `u` tag marks the repository as a subordinate fork.
|
||||||
|
// The author is then not a maintainer of the primary project, per NIP-34.
|
||||||
|
assert!(!maintainers.contains(&announcement.owner));
|
||||||
|
assert_eq!(
|
||||||
|
maintainers,
|
||||||
|
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
.finalize(&keys())
|
||||||
|
.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)
|
||||||
|
.custom_created_at(Timestamp::from(created_at))
|
||||||
|
.finalize(&keys())
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||||
|
// A PR references the root patch, per NIP-34.
|
||||||
|
// Later patches of the set reply to the previous one via NIP-10 `e` tags.
|
||||||
|
let root = patch_event("patch-one", vec![], 100);
|
||||||
|
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||||
|
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pull_request_patch(&pr, [&root, &second]),
|
||||||
|
"patch-one\npatch-two"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pull_request_patches(&pr, [&root, &second]),
|
||||||
|
vec![&root, &second]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pull_request_patches_walks_the_reply_chain_in_order() {
|
||||||
|
let root = patch_event("patch-one", vec![], 100);
|
||||||
|
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||||
|
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||||
|
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||||
|
|
||||||
|
let series = pull_request_patches(&pr, [&third, &root, &second]);
|
||||||
|
assert_eq!(
|
||||||
|
series
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.content.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["patch-one", "patch-two", "patch-three"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.
|
||||||
|
// Walk the reply chain backward to the root.
|
||||||
|
let root = patch_event("patch-one", vec![], 100);
|
||||||
|
let tip = "1111111111111111111111111111111111111111";
|
||||||
|
let last = patch_event(
|
||||||
|
"patch-two",
|
||||||
|
vec![
|
||||||
|
Tag::event(root.id),
|
||||||
|
Tag::parse(["r", tip]).expect("valid tag"),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
let pr = pr_event(
|
||||||
|
"description",
|
||||||
|
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||||
|
);
|
||||||
|
|
||||||
|
let series = pull_request_patches(&pr, [&root, &last]);
|
||||||
|
assert_eq!(
|
||||||
|
series
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.content.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["patch-one", "patch-two"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
use crate::git_event::GitEvent;
|
|
||||||
|
|
||||||
pub struct PullRequest<'a>(pub &'a Event);
|
|
||||||
|
|
||||||
impl<'a> PullRequest<'a> {
|
|
||||||
/// Wraps a pull request root event.
|
|
||||||
pub fn new(event: &'a Event) -> Self {
|
|
||||||
Self(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the ordered patch series, found via the root reference or the tip commit.
|
|
||||||
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
|
||||||
let pr = self.0;
|
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
|
||||||
|
|
||||||
if let Some(root_id) = pr.tags.event_ids().next()
|
|
||||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
|
||||||
{
|
|
||||||
return Self::forward_series(root, &patches);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(tip) = pr.current_commit() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let Some(last) = patches
|
|
||||||
.iter()
|
|
||||||
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
|
||||||
.max_by_key(|patch| patch.created_at)
|
|
||||||
.copied()
|
|
||||||
else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut series = vec![last];
|
|
||||||
loop {
|
|
||||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let Some(prev) = patches
|
|
||||||
.iter()
|
|
||||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
|
||||||
.copied()
|
|
||||||
else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
series.push(prev);
|
|
||||||
}
|
|
||||||
series.reverse();
|
|
||||||
series
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Joins the patch series with newlines, falling back to the event content.
|
|
||||||
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
|
||||||
let pr = self.0;
|
|
||||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
|
||||||
let series = self.patches(patches.iter().copied());
|
|
||||||
if series.is_empty() {
|
|
||||||
return pr.content.clone();
|
|
||||||
}
|
|
||||||
series
|
|
||||||
.iter()
|
|
||||||
.map(|patch| patch.content.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the root author's newest update, since only the author may move the tip.
|
|
||||||
pub fn latest_update(
|
|
||||||
events: impl Iterator<Item = &'a Event>,
|
|
||||||
root: &Event,
|
|
||||||
) -> Option<&'a Event> {
|
|
||||||
let root_hex = root.id.to_hex();
|
|
||||||
events
|
|
||||||
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
|
||||||
.filter(|e| e.pubkey == root.pubkey)
|
|
||||||
.filter(|e| {
|
|
||||||
e.tags
|
|
||||||
.iter()
|
|
||||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
|
||||||
})
|
|
||||||
.max_by_key(|e| e.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walks reply links outward from the root patch, taking the newest child first.
|
|
||||||
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
|
||||||
let mut series = vec![root];
|
|
||||||
loop {
|
|
||||||
let next = patches
|
|
||||||
.iter()
|
|
||||||
.filter(|patch| !series.contains(patch))
|
|
||||||
.filter(|patch| {
|
|
||||||
patch
|
|
||||||
.tags
|
|
||||||
.event_ids()
|
|
||||||
.any(|id| id == series.last().unwrap().id)
|
|
||||||
})
|
|
||||||
.max_by_key(|patch| patch.created_at);
|
|
||||||
let Some(next) = next else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
series.push(next);
|
|
||||||
}
|
|
||||||
series
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks the commit against the patch's `c` and `r` tags.
|
|
||||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
|
||||||
patch
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
|
||||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
|
||||||
_ => false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Returns the test signer keys.
|
|
||||||
fn keys() -> Keys {
|
|
||||||
Keys::new(
|
|
||||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
|
||||||
.expect("valid secret key"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a signed pull request event.
|
|
||||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
|
||||||
EventBuilder::new(Kind::GitPullRequest, content)
|
|
||||||
.tags(tags)
|
|
||||||
.finalize(&keys())
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds a signed patch event with a fixed timestamp.
|
|
||||||
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
|
|
||||||
EventBuilder::new(Kind::GitPatch, content)
|
|
||||||
.tags(tags)
|
|
||||||
.custom_created_at(Timestamp::from(created_at))
|
|
||||||
.finalize(&keys())
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Joins the full patch series into one diff.
|
|
||||||
#[test]
|
|
||||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
|
||||||
let root = patch_event("patch-one", vec![], 100);
|
|
||||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
|
||||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
PullRequest::new(&pr).patch([&root, &second]),
|
|
||||||
"patch-one\npatch-two"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PullRequest::new(&pr).patches([&root, &second]),
|
|
||||||
vec![&root, &second]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orders patches by walking the reply chain.
|
|
||||||
#[test]
|
|
||||||
fn pull_request_patches_walks_the_reply_chain_in_order() {
|
|
||||||
let root = patch_event("patch-one", vec![], 100);
|
|
||||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
|
||||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
|
||||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
|
||||||
|
|
||||||
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
|
||||||
assert_eq!(
|
|
||||||
series
|
|
||||||
.iter()
|
|
||||||
.map(|p| p.content.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec!["patch-one", "patch-two", "patch-three"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finds the patch series through the declared tip commit.
|
|
||||||
#[test]
|
|
||||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
|
||||||
let root = patch_event("patch-one", vec![], 100);
|
|
||||||
let tip = "1111111111111111111111111111111111111111";
|
|
||||||
let last = patch_event(
|
|
||||||
"patch-two",
|
|
||||||
vec![
|
|
||||||
Tag::event(root.id),
|
|
||||||
Tag::parse(["r", tip]).expect("valid tag"),
|
|
||||||
],
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
let pr = pr_event(
|
|
||||||
"description",
|
|
||||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
|
||||||
);
|
|
||||||
|
|
||||||
let series = PullRequest::new(&pr).patches([&root, &last]);
|
|
||||||
assert_eq!(
|
|
||||||
series
|
|
||||||
.iter()
|
|
||||||
.map(|p| p.content.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec!["patch-one", "patch-two"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
|
||||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
|
||||||
|
|
||||||
/// Builds a signed event with a fixed timestamp.
|
|
||||||
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
|
||||||
EventBuilder::new(kind, "")
|
|
||||||
.tags(tags)
|
|
||||||
.custom_created_at(Timestamp::from(created_at))
|
|
||||||
.finalize(&keys())
|
|
||||||
.expect("signed event")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the pull request root used by the update tests.
|
|
||||||
fn pr_root() -> Event {
|
|
||||||
signed_at(
|
|
||||||
Kind::GitPullRequest,
|
|
||||||
vec![
|
|
||||||
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
|
|
||||||
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
|
|
||||||
],
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Picks the newest update targeting the root.
|
|
||||||
#[test]
|
|
||||||
fn latest_update_picks_newest_revision_of_the_root() {
|
|
||||||
let root = pr_root();
|
|
||||||
let root_hex = root.id.to_hex();
|
|
||||||
|
|
||||||
let revision = |created_at: u64| {
|
|
||||||
signed_at(
|
|
||||||
Kind::GitPullRequestUpdate,
|
|
||||||
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
|
|
||||||
created_at,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let unrelated = signed_at(
|
|
||||||
Kind::GitPullRequestUpdate,
|
|
||||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
|
||||||
999,
|
|
||||||
);
|
|
||||||
|
|
||||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
|
||||||
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
|
||||||
|
|
||||||
assert_eq!(latest.created_at.as_secs(), 300);
|
|
||||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ignores updates from authors other than the root owner.
|
|
||||||
#[test]
|
|
||||||
fn latest_update_ignores_other_authors() {
|
|
||||||
let root = pr_root();
|
|
||||||
let root_hex = root.id.to_hex();
|
|
||||||
let other = Keys::new(
|
|
||||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
|
||||||
.expect("valid secret key"),
|
|
||||||
);
|
|
||||||
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
|
|
||||||
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
|
|
||||||
.custom_created_at(Timestamp::from(999))
|
|
||||||
.finalize(&other)
|
|
||||||
.expect("signed event");
|
|
||||||
|
|
||||||
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Build a kind `30618` repository state event from refs and HEAD,
|
||||||
|
/// it is published as `ref: refs/heads/<branch>`.
|
||||||
|
///
|
||||||
|
/// The `d` tag matches the repository id.
|
||||||
|
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||||
|
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||||
|
for (name, commit) in refs {
|
||||||
|
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||||
|
}
|
||||||
|
if let Some(head) = head {
|
||||||
|
tags.push(
|
||||||
|
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a kind `30618` repository state event into refs and HEAD.
|
||||||
|
///
|
||||||
|
/// `refs` are `(refname, commit-id)` pairs.
|
||||||
|
/// `head` is the branch pointed to by the `HEAD` tag, if any.
|
||||||
|
pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
let mut head = None;
|
||||||
|
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
match Nip34Tag::parse(tag.as_slice()) {
|
||||||
|
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||||
|
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||||
|
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
||||||
|
}
|
||||||
|
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||||
|
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(refs, head)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||||
|
const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
|
||||||
|
|
||||||
|
fn keys() -> Keys {
|
||||||
|
Keys::new(
|
||||||
|
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||||
|
.expect("valid secret key"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a signed kind `30618` event from raw tag values.
|
||||||
|
fn state_event(tags: &[&[&str]]) -> Event {
|
||||||
|
let tags: Vec<Tag> = tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
EventBuilder::new(Kind::RepoState, "")
|
||||||
|
.tags(tags)
|
||||||
|
.finalize(&keys())
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_heads_and_tags() {
|
||||||
|
let event = state_event(&[
|
||||||
|
&["HEAD", "ref: refs/heads/main"],
|
||||||
|
&["refs/heads/main", COMMIT_A],
|
||||||
|
&["refs/heads/dev", COMMIT_B],
|
||||||
|
&["refs/tags/v1.0", COMMIT_A],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let (refs, head) = parse_state(&event);
|
||||||
|
|
||||||
|
assert_eq!(head.as_deref(), Some("main"));
|
||||||
|
assert_eq!(
|
||||||
|
refs,
|
||||||
|
vec![
|
||||||
|
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||||
|
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||||
|
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_without_prefix_is_ignored() {
|
||||||
|
let event = state_event(&[&["HEAD", "main"]]);
|
||||||
|
|
||||||
|
let (refs, head) = parse_state(&event);
|
||||||
|
|
||||||
|
assert!(refs.is_empty());
|
||||||
|
assert!(head.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_state_tags() {
|
||||||
|
let event = state_event(&[&["d", "my-repo"], &["name", "ignored"]]);
|
||||||
|
|
||||||
|
let (refs, head) = parse_state(&event);
|
||||||
|
|
||||||
|
assert!(refs.is_empty());
|
||||||
|
assert!(head.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_state_round_trips_through_parse() {
|
||||||
|
let refs = [
|
||||||
|
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||||
|
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||||
|
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let event = build_state("my-repo", &refs, Some("main"))
|
||||||
|
.finalize(&keys())
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
assert_eq!(event.kind, Kind::RepoState);
|
||||||
|
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
||||||
|
|
||||||
|
let (parsed_refs, head) = parse_state(&event);
|
||||||
|
assert_eq!(parsed_refs, refs);
|
||||||
|
assert_eq!(head.as_deref(), Some("main"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_state_omits_head_when_detached() {
|
||||||
|
let refs = [("refs/heads/main".to_owned(), COMMIT_A.to_owned())];
|
||||||
|
|
||||||
|
let event = build_state("my-repo", &refs, None)
|
||||||
|
.finalize(&keys())
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
let (parsed_refs, head) = parse_state(&event);
|
||||||
|
assert_eq!(parsed_refs, refs);
|
||||||
|
assert!(head.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum RepoStatus {
|
pub enum RepoStatus {
|
||||||
Open,
|
Open,
|
||||||
@@ -27,12 +28,27 @@ impl RepoStatus {
|
|||||||
Self::Draft => Kind::GitStatusDraft,
|
Self::Draft => Kind::GitStatusDraft,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve<'a, I>(
|
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
||||||
|
///
|
||||||
|
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
||||||
|
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||||
|
let root = root.to_hex();
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the status of a root event per NIP-34.
|
||||||
|
///
|
||||||
|
/// Defaults to [`RepoStatus::Open`].
|
||||||
|
pub fn resolve_status<'a, I>(
|
||||||
status_events: I,
|
status_events: I,
|
||||||
root_author: &PublicKey,
|
root_author: &PublicKey,
|
||||||
maintainers: &[PublicKey],
|
maintainers: &[PublicKey],
|
||||||
) -> Self
|
) -> RepoStatus
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = &'a Event>,
|
I: IntoIterator<Item = &'a Event>,
|
||||||
{
|
{
|
||||||
@@ -44,12 +60,10 @@ impl RepoStatus {
|
|||||||
.and_then(|e| RepoStatus::from_kind(e.kind))
|
.and_then(|e| RepoStatus::from_kind(e.kind))
|
||||||
.unwrap_or(RepoStatus::Open)
|
.unwrap_or(RepoStatus::Open)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::GitEvent;
|
|
||||||
|
|
||||||
const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||||
const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
|
const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
|
||||||
@@ -62,6 +76,7 @@ mod tests {
|
|||||||
EventId::from_hex(ROOT_ID_HEX).expect("valid event id")
|
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 {
|
fn status_event(author: &Keys, kind: Kind, root: EventId, created_at: u64) -> Event {
|
||||||
EventBuilder::new(kind, "")
|
EventBuilder::new(kind, "")
|
||||||
.tags([Tag::event(root)])
|
.tags([Tag::event(root)])
|
||||||
@@ -80,8 +95,58 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.expect("signed event");
|
.expect("signed event");
|
||||||
|
|
||||||
assert!(event.references_root(&root));
|
assert!(references_root(&event, &root));
|
||||||
assert!(!event.references_root(&EventId::from_hex(OTHER_ID_HEX).expect("valid id")));
|
assert!(!references_root(
|
||||||
|
&event,
|
||||||
|
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn references_root_matches_uppercase_e_tag() {
|
||||||
|
let root = root_event_id();
|
||||||
|
let event = EventBuilder::new(Kind::Comment, "")
|
||||||
|
.tags([Tag::parse(["E", ROOT_ID_HEX]).expect("valid E tag")])
|
||||||
|
.finalize(&keys_from_hex(
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
))
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
assert!(references_root(&event, &root));
|
||||||
|
assert!(!references_root(
|
||||||
|
&event,
|
||||||
|
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn references_root_false_without_e_tags() {
|
||||||
|
let event = EventBuilder::new(Kind::GitStatusOpen, "")
|
||||||
|
.finalize(&keys_from_hex(
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
))
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
assert!(!references_root(&event, &root_event_id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_to_open_without_status_events() {
|
||||||
|
let owner =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001");
|
||||||
|
let maintainer =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||||
|
|
||||||
|
let statuses: Vec<Event> = Vec::new();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_status(
|
||||||
|
statuses.iter(),
|
||||||
|
&owner.public_key(),
|
||||||
|
&[maintainer.public_key()]
|
||||||
|
),
|
||||||
|
RepoStatus::Open
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -98,7 +163,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RepoStatus::resolve(
|
resolve_status(
|
||||||
statuses.iter(),
|
statuses.iter(),
|
||||||
&owner.public_key(),
|
&owner.public_key(),
|
||||||
&[maintainer.public_key()]
|
&[maintainer.public_key()]
|
||||||
@@ -123,7 +188,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
RepoStatus::resolve(
|
resolve_status(
|
||||||
statuses.iter(),
|
statuses.iter(),
|
||||||
&owner.public_key(),
|
&owner.public_key(),
|
||||||
&[maintainer.public_key()]
|
&[maintainer.public_key()]
|
||||||
@@ -131,4 +196,18 @@ mod tests {
|
|||||||
RepoStatus::Draft
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
use crate::RepoAddr;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Upstream {
|
|
||||||
pub raw: String,
|
|
||||||
pub addr: Option<RepoAddr>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Upstream {
|
|
||||||
/// Parses a raw `u` tag value, extracting the coordinate when present.
|
|
||||||
pub(crate) fn parse(raw: &str) -> Self {
|
|
||||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
|
||||||
let addr = coordinate
|
|
||||||
.parse::<Coordinate>()
|
|
||||||
.ok()
|
|
||||||
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
|
||||||
.map(RepoAddr::from);
|
|
||||||
Self {
|
|
||||||
raw: raw.to_owned(),
|
|
||||||
addr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows the coordinate when parsed, otherwise the raw value.
|
|
||||||
pub fn display(&self) -> String {
|
|
||||||
match &self.addr {
|
|
||||||
Some(addr) => addr.to_string(),
|
|
||||||
None => self.raw.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,8 @@ publish.workspace = true
|
|||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
|
|
||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
log.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||||
gix-worktree = "0.57"
|
anyhow.workspace = true
|
||||||
gix-worktree-state = "0.35"
|
|
||||||
diffy = "0.5"
|
|
||||||
ignore = "0.4"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::{Arc, Mutex, PoisonError};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use signed_core::{Announcement, RepoAddr};
|
|
||||||
|
|
||||||
use crate::repo::Repo;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GitCache {
|
|
||||||
root: PathBuf,
|
|
||||||
clone_locks: Arc<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GitCache {
|
|
||||||
/// Creates a cache rooted at `root`.
|
|
||||||
pub fn new(root: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
root,
|
|
||||||
clone_locks: Arc::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the cache root directory.
|
|
||||||
pub fn root(&self) -> &Path {
|
|
||||||
&self.root
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the cache path for a repository address.
|
|
||||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
|
||||||
self.root
|
|
||||||
.join(addr.public_key().to_hex())
|
|
||||||
.join(Self::sanitize_path_component(addr.identifier()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the cached repository, or `None` when it is not cloned yet.
|
|
||||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
|
|
||||||
let path = self.repo_path(addr);
|
|
||||||
match gix::open(&path) {
|
|
||||||
Ok(repo) => Ok(Some(Repo::new(repo))),
|
|
||||||
Err(e) if e.is_not_found() => Ok(None),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the clone lock for `path`, creating it on first use.
|
|
||||||
fn clone_lock(&self, path: &Path) -> Arc<Mutex<()>> {
|
|
||||||
let mut locks = self
|
|
||||||
.clone_locks
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(PoisonError::into_inner);
|
|
||||||
Arc::clone(locks.entry(path.to_path_buf()).or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the cached repository, cloning from one of `clone_urls` on first use.
|
|
||||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
|
||||||
let path = self.repo_path(addr);
|
|
||||||
|
|
||||||
// Lock the clone lock for this path to prevent concurrent clones.
|
|
||||||
let clone_lock = self.clone_lock(&path);
|
|
||||||
let _clone_guard = clone_lock.lock().unwrap_or_else(PoisonError::into_inner);
|
|
||||||
|
|
||||||
// Refreshes the cached repository if it is already cloned, or clones it from one of `clone_urls` otherwise.
|
|
||||||
if let Ok(Some(repo)) = self.open(addr) {
|
|
||||||
if let Err(error) = repo.fetch() {
|
|
||||||
log::warn!("failed to refresh the cached repository: {error:#}");
|
|
||||||
}
|
|
||||||
return Ok(repo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the parent directory if it does not exist.
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the cached clone if it exists but is unusable.
|
|
||||||
if path.exists() {
|
|
||||||
log::warn!("removing unusable cached clone at {}", path.display());
|
|
||||||
std::fs::remove_dir_all(&path)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Repo::clone(clone_urls, &path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps untrusted relay content onto a safe single path component.
|
|
||||||
pub fn sanitize_path_component(id: &str) -> String {
|
|
||||||
let sanitized: String = id
|
|
||||||
.chars()
|
|
||||||
.map(|c| {
|
|
||||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
|
||||||
c
|
|
||||||
} else {
|
|
||||||
'_'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if sanitized == "." || sanitized == ".." {
|
|
||||||
return "_".to_owned();
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the `owner/identifier` namespace used for fork checkouts.
|
|
||||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/{}",
|
|
||||||
announcement.owner.to_hex(),
|
|
||||||
Self::sanitize_path_component(&announcement.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DiffLineKind {
|
|
||||||
Context,
|
|
||||||
Addition,
|
|
||||||
Deletion,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DiffLine {
|
|
||||||
pub kind: DiffLineKind,
|
|
||||||
pub old: Option<u32>,
|
|
||||||
pub new: Option<u32>,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiffLine {
|
|
||||||
/// Creates a diff line with its kind, optional line numbers, and text.
|
|
||||||
pub fn new(kind: DiffLineKind, old: Option<u32>, new: Option<u32>, text: String) -> Self {
|
|
||||||
Self {
|
|
||||||
kind,
|
|
||||||
old,
|
|
||||||
new,
|
|
||||||
text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DiffHunk {
|
|
||||||
pub old_start: u32,
|
|
||||||
pub old_lines: u32,
|
|
||||||
pub new_start: u32,
|
|
||||||
pub new_lines: u32,
|
|
||||||
pub lines: Vec<DiffLine>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiffHunk {
|
|
||||||
/// Creates a hunk spanning the given old and new ranges.
|
|
||||||
pub fn new(
|
|
||||||
old_start: u32,
|
|
||||||
old_lines: u32,
|
|
||||||
new_start: u32,
|
|
||||||
new_lines: u32,
|
|
||||||
lines: Vec<DiffLine>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
old_start,
|
|
||||||
old_lines,
|
|
||||||
new_start,
|
|
||||||
new_lines,
|
|
||||||
lines,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DiffStatus {
|
|
||||||
Added,
|
|
||||||
Modified,
|
|
||||||
Deleted,
|
|
||||||
Renamed,
|
|
||||||
Copied,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct FileDiff {
|
|
||||||
pub path: String,
|
|
||||||
pub old_path: Option<String>,
|
|
||||||
pub status: DiffStatus,
|
|
||||||
pub insertions: usize,
|
|
||||||
pub deletions: usize,
|
|
||||||
pub binary: bool,
|
|
||||||
pub hunks: Vec<DiffHunk>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileDiff {
|
|
||||||
/// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks.
|
|
||||||
pub fn new(
|
|
||||||
path: String,
|
|
||||||
old_path: Option<String>,
|
|
||||||
status: DiffStatus,
|
|
||||||
insertions: usize,
|
|
||||||
deletions: usize,
|
|
||||||
binary: bool,
|
|
||||||
hunks: Vec<DiffHunk>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
path,
|
|
||||||
old_path,
|
|
||||||
status,
|
|
||||||
insertions,
|
|
||||||
deletions,
|
|
||||||
binary,
|
|
||||||
hunks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CommitDiff {
|
|
||||||
pub files: Vec<FileDiff>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommitDiff {
|
|
||||||
/// Creates a commit diff from its files.
|
|
||||||
pub fn new(files: Vec<FileDiff>) -> Self {
|
|
||||||
Self { files }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct HunkBuilder {
|
|
||||||
old_start: u32,
|
|
||||||
old_lines: u32,
|
|
||||||
new_start: u32,
|
|
||||||
new_lines: u32,
|
|
||||||
old: u32,
|
|
||||||
new: u32,
|
|
||||||
lines: Vec<DiffLine>,
|
|
||||||
insertions: usize,
|
|
||||||
deletions: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HunkBuilder {
|
|
||||||
/// Starts a hunk spanning the given old and new ranges.
|
|
||||||
pub(crate) fn new(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
old_start,
|
|
||||||
old_lines,
|
|
||||||
new_start,
|
|
||||||
new_lines,
|
|
||||||
old: old_start,
|
|
||||||
new: new_start,
|
|
||||||
lines: Vec::new(),
|
|
||||||
insertions: 0,
|
|
||||||
deletions: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Appends a line, numbering it and counting insertions and deletions.
|
|
||||||
pub(crate) fn push(&mut self, kind: DiffLineKind, text: impl Into<String>) {
|
|
||||||
let (old_no, new_no) = match kind {
|
|
||||||
DiffLineKind::Context => {
|
|
||||||
let numbers = (Some(self.old), Some(self.new));
|
|
||||||
self.old += 1;
|
|
||||||
self.new += 1;
|
|
||||||
numbers
|
|
||||||
}
|
|
||||||
DiffLineKind::Addition => {
|
|
||||||
self.insertions += 1;
|
|
||||||
let number = Some(self.new);
|
|
||||||
self.new += 1;
|
|
||||||
(None, number)
|
|
||||||
}
|
|
||||||
DiffLineKind::Deletion => {
|
|
||||||
self.deletions += 1;
|
|
||||||
let number = Some(self.old);
|
|
||||||
self.old += 1;
|
|
||||||
(number, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.lines
|
|
||||||
.push(DiffLine::new(kind, old_no, new_no, text.into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finishes the hunk and returns it with its insertion and deletion counts.
|
|
||||||
pub(crate) fn finish(self) -> (DiffHunk, usize, usize) {
|
|
||||||
let hunk = DiffHunk::new(
|
|
||||||
self.old_start,
|
|
||||||
self.old_lines,
|
|
||||||
self.new_start,
|
|
||||||
self.new_lines,
|
|
||||||
self.lines,
|
|
||||||
);
|
|
||||||
(hunk, self.insertions, self.deletions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct HunkCollector<'a> {
|
|
||||||
hunks: &'a mut Vec<DiffHunk>,
|
|
||||||
insertions: &'a mut usize,
|
|
||||||
deletions: &'a mut usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> HunkCollector<'a> {
|
|
||||||
/// Collects hunks into `hunks` while counting `insertions` and `deletions`.
|
|
||||||
pub(crate) fn new(
|
|
||||||
hunks: &'a mut Vec<DiffHunk>,
|
|
||||||
insertions: &'a mut usize,
|
|
||||||
deletions: &'a mut usize,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
hunks,
|
|
||||||
insertions,
|
|
||||||
deletions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConsumeHunk for HunkCollector<'_> {
|
|
||||||
type Out = ();
|
|
||||||
|
|
||||||
/// Consumes one hunk, tracking line numbers and insertion/deletion counts.
|
|
||||||
fn consume_hunk(
|
|
||||||
&mut self,
|
|
||||||
header: HunkHeader,
|
|
||||||
lines: &[(GixLineKind, &[u8])],
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
let mut builder = HunkBuilder::new(
|
|
||||||
header.before_hunk_start,
|
|
||||||
header.before_hunk_len,
|
|
||||||
header.after_hunk_start,
|
|
||||||
header.after_hunk_len,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (kind, content) in lines {
|
|
||||||
let kind = match kind {
|
|
||||||
GixLineKind::Context => DiffLineKind::Context,
|
|
||||||
GixLineKind::Remove => DiffLineKind::Deletion,
|
|
||||||
GixLineKind::Add => DiffLineKind::Addition,
|
|
||||||
};
|
|
||||||
builder.push(kind, String::from_utf8_lossy(content).into_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
let (hunk, insertions, deletions) = builder.finish();
|
|
||||||
*self.insertions += insertions;
|
|
||||||
*self.deletions += deletions;
|
|
||||||
self.hunks.push(hunk);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the end of the unified diff stream.
|
|
||||||
fn finish(self) {}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
|
||||||
|
|
||||||
pub struct CommitList {
|
|
||||||
pub total: usize,
|
|
||||||
pub commits: Vec<FileCommit>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommitList {
|
|
||||||
/// Creates a list holding `total` commits of which `commits` are materialized.
|
|
||||||
pub fn new(total: usize, commits: Vec<FileCommit>) -> Self {
|
|
||||||
Self { total, commits }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct FileCommit {
|
|
||||||
pub id: String,
|
|
||||||
pub summary: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub author: String,
|
|
||||||
pub time: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileCommit {
|
|
||||||
/// Creates a commit entry from its id, message parts, author, and timestamp.
|
|
||||||
pub fn new(
|
|
||||||
id: String,
|
|
||||||
summary: String,
|
|
||||||
description: Option<String>,
|
|
||||||
author: String,
|
|
||||||
time: i64,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
summary,
|
|
||||||
description,
|
|
||||||
author,
|
|
||||||
time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a gix commit including its message body.
|
|
||||||
pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
|
||||||
Self::from_commit_with_description(commit, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a gix commit, skipping the message body since lists never show it.
|
|
||||||
pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
|
||||||
Self::from_commit_with_description(commit, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a gix commit, optionally skipping the message body.
|
|
||||||
fn from_commit_with_description(
|
|
||||||
commit: &gix::Commit<'_>,
|
|
||||||
include_description: bool,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
use crate::GixResultExt as _;
|
|
||||||
|
|
||||||
let author = commit.author().into_anyhow()?;
|
|
||||||
let message = commit.message().into_anyhow()?;
|
|
||||||
|
|
||||||
Ok(FileCommit::new(
|
|
||||||
commit.id().shorten_or_id().to_string(),
|
|
||||||
String::from_utf8_lossy(message.title).trim().to_string(),
|
|
||||||
if include_description {
|
|
||||||
message
|
|
||||||
.body
|
|
||||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
|
||||||
.filter(|body| !body.is_empty())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
},
|
|
||||||
String::from_utf8_lossy(author.name).trim().to_string(),
|
|
||||||
author.time().into_anyhow()?.seconds,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3516
-34
File diff suppressed because it is too large
Load Diff
@@ -1,63 +0,0 @@
|
|||||||
use nostr::prelude::*;
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
pub(crate) struct Nip34Json {
|
|
||||||
pub(crate) identifier: Option<String>,
|
|
||||||
pub(crate) owner: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Nip34Kind {
|
|
||||||
Initialized,
|
|
||||||
Cloned,
|
|
||||||
ToolingOnly,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
pub struct GraspSignals {
|
|
||||||
pub nip34_json: bool,
|
|
||||||
pub nip34_excluded: bool,
|
|
||||||
pub nostr_repo_config: bool,
|
|
||||||
pub nostr_remote: bool,
|
|
||||||
pub grasp_remote: bool,
|
|
||||||
pub nip34_grasp_remote: bool,
|
|
||||||
pub nip34_state_refs: bool,
|
|
||||||
pub nostr_cache: bool,
|
|
||||||
pub nostr_aux_config: bool,
|
|
||||||
pub maintainers_yaml: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GraspSignals {
|
|
||||||
/// Returns whether any Nostr tooling marker was found.
|
|
||||||
pub fn any(&self) -> bool {
|
|
||||||
*self != Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct Nip34Binding {
|
|
||||||
pub kind: Nip34Kind,
|
|
||||||
pub signals: GraspSignals,
|
|
||||||
pub owner: Option<PublicKey>,
|
|
||||||
pub identifier: Option<String>,
|
|
||||||
pub grasp_urls: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Nip34Binding {
|
|
||||||
/// Creates a binding from its kind, detected signals, and recovered coordinates.
|
|
||||||
pub fn new(
|
|
||||||
kind: Nip34Kind,
|
|
||||||
signals: GraspSignals,
|
|
||||||
owner: Option<PublicKey>,
|
|
||||||
identifier: Option<String>,
|
|
||||||
grasp_urls: Vec<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
kind,
|
|
||||||
signals,
|
|
||||||
owner,
|
|
||||||
identifier,
|
|
||||||
grasp_urls,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
|
||||||
use diffy::{Hunk, Line};
|
|
||||||
|
|
||||||
use crate::diff::{CommitDiff, DiffHunk, DiffLineKind, DiffStatus, FileDiff, HunkBuilder};
|
|
||||||
use crate::history::FileCommit;
|
|
||||||
|
|
||||||
pub struct PatchParser;
|
|
||||||
|
|
||||||
impl PatchParser {
|
|
||||||
/// Splits an mbox series into the raw text of each message.
|
|
||||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
|
||||||
Self::envelopes(patch)
|
|
||||||
.into_iter()
|
|
||||||
.map(|message| message.text)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses a git diff into file diffs, returning an empty diff without one.
|
|
||||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
|
||||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
|
||||||
return Ok(CommitDiff::new(Vec::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut files = Vec::new();
|
|
||||||
|
|
||||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
|
||||||
files.push(Self::file_diff(file?));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(CommitDiff::new(files))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lists the commits represented by the mbox messages that carry an id.
|
|
||||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
|
||||||
Self::envelopes(patch)
|
|
||||||
.into_iter()
|
|
||||||
.filter(|message| !message.id.is_empty())
|
|
||||||
.map(|message| {
|
|
||||||
FileCommit::new(
|
|
||||||
message.id.to_string(),
|
|
||||||
message
|
|
||||||
.header("Subject")
|
|
||||||
.map(Self::strip_patch_prefix)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
None,
|
|
||||||
message
|
|
||||||
.header("From")
|
|
||||||
.map(Self::name_from_address)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
message
|
|
||||||
.header("Date")
|
|
||||||
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
|
||||||
.map(|time| time.seconds)
|
|
||||||
.unwrap_or(0),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
|
|
||||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
|
||||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
|
||||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
|
||||||
let mut headers_closed = false;
|
|
||||||
|
|
||||||
let mut offset = 0usize;
|
|
||||||
for line in patch.lines() {
|
|
||||||
let line_start = offset;
|
|
||||||
offset += line.len() + 1;
|
|
||||||
|
|
||||||
let is_envelope = line
|
|
||||||
.strip_prefix("From ")
|
|
||||||
.and_then(|rest| rest.split_whitespace().next())
|
|
||||||
.is_some_and(|id| id.len() == 40);
|
|
||||||
|
|
||||||
if is_envelope {
|
|
||||||
if let Some((start, id, headers)) = current.take() {
|
|
||||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = line
|
|
||||||
.strip_prefix("From ")
|
|
||||||
.and_then(|rest| rest.split_whitespace().next())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
current = Some((line_start, id, Vec::new()));
|
|
||||||
headers_closed = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((_, _, headers)) = &mut current {
|
|
||||||
if headers_closed {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if line.is_empty() {
|
|
||||||
headers_closed = true;
|
|
||||||
} else {
|
|
||||||
headers.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((start, id, headers)) = current.take() {
|
|
||||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
|
||||||
}
|
|
||||||
|
|
||||||
if messages.is_empty() {
|
|
||||||
messages.push(Envelope::new(patch, "", Vec::new()));
|
|
||||||
}
|
|
||||||
|
|
||||||
messages
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts the display name from a `From` header value.
|
|
||||||
fn name_from_address(from: &str) -> String {
|
|
||||||
match from.trim().find('<') {
|
|
||||||
Some(ix) => from[..ix].trim().to_string(),
|
|
||||||
None => from.trim().to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
|
|
||||||
fn strip_patch_prefix(subject: &str) -> String {
|
|
||||||
let trimmed = subject.trim();
|
|
||||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
|
||||||
return trimmed.to_string();
|
|
||||||
};
|
|
||||||
let Some(end) = rest.find(']') else {
|
|
||||||
return trimmed.to_string();
|
|
||||||
};
|
|
||||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
|
||||||
rest[end + 1..].trim().to_string()
|
|
||||||
} else {
|
|
||||||
trimmed.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
|
|
||||||
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
|
|
||||||
let stripped;
|
|
||||||
let operation = match file.operation() {
|
|
||||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
|
||||||
operation => {
|
|
||||||
stripped = operation.strip_prefix(1);
|
|
||||||
&stripped
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (path, old_path, status) = match operation {
|
|
||||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
|
||||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
|
||||||
FileOperation::Modify { modified, .. } => {
|
|
||||||
(modified.as_ref(), None, DiffStatus::Modified)
|
|
||||||
}
|
|
||||||
FileOperation::Rename { from, to } => {
|
|
||||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
|
||||||
}
|
|
||||||
FileOperation::Copy { from, to } => {
|
|
||||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Copied)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut insertions = 0usize;
|
|
||||||
let mut deletions = 0usize;
|
|
||||||
let mut hunks = Vec::new();
|
|
||||||
|
|
||||||
let patch = file.patch();
|
|
||||||
|
|
||||||
if let Some(text) = patch.as_text() {
|
|
||||||
for hunk in text.hunks() {
|
|
||||||
let (hunk, hunk_insertions, hunk_deletions) = Self::hunk_diff(hunk);
|
|
||||||
insertions += hunk_insertions;
|
|
||||||
deletions += hunk_deletions;
|
|
||||||
hunks.push(hunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FileDiff::new(
|
|
||||||
path.to_owned(),
|
|
||||||
old_path.map(str::to_owned),
|
|
||||||
status,
|
|
||||||
insertions,
|
|
||||||
deletions,
|
|
||||||
patch.is_binary(),
|
|
||||||
hunks,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a diffy hunk into a `DiffHunk` with its insertion and deletion counts.
|
|
||||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> (DiffHunk, usize, usize) {
|
|
||||||
let old_range = hunk.old_range();
|
|
||||||
let new_range = hunk.new_range();
|
|
||||||
|
|
||||||
let mut builder = HunkBuilder::new(
|
|
||||||
old_range.start() as u32,
|
|
||||||
old_range.len() as u32,
|
|
||||||
new_range.start() as u32,
|
|
||||||
new_range.len() as u32,
|
|
||||||
);
|
|
||||||
|
|
||||||
for line in hunk.lines() {
|
|
||||||
let (kind, text) = match line {
|
|
||||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
|
||||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
|
||||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
|
||||||
};
|
|
||||||
|
|
||||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
|
||||||
let text = text.strip_suffix('\r').unwrap_or(text);
|
|
||||||
builder.push(kind, text.to_owned());
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Envelope<'a> {
|
|
||||||
text: &'a str,
|
|
||||||
id: &'a str,
|
|
||||||
headers: Vec<&'a str>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Envelope<'a> {
|
|
||||||
/// Creates an envelope from its text, commit id, and header lines.
|
|
||||||
fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
|
|
||||||
Self { text, id, headers }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the first header line matching `name: `.
|
|
||||||
fn header(&self, name: &str) -> Option<&str> {
|
|
||||||
let prefix = format!("{name}: ");
|
|
||||||
self.headers
|
|
||||||
.iter()
|
|
||||||
.find(|line| line.starts_with(&prefix))
|
|
||||||
.map(|line| &line[prefix.len()..])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use ignore::WalkBuilder;
|
|
||||||
|
|
||||||
use crate::nip34::Nip34Binding;
|
|
||||||
use crate::repo::Repo;
|
|
||||||
|
|
||||||
const SCAN_MAX_DEPTH: usize = 12;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LocalRepo {
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub nip34: Option<Nip34Binding>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalRepo {
|
|
||||||
/// Creates a local repository entry from its path and optional NIP-34 binding.
|
|
||||||
pub fn new(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
|
|
||||||
Self { path, nip34 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finds git repositories under `root` up to `SCAN_MAX_DEPTH` deep, without descending into them.
|
|
||||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
|
||||||
let Ok(root) = root.canonicalize() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
|
|
||||||
let found = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
|
|
||||||
|
|
||||||
let walker = WalkBuilder::new(&root)
|
|
||||||
.max_depth(Some(SCAN_MAX_DEPTH))
|
|
||||||
.require_git(false)
|
|
||||||
.filter_entry({
|
|
||||||
let found = found.clone();
|
|
||||||
move |entry| {
|
|
||||||
let Some(kind) = entry.file_type() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
if !kind.is_dir() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dir = entry.path();
|
|
||||||
let Ok(repo) = gix::discover(dir) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(workdir) = repo.workdir() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if workdir != dir {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
match found.lock() {
|
|
||||||
Ok(mut repos) => repos.push(dir.to_path_buf()),
|
|
||||||
Err(poisoned) => poisoned.into_inner().push(dir.to_path_buf()),
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.build();
|
|
||||||
|
|
||||||
for _ in walker {}
|
|
||||||
|
|
||||||
let mut repos = match found.lock() {
|
|
||||||
Ok(mut repos) => std::mem::take(&mut *repos),
|
|
||||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
|
||||||
};
|
|
||||||
repos.sort();
|
|
||||||
|
|
||||||
repos
|
|
||||||
.into_iter()
|
|
||||||
.map(|path| {
|
|
||||||
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
|
|
||||||
LocalRepo::new(path, nip34)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
/// How a single branch compares between local and Nostr.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum RefSync {
|
|
||||||
InSync,
|
|
||||||
LocalAhead { ahead: usize },
|
|
||||||
RemoteAhead { behind: usize },
|
|
||||||
Diverged { ahead: usize, behind: usize },
|
|
||||||
LocalOnly,
|
|
||||||
RemoteOnly,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Branch comparison between a local repository and its Nostr state.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct RepoSyncStatus {
|
|
||||||
/// Branch short name and its classification.
|
|
||||||
pub refs: Vec<(String, RefSync)>,
|
|
||||||
pub ahead_total: usize,
|
|
||||||
pub behind_total: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RepoSyncStatus {
|
|
||||||
/// True when every branch matches the Nostr state.
|
|
||||||
pub fn in_sync(&self) -> bool {
|
|
||||||
self.refs.iter().all(|(_, sync)| *sync == RefSync::InSync)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps branch short name to lowercase commit oid.
|
|
||||||
pub(crate) fn branches(refs: &[(String, String)]) -> BTreeMap<&str, String> {
|
|
||||||
refs.iter()
|
|
||||||
.filter_map(|(name, commit)| {
|
|
||||||
name.strip_prefix("refs/heads/")
|
|
||||||
.map(|branch| (branch, commit.to_ascii_lowercase()))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,849 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use nostr::prelude::Keys;
|
|
||||||
use signed_core::RepoAddr;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blocks_parent_components() {
|
|
||||||
assert_eq!(GitCache::sanitize_path_component(".."), "_");
|
|
||||||
assert_eq!(GitCache::sanitize_path_component("."), "_");
|
|
||||||
assert_eq!(GitCache::sanitize_path_component("../.."), ".._..");
|
|
||||||
assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn concurrent_ensure_clone_succeeds_for_every_caller() {
|
|
||||||
let server = tempfile::tempdir().expect("tempdir");
|
|
||||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
|
||||||
|
|
||||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&work_repo, "initial");
|
|
||||||
let work_dir = work_dir.path();
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", server.path().display());
|
|
||||||
Repo::open(work_dir)
|
|
||||||
.expect("open")
|
|
||||||
.push_all(&base_url, "npub1test", "repo")
|
|
||||||
.expect("push");
|
|
||||||
|
|
||||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
|
||||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
|
||||||
let clone_url = format!("file://{}", server_repo.display());
|
|
||||||
|
|
||||||
std::thread::scope(|scope| {
|
|
||||||
for _ in 0..4 {
|
|
||||||
scope.spawn(|| {
|
|
||||||
cache
|
|
||||||
.ensure_clone(&addr, &[&clone_url])
|
|
||||||
.expect("ensure_clone");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(cache.open(&addr).expect("open").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_clone_recovers_from_a_leftover_directory() {
|
|
||||||
let server = tempfile::tempdir().expect("tempdir");
|
|
||||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
|
||||||
|
|
||||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&work_repo, "initial");
|
|
||||||
let work_dir = work_dir.path();
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", server.path().display());
|
|
||||||
Repo::open(work_dir)
|
|
||||||
.expect("open")
|
|
||||||
.push_all(&base_url, "npub1test", "repo")
|
|
||||||
.expect("push");
|
|
||||||
|
|
||||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
|
||||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
|
||||||
let clone_url = format!("file://{}", server_repo.display());
|
|
||||||
|
|
||||||
// Simulate the remains of an interrupted clone.
|
|
||||||
let leftover = cache.repo_path(&addr);
|
|
||||||
std::fs::create_dir_all(&leftover).expect("mkdir");
|
|
||||||
|
|
||||||
cache
|
|
||||||
.ensure_clone(&addr, &[&clone_url])
|
|
||||||
.expect("ensure_clone");
|
|
||||||
|
|
||||||
assert!(cache.open(&addr).expect("open").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn root_commit_reports_the_first_ancestor() {
|
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
|
|
||||||
let dir = dir.path();
|
|
||||||
let root = repo.root_commit().expect("root").expect("commit");
|
|
||||||
assert_eq!(root.len(), 40);
|
|
||||||
|
|
||||||
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
|
||||||
commit_all(&repo, "second");
|
|
||||||
assert_eq!(
|
|
||||||
repo.root_commit().expect("root").as_deref(),
|
|
||||||
Some(root.as_str())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_all_mirrors_branches_and_tags() {
|
|
||||||
let server = tempfile::tempdir().unwrap();
|
|
||||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
|
||||||
|
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
let dir = dir.path();
|
|
||||||
|
|
||||||
git_run(dir, &["checkout", "-b", "feature"]);
|
|
||||||
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
|
||||||
commit_all(&repo, "feature work");
|
|
||||||
git_run(dir, &["checkout", "-"]);
|
|
||||||
git_run(dir, &["tag", "v1.0"]);
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", server.path().display());
|
|
||||||
Repo::open(dir)
|
|
||||||
.expect("open")
|
|
||||||
.push_all(&base_url, "npub1test", "my-repo")
|
|
||||||
.expect("push");
|
|
||||||
|
|
||||||
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
|
||||||
assert!(refs.contains("refs/heads/main"));
|
|
||||||
assert!(refs.contains("refs/heads/feature"));
|
|
||||||
assert!(refs.contains("refs/tags/v1.0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ls_remote_lists_branches_and_tags_only() {
|
|
||||||
let server = tempfile::tempdir().unwrap();
|
|
||||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
|
||||||
|
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
let dir = dir.path();
|
|
||||||
|
|
||||||
git_run(dir, &["branch", "feature"]);
|
|
||||||
git_run(dir, &["tag", "v1"]);
|
|
||||||
git_run(dir, &["tag", "-a", "v2", "-m", "annotated"]);
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", server.path().display());
|
|
||||||
let local = Repo::open(dir).expect("open");
|
|
||||||
local
|
|
||||||
.push_all(&base_url, "npub1test", "my-repo")
|
|
||||||
.expect("push");
|
|
||||||
|
|
||||||
// Refs outside heads and tags, and the HEAD symref line, are not listed.
|
|
||||||
let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid");
|
|
||||||
git_run(&server_repo, &["update-ref", "refs/nostr/abc", &main]);
|
|
||||||
|
|
||||||
let remote = local
|
|
||||||
.ls_remote(&format!("file://{}", server_repo.display()))
|
|
||||||
.expect("ls-remote");
|
|
||||||
let names: Vec<String> = remote.iter().map(|(name, _)| name.clone()).collect();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
names,
|
|
||||||
vec![
|
|
||||||
"refs/heads/feature".to_owned(),
|
|
||||||
"refs/heads/main".to_owned(),
|
|
||||||
"refs/tags/v1".to_owned(),
|
|
||||||
"refs/tags/v2".to_owned(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
remote
|
|
||||||
.iter()
|
|
||||||
.find(|(name, _)| name == "refs/heads/main")
|
|
||||||
.map(|(_, oid)| oid.as_str()),
|
|
||||||
Some(main.as_str())
|
|
||||||
);
|
|
||||||
// The annotated tag lists the tag object, matching the local ref state,
|
|
||||||
// without a peeled `^{}` duplicate.
|
|
||||||
let v2 = git_in(dir, &["rev-parse", "refs/tags/v2"]).expect("v2 oid");
|
|
||||||
assert_eq!(
|
|
||||||
remote
|
|
||||||
.iter()
|
|
||||||
.find(|(name, _)| name == "refs/tags/v2")
|
|
||||||
.map(|(_, oid)| oid.as_str()),
|
|
||||||
Some(v2.as_str())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_all_force_updates_diverged_remote_branches() {
|
|
||||||
let server = tempfile::tempdir().unwrap();
|
|
||||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
|
||||||
|
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
std::fs::write(dir.path().join("b.txt"), b"two").expect("write");
|
|
||||||
commit_all(&repo, "second");
|
|
||||||
let dir = dir.path();
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", server.path().display());
|
|
||||||
let local = Repo::open(dir).expect("open");
|
|
||||||
local
|
|
||||||
.push_all(&base_url, "npub1test", "my-repo")
|
|
||||||
.expect("initial push");
|
|
||||||
|
|
||||||
// Diverge local main from the pushed state.
|
|
||||||
git_run(dir, &["reset", "-q", "--hard", "HEAD~1"]);
|
|
||||||
std::fs::write(dir.join("c.txt"), b"three").expect("write");
|
|
||||||
commit_all(&repo, "diverged");
|
|
||||||
|
|
||||||
local
|
|
||||||
.push_all(&base_url, "npub1test", "my-repo")
|
|
||||||
.expect("force push");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
git_in(dir, &["rev-parse", "refs/heads/main"]).expect("local main"),
|
|
||||||
git_in(&server_repo, &["rev-parse", "refs/heads/main"]).expect("server main")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_refspecs_deletes_server_refs_missing_locally() {
|
|
||||||
let server = tempfile::tempdir().unwrap();
|
|
||||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
|
||||||
|
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
let dir = dir.path();
|
|
||||||
|
|
||||||
git_run(dir, &["branch", "feature"]);
|
|
||||||
git_run(dir, &["tag", "v1"]);
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", server.path().display());
|
|
||||||
let local = Repo::open(dir).expect("open");
|
|
||||||
local
|
|
||||||
.push_all(&base_url, "npub1test", "my-repo")
|
|
||||||
.expect("initial push");
|
|
||||||
|
|
||||||
git_run(dir, &["branch", "-D", "feature"]);
|
|
||||||
git_run(dir, &["tag", "-d", "v1"]);
|
|
||||||
|
|
||||||
local
|
|
||||||
.push_refspecs(
|
|
||||||
&base_url,
|
|
||||||
"npub1test",
|
|
||||||
"my-repo",
|
|
||||||
&[
|
|
||||||
"+refs/heads/*:refs/heads/*",
|
|
||||||
"+refs/tags/*:refs/tags/*",
|
|
||||||
":refs/heads/feature",
|
|
||||||
":refs/tags/v1",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.expect("push with deletions");
|
|
||||||
|
|
||||||
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
|
||||||
assert!(refs.contains("refs/heads/main"));
|
|
||||||
assert!(!refs.contains("refs/heads/feature"));
|
|
||||||
assert!(!refs.contains("refs/tags/v1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_refs_brings_server_only_branches_into_local() {
|
|
||||||
let server = tempfile::tempdir().unwrap();
|
|
||||||
bare_server(server.path(), "npub1test", "my-repo");
|
|
||||||
|
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
let dir = dir.path();
|
|
||||||
git_run(dir, &["branch", "feature"]);
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", server.path().display());
|
|
||||||
Repo::open(dir)
|
|
||||||
.expect("open")
|
|
||||||
.push_all(&base_url, "npub1test", "my-repo")
|
|
||||||
.expect("push");
|
|
||||||
let feature = git_in(dir, &["rev-parse", "refs/heads/feature"]).expect("feature oid");
|
|
||||||
|
|
||||||
// A repository that never had the branch fetches it by name.
|
|
||||||
let (other_dir, _other_repo) = fixture(&[("unrelated.txt", b"x")]);
|
|
||||||
let other = Repo::open(other_dir.path()).expect("open other");
|
|
||||||
let server_url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
|
||||||
other
|
|
||||||
.fetch_refs(&[&server_url], "+refs/heads/feature:refs/heads/feature")
|
|
||||||
.expect("fetch ref");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
git_in(other_dir.path(), &["rev-parse", "refs/heads/feature"]).expect("fetched feature"),
|
|
||||||
feature
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ref_state_matches_the_git_cli_ref_listing() {
|
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
let dir = dir.path();
|
|
||||||
|
|
||||||
git_run(dir, &["branch", "browse-group"]);
|
|
||||||
git_run(dir, &["checkout", "-q", "-b", "new-search"]);
|
|
||||||
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
|
||||||
commit_all(&repo, "search work");
|
|
||||||
git_run(dir, &["checkout", "-q", "-"]);
|
|
||||||
git_run(dir, &["tag", "v1"]);
|
|
||||||
|
|
||||||
let listing = |repo: &Repo| {
|
|
||||||
let mut refs = repo.ref_state().expect("ref state").refs;
|
|
||||||
refs.sort();
|
|
||||||
refs
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loose refs.
|
|
||||||
let loose = listing(&Repo::open(dir).expect("open"));
|
|
||||||
|
|
||||||
// Packed refs.
|
|
||||||
git_run(dir, &["pack-refs", "--all"]);
|
|
||||||
let packed = listing(&Repo::open(dir).expect("open"));
|
|
||||||
|
|
||||||
let output = git_run(
|
|
||||||
dir,
|
|
||||||
&[
|
|
||||||
"for-each-ref",
|
|
||||||
"--format=%(refname) %(objectname)",
|
|
||||||
"refs/heads",
|
|
||||||
"refs/tags",
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let mut cli = String::from_utf8_lossy(&output.stdout)
|
|
||||||
.lines()
|
|
||||||
.map(|line| {
|
|
||||||
let (name, oid) = line.split_once(' ').expect("for-each-ref format");
|
|
||||||
(name.to_owned(), oid.to_owned())
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
cli.sort();
|
|
||||||
|
|
||||||
assert_eq!(loose, cli);
|
|
||||||
assert_eq!(packed, cli);
|
|
||||||
assert!(
|
|
||||||
loose
|
|
||||||
.iter()
|
|
||||||
.any(|(name, _)| name == "refs/heads/new-search")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
loose
|
|
||||||
.iter()
|
|
||||||
.any(|(name, _)| name == "refs/heads/browse-group")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repo_ref_state_lists_branches_tags_and_head() {
|
|
||||||
let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
let workdir = repo.workdir().expect("workdir").to_path_buf();
|
|
||||||
|
|
||||||
let state = repo.ref_state().expect("refs");
|
|
||||||
|
|
||||||
let branch = repo.current_branch().expect("on a branch");
|
|
||||||
assert_eq!(state.head.as_deref(), Some(branch.as_str()));
|
|
||||||
assert_eq!(state.refs.len(), 1);
|
|
||||||
assert_eq!(state.refs[0].0, format!("refs/heads/{branch}"));
|
|
||||||
assert_eq!(state.refs[0].1.len(), 40);
|
|
||||||
|
|
||||||
git_run(&workdir, &["branch", "feature"]);
|
|
||||||
git_run(&workdir, &["tag", "v1.0"]);
|
|
||||||
|
|
||||||
let state = repo.ref_state().expect("refs");
|
|
||||||
let mut expected: Vec<String> = vec![
|
|
||||||
format!("refs/heads/{branch}"),
|
|
||||||
"refs/heads/feature".to_owned(),
|
|
||||||
"refs/tags/v1.0".to_owned(),
|
|
||||||
];
|
|
||||||
expected.sort();
|
|
||||||
assert_eq!(
|
|
||||||
state
|
|
||||||
.refs
|
|
||||||
.iter()
|
|
||||||
.map(|(name, _)| name.clone())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
expected
|
|
||||||
);
|
|
||||||
|
|
||||||
git_run(&workdir, &["checkout", "--detach"]);
|
|
||||||
let state = repo.ref_state().expect("refs");
|
|
||||||
assert!(state.head.is_none());
|
|
||||||
assert_eq!(state.refs.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a repository fixture with the given files.
|
|
||||||
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
gix::init(&dir).expect("init");
|
|
||||||
|
|
||||||
for (rel, bytes) in files {
|
|
||||||
let path = dir.path().join(rel);
|
|
||||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
|
|
||||||
std::fs::write(&path, bytes).expect("write");
|
|
||||||
}
|
|
||||||
|
|
||||||
let repo = Repo::open(dir.path()).expect("open");
|
|
||||||
(dir, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stages and commits every change in the repository.
|
|
||||||
fn commit_all(repo: &Repo, message: &str) {
|
|
||||||
git_run(repo.workdir().expect("workdir"), &["add", "-A"]);
|
|
||||||
git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_base_finds_the_fork_point_and_reports_unrelated_history() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("repo");
|
|
||||||
let initial = Repo::init(&path, "My Repo", "desc").expect("init");
|
|
||||||
let repo = Repo::open(&path).expect("open");
|
|
||||||
|
|
||||||
git_run(&path, &["checkout", "-b", "feature"]);
|
|
||||||
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
|
|
||||||
commit_all(&repo, "feature commit");
|
|
||||||
git_run(&path, &["checkout", "main"]);
|
|
||||||
std::fs::write(path.join("main.txt"), "main\n").expect("write");
|
|
||||||
commit_all(&repo, "mainline commit");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
repo.merge_base("feature", "main")
|
|
||||||
.expect("merge base")
|
|
||||||
.as_deref(),
|
|
||||||
Some(initial.as_str())
|
|
||||||
);
|
|
||||||
|
|
||||||
git_run(&path, &["checkout", "--orphan", "orphan"]);
|
|
||||||
std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write");
|
|
||||||
commit_all(&repo, "orphan commit");
|
|
||||||
assert_eq!(repo.merge_base("orphan", "main").expect("ok"), None);
|
|
||||||
|
|
||||||
assert!(repo.merge_base("orphan", "no-such-ref").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_patch_series_splits_real_multi_commit_mboxes() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("repo");
|
|
||||||
let initial = Repo::init(&path, "My Repo", "desc").expect("init");
|
|
||||||
let repo = Repo::open(&path).expect("open");
|
|
||||||
|
|
||||||
git_run(&path, &["checkout", "-b", "feature"]);
|
|
||||||
std::fs::write(path.join("one.txt"), "one\n").expect("write");
|
|
||||||
commit_all(&repo, "first commit");
|
|
||||||
std::fs::write(path.join("two.txt"), "two\n").expect("write");
|
|
||||||
commit_all(&repo, "second commit");
|
|
||||||
|
|
||||||
let series = repo
|
|
||||||
.format_patch_between(&initial, "feature")
|
|
||||||
.expect("series");
|
|
||||||
let parts = PatchParser::split_patch_series(&series);
|
|
||||||
|
|
||||||
assert_eq!(parts.len(), 2);
|
|
||||||
assert!(parts[0].contains("Subject: [PATCH 1/2] first commit"));
|
|
||||||
assert!(parts[1].contains("Subject: [PATCH 2/2] second commit"));
|
|
||||||
let first = parts[0].lines().next().expect("first header");
|
|
||||||
let second = parts[1].lines().next().expect("second header");
|
|
||||||
assert!(first.starts_with("From ") && first.len() >= 45);
|
|
||||||
assert_ne!(first, second);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn head_commit_and_commits_since_track_applied_commits() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let path = dir.path().join("repo");
|
|
||||||
let initial = Repo::init(&path, "My Repo", "desc").expect("init");
|
|
||||||
let repo = Repo::open(&path).expect("open");
|
|
||||||
|
|
||||||
assert_eq!(repo.head().as_deref(), Some(initial.as_str()));
|
|
||||||
assert_eq!(
|
|
||||||
repo.commits_since(None).expect("commits"),
|
|
||||||
vec![initial.clone()]
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::write(path.join("one.txt"), "one\n").expect("write");
|
|
||||||
commit_all(&repo, "first commit");
|
|
||||||
let first = repo.head().expect("on a branch");
|
|
||||||
|
|
||||||
std::fs::write(path.join("two.txt"), "two\n").expect("write");
|
|
||||||
commit_all(&repo, "second commit");
|
|
||||||
let second = repo.head().expect("on a branch");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
repo.commits_since(Some(&initial)).expect("commits"),
|
|
||||||
vec![first.clone(), second.clone()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
repo.commits_since(Some(&first)).expect("commits"),
|
|
||||||
vec![second]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn working_copy_cloned_from_the_mirror_matches_head_and_origin() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
let mirror = dir.path().join("mirror");
|
|
||||||
let commit = Repo::init(&mirror, "My Repo", "Does things.").expect("init");
|
|
||||||
Repo::open(&mirror)
|
|
||||||
.expect("open")
|
|
||||||
.ensure_origin("https://gitnostr.com/npub1test/my-repo.git")
|
|
||||||
.expect("origin");
|
|
||||||
|
|
||||||
let destination = dir.path().join("folder").join("My_Repo");
|
|
||||||
std::fs::create_dir_all(destination.parent().unwrap()).expect("parent");
|
|
||||||
Repo::clone(&[format!("file://{}", mirror.display())], &destination).expect("clone");
|
|
||||||
Repo::open(&destination)
|
|
||||||
.expect("open")
|
|
||||||
.set_origin("https://gitnostr.com/npub1test/my-repo.git")
|
|
||||||
.expect("set origin");
|
|
||||||
let working = Repo::open(&destination).expect("open");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
working.origin_url().expect("url").as_deref(),
|
|
||||||
Some("https://gitnostr.com/npub1test/my-repo.git")
|
|
||||||
);
|
|
||||||
assert_eq!(working.head().as_deref(), Some(commit.as_str()));
|
|
||||||
assert!(destination.join("README.md").is_file());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
bare_server(dir.path(), "npub1test", "repo");
|
|
||||||
|
|
||||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&work_repo, "initial");
|
|
||||||
let work = work_dir.path();
|
|
||||||
let base_url = format!("file://{}", dir.path().display());
|
|
||||||
work_repo
|
|
||||||
.push_all(&base_url, "npub1test", "repo")
|
|
||||||
.expect("push");
|
|
||||||
|
|
||||||
let mirror = dir.path().join("mirror");
|
|
||||||
git_run(
|
|
||||||
dir.path(),
|
|
||||||
&[
|
|
||||||
"clone",
|
|
||||||
"-q",
|
|
||||||
&format!("{base_url}/npub1test/repo.git"),
|
|
||||||
mirror.to_str().unwrap(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let initial = git_in(&mirror, &["rev-parse", "HEAD"]).expect("initial");
|
|
||||||
let repo = Repo::open(&mirror).expect("open");
|
|
||||||
|
|
||||||
std::fs::write(work.join("new.txt"), b"new\n").expect("write");
|
|
||||||
commit_all(&work_repo, "new commit");
|
|
||||||
work_repo
|
|
||||||
.push_all(&base_url, "npub1test", "repo")
|
|
||||||
.expect("push");
|
|
||||||
git_run(&mirror, &["fetch", "origin"]);
|
|
||||||
let remote = git_in(&mirror, &["rev-parse", "refs/remotes/origin/main"]).expect("remote");
|
|
||||||
assert_eq!(
|
|
||||||
git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"),
|
|
||||||
initial
|
|
||||||
);
|
|
||||||
assert_ne!(remote, initial);
|
|
||||||
|
|
||||||
assert!(repo.fast_forward_branches().expect("ff"));
|
|
||||||
assert_eq!(
|
|
||||||
git_in(&mirror, &["rev-parse", "HEAD"]).expect("local"),
|
|
||||||
remote
|
|
||||||
);
|
|
||||||
assert!(mirror.join("new.txt").is_file());
|
|
||||||
assert!(!repo.fast_forward_branches().expect("idle"));
|
|
||||||
|
|
||||||
git_run(&mirror, &["checkout", "-b", "wip"]);
|
|
||||||
std::fs::write(mirror.join("wip.txt"), b"wip\n").expect("write");
|
|
||||||
commit_all(&repo, "local wip");
|
|
||||||
let wip = git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip");
|
|
||||||
assert!(!repo.fast_forward_branches().expect("wip skipped"));
|
|
||||||
assert_eq!(
|
|
||||||
git_in(&mirror, &["rev-parse", "HEAD"]).expect("wip kept"),
|
|
||||||
wip
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
|
||||||
|
|
||||||
let base_server = bare_server(dir.path(), "npub1base", "base");
|
|
||||||
|
|
||||||
let (upstream_dir, upstream_repo) = fixture(&[("a.txt", b"one")]);
|
|
||||||
commit_all(&upstream_repo, "initial");
|
|
||||||
let upstream_path = upstream_dir.path();
|
|
||||||
let initial = git_in(upstream_path, &["rev-parse", "HEAD"]).expect("initial");
|
|
||||||
upstream_repo
|
|
||||||
.push_all(
|
|
||||||
&format!("file://{}", dir.path().display()),
|
|
||||||
"npub1base",
|
|
||||||
"base",
|
|
||||||
)
|
|
||||||
.expect("push");
|
|
||||||
|
|
||||||
let base_url = format!("file://{}", base_server.display());
|
|
||||||
let mirror = dir.path().join("mirror");
|
|
||||||
git_run(
|
|
||||||
dir.path(),
|
|
||||||
&["clone", "-q", &base_url, mirror.to_str().unwrap()],
|
|
||||||
);
|
|
||||||
let mirror_repo = Repo::open(&mirror).expect("open");
|
|
||||||
|
|
||||||
let fork_work = dir.path().join("fork-work");
|
|
||||||
git_run(
|
|
||||||
dir.path(),
|
|
||||||
&["clone", "-q", &base_url, fork_work.to_str().unwrap()],
|
|
||||||
);
|
|
||||||
git_run(&fork_work, &["checkout", "-b", "feature"]);
|
|
||||||
std::fs::write(fork_work.join("feature.txt"), "feature\n").expect("write");
|
|
||||||
commit_all(&Repo::open(&fork_work).expect("open"), "feature commit");
|
|
||||||
let tip = git_in(&fork_work, &["rev-parse", "HEAD"]).expect("tip");
|
|
||||||
|
|
||||||
let fork_server = bare_server(dir.path(), "npub1fork", "fork");
|
|
||||||
Repo::open(&fork_work)
|
|
||||||
.expect("open")
|
|
||||||
.push_ref(
|
|
||||||
&format!("file://{}", fork_server.display()),
|
|
||||||
&tip,
|
|
||||||
"refs/heads/feature",
|
|
||||||
)
|
|
||||||
.expect("push");
|
|
||||||
|
|
||||||
let dead = format!("file://{}/missing.git", dir.path().display());
|
|
||||||
mirror_repo
|
|
||||||
.fetch_refs(
|
|
||||||
&[dead, format!("file://{}", fork_server.display())],
|
|
||||||
"+refs/heads/*:refs/fork/npub1fork/fork/*",
|
|
||||||
)
|
|
||||||
.expect("fetch");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
mirror_repo
|
|
||||||
.refs_with_prefix("refs/fork/npub1fork/fork")
|
|
||||||
.expect("refs"),
|
|
||||||
vec!["refs/fork/npub1fork/fork/feature"]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
mirror_repo
|
|
||||||
.refs_with_prefix("refs/heads/fork")
|
|
||||||
.expect("refs"),
|
|
||||||
Vec::<String>::new()
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
mirror_repo
|
|
||||||
.merge_base(
|
|
||||||
"refs/remotes/origin/main",
|
|
||||||
"refs/fork/npub1fork/fork/feature",
|
|
||||||
)
|
|
||||||
.expect("merge base")
|
|
||||||
.as_deref(),
|
|
||||||
Some(initial.as_str())
|
|
||||||
);
|
|
||||||
let patch = mirror_repo
|
|
||||||
.format_patch_between(&initial, "refs/fork/npub1fork/fork/feature")
|
|
||||||
.expect("patch");
|
|
||||||
assert!(patch.contains("Subject: [PATCH] feature commit"));
|
|
||||||
assert!(patch.contains("feature.txt"));
|
|
||||||
|
|
||||||
mirror_repo
|
|
||||||
.delete_refs_with_prefix("refs/fork/npub1fork/fork")
|
|
||||||
.expect("delete");
|
|
||||||
assert_eq!(
|
|
||||||
mirror_repo
|
|
||||||
.refs_with_prefix("refs/fork/npub1fork/fork")
|
|
||||||
.expect("refs"),
|
|
||||||
Vec::<String>::new()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs `git` in `dir`, failing the test on a non-zero exit.
|
|
||||||
fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
|
||||||
let output = 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)
|
|
||||||
.output()
|
|
||||||
.expect("spawn git");
|
|
||||||
assert!(output.status.success(), "git {args:?} failed");
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_git_repos_reports_the_outermost_repository() {
|
|
||||||
let root = tempfile::tempdir().expect("tempdir");
|
|
||||||
let repo_dir = root.path().join("project").join("sub");
|
|
||||||
std::fs::create_dir_all(&repo_dir).expect("mkdir");
|
|
||||||
git_run(&repo_dir, &["init", "-q"]);
|
|
||||||
|
|
||||||
let plain_dir = root.path().join("plain").join("deep");
|
|
||||||
std::fs::create_dir_all(&plain_dir).expect("mkdir");
|
|
||||||
|
|
||||||
let repos = find_git_repos(root.path());
|
|
||||||
|
|
||||||
assert_eq!(repos.len(), 1);
|
|
||||||
assert_eq!(repos[0].path, repo_dir.canonicalize().expect("canonical"));
|
|
||||||
assert!(repos[0].nip34.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_git_repos_skips_repositories_nested_in_repositories() {
|
|
||||||
let root = tempfile::tempdir().expect("tempdir");
|
|
||||||
let outer = root.path().join("outer");
|
|
||||||
std::fs::create_dir_all(&outer).expect("mkdir");
|
|
||||||
git_run(&outer, &["init", "-q"]);
|
|
||||||
|
|
||||||
let nested = outer.join("nested");
|
|
||||||
std::fs::create_dir_all(&nested).expect("mkdir");
|
|
||||||
git_run(&nested, &["init", "-q"]);
|
|
||||||
|
|
||||||
let repos = find_git_repos(root.path());
|
|
||||||
|
|
||||||
assert_eq!(repos.len(), 1);
|
|
||||||
assert_eq!(repos[0].path, outer.canonicalize().expect("canonical"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_git_repos_ignores_a_repository_above_the_scan_root() {
|
|
||||||
let outer = tempfile::tempdir().expect("tempdir");
|
|
||||||
git_run(outer.path(), &["init", "-q"]);
|
|
||||||
|
|
||||||
let root = outer.path().join("scan");
|
|
||||||
std::fs::create_dir_all(&root).expect("mkdir");
|
|
||||||
|
|
||||||
assert!(find_git_repos(&root).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates an empty bare server repository for push tests.
|
|
||||||
fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf {
|
|
||||||
let repo = base.join(owner).join(format!("{name}.git"));
|
|
||||||
std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir");
|
|
||||||
git_run(
|
|
||||||
base,
|
|
||||||
&["init", "--bare", "-q", repo.to_str().expect("utf8 path")],
|
|
||||||
);
|
|
||||||
repo
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn patch_commits_lists_every_patch_in_order() {
|
|
||||||
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Alice <alice@example.com>
|
|
||||||
Date: Tue, 1 Aug 2023 10:00:00 +0200
|
|
||||||
Subject: [PATCH 1/2] first
|
|
||||||
|
|
||||||
body one
|
|
||||||
---
|
|
||||||
a.txt | 1 +
|
|
||||||
1 file changed, 1 insertion(+)
|
|
||||||
|
|
||||||
diff --git a/a.txt b/a.txt
|
|
||||||
@@ -1 +1,2 @@
|
|
||||||
a
|
|
||||||
+b
|
|
||||||
|
|
||||||
From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Bob <bob@example.com>
|
|
||||||
Date: Wed, 2 Aug 2023 11:30:00 +0000
|
|
||||||
Subject: [PATCH 2/2] second
|
|
||||||
|
|
||||||
body two
|
|
||||||
---
|
|
||||||
b.txt | 1 +
|
|
||||||
1 file changed, 1 insertion(+)
|
|
||||||
|
|
||||||
diff --git a/b.txt b/b.txt
|
|
||||||
@@ -1 +1,2 @@
|
|
||||||
x
|
|
||||||
+y
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let commits = PatchParser::patch_commits(patch);
|
|
||||||
assert_eq!(commits.len(), 2);
|
|
||||||
|
|
||||||
assert_eq!(commits[0].id, "1111111111111111111111111111111111111111");
|
|
||||||
assert_eq!(commits[0].summary, "first");
|
|
||||||
assert_eq!(commits[0].author, "Alice");
|
|
||||||
assert_eq!(commits[0].time, 1690876800);
|
|
||||||
|
|
||||||
assert_eq!(commits[1].id, "2222222222222222222222222222222222222222");
|
|
||||||
assert_eq!(commits[1].summary, "second");
|
|
||||||
assert_eq!(commits[1].author, "Bob");
|
|
||||||
assert_eq!(commits[1].time, 1690975800);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_real_format_patch_output() {
|
|
||||||
let (dir, repo) = fixture(&[
|
|
||||||
("src/main.rs", b"fn main() {\n println!(\"one\");\n}\n"),
|
|
||||||
("my file.txt", b"hello\n"),
|
|
||||||
("\u{8bf4}\u{660e}.md", "# \u{8bf4}\u{660e}\n".as_bytes()),
|
|
||||||
("img.png", b"\x89PNG\r\n\x1a\n\x00binary"),
|
|
||||||
]);
|
|
||||||
commit_all(&repo, "initial");
|
|
||||||
|
|
||||||
std::fs::write(
|
|
||||||
dir.path().join("src/main.rs"),
|
|
||||||
b"fn main() {\n println!(\"two\");\n println!(\"three\");\n}\n",
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
std::fs::write(dir.path().join("my file.txt"), b"hello world\n").expect("write");
|
|
||||||
std::fs::write(
|
|
||||||
dir.path().join("\u{8bf4}\u{660e}.md"),
|
|
||||||
"# \u{8bf4}\u{660e}\nupdated\n",
|
|
||||||
)
|
|
||||||
.expect("write");
|
|
||||||
std::fs::remove_file(dir.path().join("img.png")).expect("remove");
|
|
||||||
std::fs::write(dir.path().join("new file.md"), b"# new\n").expect("write");
|
|
||||||
commit_all(&repo, "changes");
|
|
||||||
|
|
||||||
let output = git_run(dir.path(), &["format-patch", "-1", "--stdout"]);
|
|
||||||
let patch = String::from_utf8(output.stdout).expect("patch is utf-8");
|
|
||||||
|
|
||||||
let diff = PatchParser::patch_diffs(&patch).expect("parse real format-patch output");
|
|
||||||
|
|
||||||
let by_path = |path: &str| {
|
|
||||||
diff.files
|
|
||||||
.iter()
|
|
||||||
.find(|file| file.path == path)
|
|
||||||
.unwrap_or_else(|| panic!("missing file {path:?}"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let file = by_path("my file.txt");
|
|
||||||
assert_eq!(file.status, DiffStatus::Modified);
|
|
||||||
assert_eq!(file.insertions, 1);
|
|
||||||
|
|
||||||
let file = by_path("\u{8bf4}\u{660e}.md");
|
|
||||||
assert_eq!(file.status, DiffStatus::Modified);
|
|
||||||
assert_eq!(file.insertions, 1);
|
|
||||||
|
|
||||||
let file = by_path("src/main.rs");
|
|
||||||
assert_eq!(file.status, DiffStatus::Modified);
|
|
||||||
assert_eq!(file.insertions, 2);
|
|
||||||
assert_eq!(file.deletions, 1);
|
|
||||||
assert!(!file.hunks.is_empty());
|
|
||||||
|
|
||||||
let file = by_path("new file.md");
|
|
||||||
assert_eq!(file.status, DiffStatus::Added);
|
|
||||||
assert_eq!(file.insertions, 1);
|
|
||||||
|
|
||||||
let file = by_path("img.png");
|
|
||||||
assert_eq!(file.status, DiffStatus::Deleted);
|
|
||||||
assert!(file.binary);
|
|
||||||
assert!(file.hunks.is_empty());
|
|
||||||
assert_eq!(file.insertions, 0);
|
|
||||||
assert_eq!(file.deletions, 0);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use crate::history::FileCommit;
|
|
||||||
|
|
||||||
pub struct WorktreeSnapshot {
|
|
||||||
pub entries: Vec<PathBuf>,
|
|
||||||
pub readme_path: Option<PathBuf>,
|
|
||||||
pub readme: Option<Vec<u8>>,
|
|
||||||
pub current_branch: Option<String>,
|
|
||||||
pub head_commit: Option<FileCommit>,
|
|
||||||
pub branches: Vec<String>,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorktreeSnapshot {
|
|
||||||
/// Creates a snapshot of the worktree contents and ref state.
|
|
||||||
pub(crate) fn new(
|
|
||||||
entries: Vec<PathBuf>,
|
|
||||||
readme_path: Option<PathBuf>,
|
|
||||||
readme: Option<Vec<u8>>,
|
|
||||||
current_branch: Option<String>,
|
|
||||||
head_commit: Option<FileCommit>,
|
|
||||||
branches: Vec<String>,
|
|
||||||
tags: Vec<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
entries,
|
|
||||||
readme_path,
|
|
||||||
readme,
|
|
||||||
current_branch,
|
|
||||||
head_commit,
|
|
||||||
branches,
|
|
||||||
tags,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,12 @@ publish.workspace = true
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
nostr-gossip-memory.workspace = true
|
nostr-gossip-memory.workspace = true
|
||||||
nostr-lmdb.workspace = true
|
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
webbrowser.workspace = true
|
webbrowser.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
nostr-memory.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
nostr-lmdb.workspace = true
|
||||||
|
|||||||
@@ -1,29 +1,34 @@
|
|||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use nostr_gossip_memory::prelude::*;
|
use nostr_gossip_memory::prelude::*;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use nostr_lmdb::prelude::*;
|
use nostr_lmdb::prelude::*;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use nostr_memory::prelude::*;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::signer::UniversalSigner;
|
use crate::signer::UniversalSigner;
|
||||||
|
|
||||||
pub struct NostrBackend {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub client: Client,
|
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
||||||
pub signer: UniversalSigner,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NostrBackend {
|
|
||||||
pub async fn open(db_path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let db_path = db_path.as_ref();
|
|
||||||
let signer = UniversalSigner::new(Keys::generate());
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
let database = NostrLmdb::open(db_path)
|
let database = NostrLmdb::open(db_path)
|
||||||
.await
|
.await
|
||||||
.context("failed to open nostr database")?;
|
.context("failed to open nostr database")?;
|
||||||
Ok(Self::with_database(signer, database))
|
Ok(with_database(signer, database))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_database<D>(signer: UniversalSigner, database: D) -> Self
|
/// In-memory database on wasm, LMDB is unavailable there.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn new_backend() -> Result<(Client, UniversalSigner)> {
|
||||||
|
let signer = UniversalSigner::new(Keys::generate());
|
||||||
|
Ok(with_database(signer, MemoryDatabase::unbounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_database<D>(signer: UniversalSigner, database: D) -> (Client, UniversalSigner)
|
||||||
where
|
where
|
||||||
D: IntoNostrDatabase,
|
D: IntoNostrDatabase,
|
||||||
{
|
{
|
||||||
@@ -42,6 +47,5 @@ impl NostrBackend {
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
Self { client, signer }
|
(client, signer)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ mod backend;
|
|||||||
mod signer;
|
mod signer;
|
||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
pub use backend::NostrBackend;
|
pub use backend::new_backend;
|
||||||
pub use signer::{SignedAuthUrlHandler, UniversalSigner};
|
pub use signer::{SignedAuthUrlHandler, UniversalSigner};
|
||||||
pub use update::Update;
|
pub use update::Update;
|
||||||
|
|||||||
@@ -5,9 +5,33 @@ use std::pin::Pin;
|
|||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use nostr_connect::client::AuthUrlHandler;
|
use nostr_connect::client::AuthUrlHandler;
|
||||||
use nostr_sdk::error::Error as SignerError;
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
|
||||||
|
|
||||||
|
impl fmt::Display for UniversalSignerError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for UniversalSignerError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
Some(&*self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UniversalSignerError {
|
||||||
|
pub fn new<E>(err: E) -> Self
|
||||||
|
where
|
||||||
|
E: Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
UniversalSignerError(Box::new(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A type-erased signer whose inner signer can be swapped in-place.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UniversalSigner {
|
pub struct UniversalSigner {
|
||||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||||
@@ -26,6 +50,7 @@ impl UniversalSigner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Swap the inner signer in-place. All clones see the new signer.
|
||||||
pub fn swap_inner<T>(&self, new_signer: T)
|
pub fn swap_inner<T>(&self, new_signer: T)
|
||||||
where
|
where
|
||||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||||
@@ -40,21 +65,21 @@ impl UniversalSigner {
|
|||||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
|
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||||
fn nip44_decrypt_async<'a>(
|
fn nip44_decrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -69,22 +94,22 @@ where
|
|||||||
{
|
{
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncGetPublicKey::get_public_key_async(&self.0)
|
AsyncGetPublicKey::get_public_key_async(&self.0)
|
||||||
.await
|
.await
|
||||||
.map_err(SignerError::other)
|
.map_err(UniversalSignerError::new)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
unsigned: UnsignedEvent,
|
unsigned: UnsignedEvent,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
|
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
AsyncSignEvent::sign_event_async(&self.0, unsigned)
|
||||||
.await
|
.await
|
||||||
.map_err(SignerError::other)
|
.map_err(UniversalSignerError::new)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,11 +117,11 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
|
||||||
.await
|
.await
|
||||||
.map_err(SignerError::other)
|
.map_err(UniversalSignerError::new)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,17 +129,17 @@ where
|
|||||||
&'a self,
|
&'a self,
|
||||||
public_key: &'a PublicKey,
|
public_key: &'a PublicKey,
|
||||||
payload: &'a str,
|
payload: &'a str,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(SignerError::other)
|
.map_err(UniversalSignerError::new)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncGetPublicKey for UniversalSigner {
|
impl AsyncGetPublicKey for UniversalSigner {
|
||||||
type Error = SignerError;
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
fn get_public_key_async(
|
fn get_public_key_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -125,7 +150,7 @@ impl AsyncGetPublicKey for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncSignEvent for UniversalSigner {
|
impl AsyncSignEvent for UniversalSigner {
|
||||||
type Error = SignerError;
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
fn sign_event_async(
|
fn sign_event_async(
|
||||||
&self,
|
&self,
|
||||||
@@ -137,7 +162,7 @@ impl AsyncSignEvent for UniversalSigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncNip44 for UniversalSigner {
|
impl AsyncNip44 for UniversalSigner {
|
||||||
type Error = SignerError;
|
type Error = UniversalSignerError;
|
||||||
|
|
||||||
fn nip44_encrypt_async<'a>(
|
fn nip44_encrypt_async<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -158,6 +183,7 @@ impl AsyncNip44 for UniversalSigner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the NIP-46 auth URL in the default browser.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SignedAuthUrlHandler;
|
pub struct SignedAuthUrlHandler;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
/// A lightweight change notification for the UI.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Update {
|
pub struct Update {
|
||||||
pub kind: Kind,
|
pub kind: Kind,
|
||||||
|
/// First `a` tag value of the event, if any, for example the repository coordinate.
|
||||||
pub coordinate: Option<Coordinate>,
|
pub coordinate: Option<Coordinate>,
|
||||||
pub author: PublicKey,
|
pub author: PublicKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Update {
|
impl Update {
|
||||||
|
/// Build an update from a received event.
|
||||||
pub fn from_event(event: &Event) -> Self {
|
pub fn from_event(event: &Event) -> Self {
|
||||||
let coordinate = event.tags.coordinates().nth(0);
|
let coordinate = event.tags.coordinates().nth(0);
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ nostr-connect.workspace = true
|
|||||||
|
|
||||||
bitcoin_hashes = "1"
|
bitcoin_hashes = "1"
|
||||||
|
|
||||||
gix.workspace = true
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
|
|||||||
+1127
-711
File diff suppressed because it is too large
Load Diff
@@ -1,87 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use nostr_connect::prelude::*;
|
|
||||||
use nostr_sdk::client::SyncSummary;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use signed_core::Filters;
|
|
||||||
|
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
|
||||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
|
||||||
|
|
||||||
pub(crate) async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
|
|
||||||
for url in BOOTSTRAP_RELAYS {
|
|
||||||
client.add_relay(url).and_connect().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for url in INDEXER_RELAYS {
|
|
||||||
client
|
|
||||||
.add_relay(url)
|
|
||||||
.capabilities(RelayCapabilities::DISCOVERY)
|
|
||||||
.and_connect()
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn subscribe_bootstrap_only(
|
|
||||||
client: &Client,
|
|
||||||
filters: Vec<Filter>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
ensure_bootstrap_relays(client).await?;
|
|
||||||
|
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
|
||||||
.timeout(Some(Duration::from_secs(10)));
|
|
||||||
|
|
||||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
|
||||||
.iter()
|
|
||||||
.map(|relay| (*relay, filters.clone()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
client.subscribe(target).close_on(opts).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sync_bootstrap_only(
|
|
||||||
client: &Client,
|
|
||||||
filter: Filter,
|
|
||||||
opts: SyncOptions,
|
|
||||||
) -> Result<SyncSummary, Error> {
|
|
||||||
ensure_bootstrap_relays(client).await?;
|
|
||||||
|
|
||||||
let output = client
|
|
||||||
.sync(filter)
|
|
||||||
.with(BOOTSTRAP_RELAYS)
|
|
||||||
.opts(opts)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(output.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
|
||||||
event
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.filter(|tag| tag.kind() == "g")
|
|
||||||
.filter_map(|tag| tag.content())
|
|
||||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn user_grasp_list_servers(
|
|
||||||
client: &Client,
|
|
||||||
user: PublicKey,
|
|
||||||
) -> Result<Vec<RelayUrl>, Error> {
|
|
||||||
let events: Vec<Event> = client
|
|
||||||
.database()
|
|
||||||
.query(Filters::grasp_list(user).limit(1))
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(events.first().map(grasp_list_servers).unwrap_or_default())
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,35 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use gpui::{App, Global};
|
||||||
use nostr::prelude::Url;
|
use signed_git::GitCache;
|
||||||
use signed_core::RepoAddr;
|
|
||||||
use signed_git::{GitCache, Repo};
|
|
||||||
|
|
||||||
static GIT_CACHE: OnceLock<GitCache> = OnceLock::new();
|
struct GlobalGitStore(GitCache);
|
||||||
|
|
||||||
pub struct Mirrors;
|
impl Global for GlobalGitStore {}
|
||||||
|
|
||||||
impl Mirrors {
|
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
||||||
pub fn install(root: impl Into<PathBuf>) {
|
#[derive(Debug, Clone)]
|
||||||
if GIT_CACHE.set(GitCache::new(root.into())).is_err() {
|
pub struct GitStore(GitCache);
|
||||||
log::warn!("git cache root is already set, keeping the first one");
|
|
||||||
}
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cache() -> &'static GitCache {
|
/// The app-wide clone cache.
|
||||||
GIT_CACHE
|
pub fn global(cx: &App) -> Self {
|
||||||
.get()
|
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||||
.expect("git cache is initialized by signed_state::init")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn root() -> PathBuf {
|
fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
Self::cache().root().to_path_buf()
|
Self(GitCache::new(root.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn path(addr: &RepoAddr) -> PathBuf {
|
/// Underlying clone cache.
|
||||||
Self::cache().repo_path(addr)
|
pub fn cache(&self) -> &GitCache {
|
||||||
}
|
&self.0
|
||||||
|
|
||||||
pub fn open(addr: &RepoAddr) -> Result<Option<Repo>> {
|
|
||||||
Self::cache().open(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure(addr: &RepoAddr, clone_urls: &[Url]) -> Result<Repo> {
|
|
||||||
Self::cache().ensure_clone(addr, clone_urls)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
use anyhow::Error;
|
|
||||||
use gpui::{AppContext, Context, Task};
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use signed_core::{Deletions, Filters, GitEvent, InboxItem, InboxReadState, inbox};
|
|
||||||
|
|
||||||
use crate::backend::Backend;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Inbox {
|
|
||||||
state: InboxReadState,
|
|
||||||
loaded: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Inbox {
|
|
||||||
pub fn state(&self) -> &InboxReadState {
|
|
||||||
&self.state
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_loaded(&self) -> bool {
|
|
||||||
self.loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
|
||||||
self.state = InboxReadState::default();
|
|
||||||
self.loaded = false;
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let work = cx.background_spawn(async move { Self::load_state(&client, me).await });
|
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
let loaded = work.await;
|
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
if backend.read(cx).current_user() != Some(me) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match loaded {
|
|
||||||
Ok(Some(state)) => this.state = state,
|
|
||||||
Ok(None) => this.state = InboxReadState::default(),
|
|
||||||
Err(error) => log::warn!("failed to load inbox state: {error}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loaded = true;
|
|
||||||
cx.notify();
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok::<(), Error>(())
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
|
||||||
self.state = InboxReadState::default();
|
|
||||||
self.loaded = false;
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
|
||||||
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 state = self.state.clone();
|
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
|
||||||
if let Err(error) = Self::save_state(&client, me, &state).await {
|
|
||||||
log::warn!("failed to save inbox state: {error}");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn query(
|
|
||||||
client: &Client,
|
|
||||||
me: PublicKey,
|
|
||||||
state: &InboxReadState,
|
|
||||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
|
||||||
let deletion_events = client.database().query(Filters::deletions()).await?;
|
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
|
||||||
|
|
||||||
let (notification_events, mut by_id) =
|
|
||||||
Self::fetch_notifications(client, me, &deletions).await?;
|
|
||||||
|
|
||||||
let mut activity = Vec::new();
|
|
||||||
for event in client
|
|
||||||
.database()
|
|
||||||
.query(Filters::authored_activity(me))
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
if deletions.is_deleted(&event) || !event.is_git_activity() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
|
||||||
activity.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
|
||||||
by_id.get(&id).cloned()
|
|
||||||
});
|
|
||||||
|
|
||||||
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
|
||||||
|
|
||||||
Ok((items, unread_count))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Inbox {
|
|
||||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
|
||||||
format!("signed-inbox-state:{}", me.to_hex())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::ApplicationSpecificData)
|
|
||||||
.identifier(Self::inbox_state_d_tag(me));
|
|
||||||
|
|
||||||
let events = client.database().query(filter).await?;
|
|
||||||
|
|
||||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
match serde_json::from_str(&event.content) {
|
|
||||||
Ok(state) => Ok(Some(state)),
|
|
||||||
Err(error) => {
|
|
||||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signed with a random key: the state is local-only, authorship does not
|
|
||||||
// matter and no key material needs to be kept.
|
|
||||||
async fn save_state(
|
|
||||||
client: &Client,
|
|
||||||
me: PublicKey,
|
|
||||||
state: &InboxReadState,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
|
||||||
.tags([Tag::identifier(Self::inbox_state_d_tag(me))])
|
|
||||||
.finalize(&Keys::generate())?;
|
|
||||||
|
|
||||||
client.database().save_event(&event).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_notifications(
|
|
||||||
client: &Client,
|
|
||||||
me: PublicKey,
|
|
||||||
deletions: &Deletions,
|
|
||||||
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
|
||||||
let mut notifications: Vec<Event> = Vec::new();
|
|
||||||
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
|
||||||
|
|
||||||
for filter in Filters::notifications(me) {
|
|
||||||
for event in client.database().query(filter).await? {
|
|
||||||
if deletions.is_deleted(&event) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if by_id.insert(event.id, event.clone()).is_none() {
|
|
||||||
notifications.push(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pending: Vec<EventId> = notifications
|
|
||||||
.iter()
|
|
||||||
.flat_map(Self::event_references)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
pending.retain(|id| seen.insert(*id));
|
|
||||||
|
|
||||||
if pending.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ancestors = client
|
|
||||||
.database()
|
|
||||||
.query(Filter::new().ids(pending.iter().copied()))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut next = Vec::new();
|
|
||||||
|
|
||||||
for event in ancestors {
|
|
||||||
if deletions.is_deleted(&event) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.extend(Self::event_references(&event).filter(|id| !seen.contains(id)));
|
|
||||||
by_id.entry(event.id).or_insert(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
pending = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((notifications, by_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
|
||||||
event.tags.iter().filter_map(|tag| {
|
|
||||||
if tag.kind() != "e" && tag.kind() != "E" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
tag.content()
|
|
||||||
.and_then(|content| EventId::from_hex(content).ok())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +1,69 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
mod bootstrap;
|
|
||||||
mod checkouts;
|
mod checkouts;
|
||||||
mod git_store;
|
mod git_store;
|
||||||
mod inbox;
|
|
||||||
mod local_repos;
|
mod local_repos;
|
||||||
mod patch_series;
|
mod profile;
|
||||||
mod push;
|
|
||||||
mod refresh;
|
mod refresh;
|
||||||
mod repo;
|
mod repo;
|
||||||
mod repos;
|
mod repo_list;
|
||||||
mod state;
|
|
||||||
mod sync_status;
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub use backend::{Backend, BackendEvent};
|
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||||
pub use bootstrap::{sync_bootstrap_only, user_grasp_list_servers};
|
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||||
pub use checkouts::{CheckoutStatus, CheckoutsStore};
|
pub use git_store::GitStore;
|
||||||
pub use git_store::Mirrors;
|
use gpui::{App, AppContext, Entity};
|
||||||
use gpui::{App, AppContext};
|
pub use local_repos::LocalReposStore;
|
||||||
pub use inbox::Inbox;
|
|
||||||
pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
|
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use push::{GraspServer, PushOutcome};
|
pub use profile::{Profile, ProfileStore};
|
||||||
pub use refresh::{RefreshGate, RefreshRequest};
|
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repos::RepoListStore;
|
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
||||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
use signed_nostr::new_backend;
|
||||||
use signed_nostr::NostrBackend;
|
|
||||||
pub use state::{GitPushMode, GitPushPolicy, GitRef, PushStep, PushStepStatus, SyncState};
|
|
||||||
pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore};
|
|
||||||
|
|
||||||
|
/// Initialize the backend and stores, and install them as globals.
|
||||||
|
/// Call once at startup, before opening any window that uses the stores.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn init(
|
pub fn init(
|
||||||
db_path: impl AsRef<Path>,
|
db_path: impl AsRef<Path>,
|
||||||
repos_root: impl Into<PathBuf>,
|
repos_root: impl Into<PathBuf>,
|
||||||
scan_paths: Vec<PathBuf>,
|
scan_paths: Vec<PathBuf>,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) -> Entity<Backend> {
|
||||||
// rustls uses the `aws_lc_rs` provider by default.
|
// rustls uses the `aws_lc_rs` provider by default.
|
||||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.ok();
|
||||||
|
|
||||||
let backend = cx
|
let (client, signer) = cx.foreground_executor().block_on(async move {
|
||||||
.foreground_executor()
|
let path = db_path.as_ref().to_path_buf();
|
||||||
.block_on(async move { NostrBackend::open(db_path.as_ref()).await });
|
new_backend(path)
|
||||||
let backend = backend.expect("failed to initialize nostr backend");
|
.await
|
||||||
let (client, signer) = (backend.client, backend.signer);
|
.expect("failed to initialize nostr backend")
|
||||||
|
});
|
||||||
|
|
||||||
Mirrors::install(repos_root);
|
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||||
|
Backend::set_global(entity.clone(), cx);
|
||||||
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
// The local git clone cache, the grasp mirrors.
|
||||||
|
GitStore::set_global(repos_root, cx);
|
||||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||||
SyncStatusStore::set_global(cx.new(SyncStatusStore::new), cx);
|
|
||||||
|
entity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the backend with an in-memory database on wasm.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||||
|
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
|
||||||
|
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||||
|
Backend::set_global(entity.clone(), cx);
|
||||||
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), 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);
|
||||||
|
entity
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_git::find_git_repos;
|
||||||
use signed_git::{LocalRepo, Nip34Binding, find_git_repos};
|
|
||||||
|
|
||||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||||
|
|
||||||
impl Global for GlobalLocalReposStore {}
|
impl Global for GlobalLocalReposStore {}
|
||||||
|
|
||||||
|
/// Store of the git repositories discovered under a set of scan paths.
|
||||||
pub struct LocalReposStore {
|
pub struct LocalReposStore {
|
||||||
|
/// The directories being scanned.
|
||||||
pub roots: Arc<Vec<PathBuf>>,
|
pub roots: Arc<Vec<PathBuf>>,
|
||||||
pub repos: Arc<Vec<LocalRepo>>,
|
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
||||||
|
pub repos: Arc<Vec<PathBuf>>,
|
||||||
|
/// A scan is currently running.
|
||||||
pub scanning: bool,
|
pub scanning: bool,
|
||||||
|
/// A scan was requested while one was already running.
|
||||||
scan_dirty: bool,
|
scan_dirty: bool,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalReposStore {
|
impl LocalReposStore {
|
||||||
|
/// Retrieve the global local-repositories store.
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalLocalReposStore>().0.clone()
|
cx.global::<GlobalLocalReposStore>().0.clone()
|
||||||
}
|
}
|
||||||
@@ -27,39 +32,37 @@ impl LocalReposStore {
|
|||||||
cx.set_global(GlobalLocalReposStore(entity));
|
cx.set_global(GlobalLocalReposStore(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a store scanning `roots` right away.
|
||||||
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||||
let weak = cx.entity().downgrade();
|
let mut store = Self {
|
||||||
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),
|
roots: Arc::new(roots),
|
||||||
repos: Arc::new(Vec::new()),
|
repos: Arc::new(Vec::new()),
|
||||||
scanning: false,
|
scanning: false,
|
||||||
scan_dirty: false,
|
scan_dirty: false,
|
||||||
}
|
tasks: Vec::new(),
|
||||||
|
};
|
||||||
|
store.rescan(cx);
|
||||||
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forget a repository that has just been published to NIP-34.
|
||||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||||
self.repos = Arc::new(
|
self.repos = Arc::new(
|
||||||
self.repos
|
self.repos
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|repo| repo.path.as_path() != path)
|
.filter(|repo| repo.as_path() != path)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-run the scan.
|
||||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.scanning {
|
if self.scanning {
|
||||||
self.scan_dirty = true;
|
self.scan_dirty = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.roots.is_empty() {
|
if self.roots.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,18 +71,17 @@ impl LocalReposStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let roots = self.roots.clone();
|
let roots = self.roots.clone();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let mut repos = Vec::new();
|
let mut repos = Vec::new();
|
||||||
for root in roots.iter() {
|
for root in roots.iter() {
|
||||||
repos.extend(find_git_repos(root));
|
repos.extend(find_git_repos(root));
|
||||||
}
|
}
|
||||||
repos.sort_by(|a, b| a.path.cmp(&b.path));
|
repos.sort();
|
||||||
repos.dedup_by(|a, b| a.path == b.path);
|
repos.dedup();
|
||||||
repos
|
repos
|
||||||
});
|
});
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let repos = work.await;
|
let repos = work.await;
|
||||||
let again = this.update(cx, |this, cx| {
|
let again = this.update(cx, |this, cx| {
|
||||||
this.repos = Arc::new(repos);
|
this.repos = Arc::new(repos);
|
||||||
@@ -91,139 +93,12 @@ impl LocalReposStore {
|
|||||||
dirty
|
dirty
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||||
if again {
|
if again {
|
||||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
}));
|
||||||
|
|
||||||
task.detach();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
|
|
||||||
let binding = repo.nip34.as_ref()?;
|
|
||||||
let owner = binding.owner?;
|
|
||||||
let identifier = binding.identifier.as_deref()?;
|
|
||||||
|
|
||||||
Some(RepoAddr::new(owner, identifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct ResolvedLocalRepo {
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub nip34: Option<Nip34Binding>,
|
|
||||||
pub announcement: Option<Announcement>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResolvedLocalRepo {
|
|
||||||
pub fn name(&self) -> SharedString {
|
|
||||||
self.path
|
|
||||||
.file_name()
|
|
||||||
.map(|name| SharedString::from(name.to_string_lossy().into_owned()))
|
|
||||||
.unwrap_or_else(|| SharedString::from("Untitled"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalReposStore {
|
|
||||||
pub fn resolve(
|
|
||||||
repos: &[LocalRepo],
|
|
||||||
known: &[Announcement],
|
|
||||||
own: &[Announcement],
|
|
||||||
) -> Vec<ResolvedLocalRepo> {
|
|
||||||
let shown: HashSet<RepoAddr> = own.iter().map(Announcement::addr).collect();
|
|
||||||
|
|
||||||
repos
|
|
||||||
.iter()
|
|
||||||
.filter_map(|repo| {
|
|
||||||
let addr = local_repo_addr(repo);
|
|
||||||
|
|
||||||
if let Some(addr) = &addr
|
|
||||||
&& shown.contains(addr)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let announcement = addr
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|addr| {
|
|
||||||
known
|
|
||||||
.iter()
|
|
||||||
.find(|announcement| announcement.addr() == *addr)
|
|
||||||
})
|
|
||||||
.cloned();
|
|
||||||
|
|
||||||
Some(ResolvedLocalRepo {
|
|
||||||
path: repo.path.clone(),
|
|
||||||
nip34: repo.nip34.clone(),
|
|
||||||
announcement,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use nostr::prelude::*;
|
|
||||||
use signed_git::{GraspSignals, Nip34Kind};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001";
|
|
||||||
const OTHER_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000002";
|
|
||||||
|
|
||||||
fn announcement(secret: &str, id: &str) -> Announcement {
|
|
||||||
let keys = Keys::new(SecretKey::from_hex(secret).expect("secret"));
|
|
||||||
let tag = Tag::parse(vec!["d", id]).expect("tag");
|
|
||||||
let event = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
|
||||||
.tags(vec![tag])
|
|
||||||
.finalize(&keys)
|
|
||||||
.expect("signed");
|
|
||||||
|
|
||||||
Announcement::from_event(&event).expect("parsed")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn owner(secret: &str) -> PublicKey {
|
|
||||||
Keys::new(SecretKey::from_hex(secret).expect("secret")).public_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bound(secret: &str, id: &str) -> LocalRepo {
|
|
||||||
let binding = Nip34Binding {
|
|
||||||
kind: Nip34Kind::Initialized,
|
|
||||||
signals: GraspSignals {
|
|
||||||
nip34_json: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
owner: Some(owner(secret)),
|
|
||||||
identifier: Some(id.to_owned()),
|
|
||||||
grasp_urls: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
LocalRepo {
|
|
||||||
path: PathBuf::from(id),
|
|
||||||
nip34: Some(binding),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_users_own_announcement_is_dropped() {
|
|
||||||
let own = announcement(KEY, "mine");
|
|
||||||
let repo = bound(KEY, "mine");
|
|
||||||
|
|
||||||
let own = std::slice::from_ref(&own);
|
|
||||||
assert!(LocalReposStore::resolve(&[repo], own, own).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn another_owners_announcement_is_linked_and_kept() {
|
|
||||||
let known = announcement(OTHER_KEY, "theirs");
|
|
||||||
let repo = bound(OTHER_KEY, "theirs");
|
|
||||||
|
|
||||||
let resolved = LocalReposStore::resolve(&[repo], std::slice::from_ref(&known), &[]);
|
|
||||||
|
|
||||||
assert_eq!(resolved.len(), 1);
|
|
||||||
assert_eq!(resolved[0].announcement.as_ref(), Some(&known));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user