Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68c098818 | ||
|
|
f9f0d33bb1 | ||
|
|
651ffec22d | ||
|
|
40deb9db66 | ||
|
|
38e8b2b933 | ||
|
|
a8e19e5fcd | ||
|
|
1af4c66566 | ||
|
|
02a0134164 | ||
|
|
cf1c9e2162 | ||
|
|
00167c6a8d | ||
|
|
33cbe42551 | ||
|
|
92afc5941e | ||
|
|
adbf49b6b7 | ||
|
|
5c6ab88710 | ||
|
|
05ab543fa0 | ||
|
|
d2468545d6 | ||
|
|
767638eda2 | ||
|
|
c76ebfb8f6 | ||
|
|
fc796582a8 | ||
|
|
7e421a0f3e | ||
|
|
932679311c |
@@ -0,0 +1,173 @@
|
||||
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.su/"
|
||||
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 }}"
|
||||
@@ -0,0 +1,32 @@
|
||||
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 +1,20 @@
|
||||
/target
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
dist/
|
||||
|
||||
# Snap and Flatpak local build output
|
||||
/snap
|
||||
/su.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
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Rust coding guidelines
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
- Propagate errors with `?` when the calling function should handle them
|
||||
- Use `.log_err()` or similar when you need to ignore errors but want visibility
|
||||
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
|
||||
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
|
||||
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
|
||||
* Avoid creative additions unless explicitly requested
|
||||
* Use full words for variable names (no abbreviations like "q" for "queue")
|
||||
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
|
||||
Example:
|
||||
```rust
|
||||
executor.spawn({
|
||||
let task_ran = task_ran.clone();
|
||||
async move {
|
||||
*task_ran.borrow_mut() = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
# Timers in tests
|
||||
|
||||
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
|
||||
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
|
||||
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
|
||||
|
||||
# GPUI
|
||||
|
||||
GPUI is a UI framework which also provides primitives for state and concurrency management.
|
||||
|
||||
## Context
|
||||
|
||||
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
|
||||
|
||||
* `App` is the root context type, providing access to global state and read and update of entities.
|
||||
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
|
||||
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
|
||||
|
||||
## `Window`
|
||||
|
||||
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
|
||||
|
||||
## Entities
|
||||
|
||||
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
|
||||
|
||||
* `thing.entity_id()` returns `EntityId`
|
||||
* `thing.downgrade()` returns `WeakEntity<T>`
|
||||
* `thing.read(cx: &App)` returns `&T`.
|
||||
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
|
||||
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
|
||||
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
|
||||
|
||||
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
|
||||
|
||||
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
|
||||
|
||||
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
|
||||
|
||||
## Concurrency
|
||||
|
||||
All use of entities and UI rendering occurs on a single foreground thread.
|
||||
|
||||
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
|
||||
|
||||
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
|
||||
|
||||
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
|
||||
|
||||
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
|
||||
|
||||
* Awaiting the task in some other async context.
|
||||
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
|
||||
* Storing the task in a field, if the work should be halted when the struct is dropped.
|
||||
|
||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||
|
||||
## Elements
|
||||
|
||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
struct TextWithBorder(SharedString);
|
||||
|
||||
impl Render for TextWithBorder {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().border_1().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
|
||||
|
||||
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
|
||||
|
||||
The style methods on elements are similar to those used by Tailwind CSS.
|
||||
|
||||
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
|
||||
|
||||
## Input events
|
||||
|
||||
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
|
||||
|
||||
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
|
||||
|
||||
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
|
||||
|
||||
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
|
||||
|
||||
## Notify
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
|
||||
|
||||
# Pull request hygiene
|
||||
|
||||
When an agent opens or updates a pull request, it must:
|
||||
|
||||
- Use a clear, correctly capitalized, imperative PR title (for example, `Fix crash in project panel`).
|
||||
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
|
||||
- Avoid trailing punctuation in PR titles.
|
||||
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `git_ui: Add history view`).
|
||||
- Include a `Release Notes:` section as the final section in the PR body.
|
||||
- Use one bullet under `Release Notes:`:
|
||||
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
|
||||
- `- N/A` for docs-only and other non-user-facing changes.
|
||||
- Format release notes exactly with a blank line after the heading, for example:
|
||||
|
||||
```
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
```
|
||||
|
||||
# Rules Hygiene
|
||||
|
||||
These `.rules` files are read by every agent session. Keep them high-signal.
|
||||
|
||||
## After any agentic session
|
||||
If you discover a non-obvious pattern that would help future sessions, include a **"Suggested .rules additions"** heading in your PR description with the proposed text. Do **not** edit `.rules` inline during normal feature/fix work. Reviewers decide what gets merged.
|
||||
|
||||
## High bar for new rules
|
||||
Editing or clarifying existing rules is always welcome. New rules must meet **all three** criteria:
|
||||
1. **Non-obvious** — someone familiar with the codebase would still get it wrong without the rule.
|
||||
2. **Repeatedly encountered** — it came up more than once (multiple hits in one session counts).
|
||||
3. **Specific enough to act on** — a concrete instruction, not a vague principle.
|
||||
|
||||
Rules that apply to a single crate belong in that crate's own `.rules` file, not the repo root.
|
||||
|
||||
## What NOT to put in `.rules`
|
||||
Avoid architectural descriptions of a crate (module layout, data flow, key types). These go stale fast and the agent can gather them by reading the code. Rules should be **traps to avoid**, not **maps to follow**.
|
||||
|
||||
## No drive-by additions
|
||||
Rules emerge from validated patterns, not one-off observations. The workflow is:
|
||||
1. Agent notes a pattern during a session.
|
||||
2. Team validates the pattern in code review.
|
||||
3. A dedicated commit adds the rule with context on *why* it exists.
|
||||
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.0.0"
|
||||
version = "0.1.0-alpha"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
@@ -12,18 +12,15 @@ publish = false
|
||||
# GPUI
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
|
||||
gpui_linux = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_windows = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_macos = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed" }
|
||||
|
||||
# `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 Kit
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], }
|
||||
gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" }
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
@@ -33,9 +30,8 @@ nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
|
||||
gix = { version = "0.86", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] }
|
||||
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
|
||||
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
smol = "2"
|
||||
futures = "0.3"
|
||||
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
|
||||
@@ -60,7 +56,7 @@ strip = true
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
panic = "unwind"
|
||||
|
||||
[profile.profiling]
|
||||
inherits = "release"
|
||||
|
||||
@@ -8,7 +8,6 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M20 9L13.4142 15.5858C12.6332 16.3668 11.3669 16.3668 10.5858 15.5858L4 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 209 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="5.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6.5 12H1.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M22.25 12H17.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 357 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20.255 17.05V10.35C20.255 9.78995 20.255 9.50992 20.146 9.29601C20.0501 9.10785 19.8972 8.95487 19.709 8.85899C19.4951 8.75 19.2151 8.75 18.655 8.75H5.35C4.78995 8.75 4.50992 8.75 4.29601 8.85899C4.10785 8.95487 3.95487 9.10785 3.85899 9.29601C3.75 9.50992 3.75 9.78995 3.75 10.35V17.05C3.75 18.1701 3.75 18.7302 3.96799 19.158C4.15973 19.5343 4.46569 19.8403 4.84202 20.032C5.26984 20.25 5.82989 20.25 6.95 20.25H17.055C18.1751 20.25 18.7352 20.25 19.163 20.032C19.5393 19.8403 19.8453 19.5343 20.037 19.158C20.255 18.7302 20.255 18.1701 20.255 17.05Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 8V3.75C5.75 3.19772 6.19772 2.75 6.75 2.75H9.67157C10.202 2.75 10.7107 2.96071 11.0858 3.33579L12.2071 4.45711C12.3946 4.64464 12.649 4.75 12.9142 4.75H17.25C17.8023 4.75 18.25 5.19772 18.25 5.75V8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.75L11.75 12.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H8.25C9.35457 3.75 10.25 4.64543 10.25 5.75V8.25C10.25 9.35457 9.35457 10.25 8.25 10.25H5.75C4.64543 10.25 3.75 9.35457 3.75 8.25V5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 15.75C3.75 14.6454 4.64543 13.75 5.75 13.75H8.25C9.35457 13.75 10.25 14.6454 10.25 15.75V18.25C10.25 19.3546 9.35457 20.25 8.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V15.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.75 17C13.75 15.2051 15.2051 13.75 17 13.75C18.7949 13.75 20.25 15.2051 20.25 17C20.25 18.7949 18.7949 20.25 17 20.25C15.2051 20.25 13.75 18.7949 13.75 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.75 5.75C13.75 4.64543 14.6454 3.75 15.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V8.25C20.25 9.35457 19.3546 10.25 18.25 10.25H15.75C14.6454 10.25 13.75 9.35457 13.75 8.25V5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18.5 19.0615C20.6627 18.4544 22.25 16.4502 22.25 14.0714C22.25 11.2114 19.9555 8.89286 17.125 8.89286C16.5661 8.89286 16.0281 8.98326 15.5245 9.15037C14.4289 6.56294 11.8865 4.75 8.925 4.75C4.96236 4.75 1.75 7.99594 1.75 12C1.75 14.7508 3.26609 17.1437 5.5 18.3722M14.5 16.25L12 13.75L9.5 16.25M12 20V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="black" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 193 B After Width: | Height: | Size: 203 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 21C13.5523 21 14 20.5523 14 20C14 19.4477 13.5523 19 13 19C12.4477 19 12 19.4477 12 20C12 20.5523 12.4477 21 13 21Z" fill="currentColor"/><path d="M21 11C21 10.4477 20.5523 9.99999 20 9.99999C19.4477 9.99999 19 10.4477 19 11C19 11.5523 19.4477 12 20 12C20.5523 12 21 11.5523 21 11Z" fill="currentColor"/><path d="M19.9295 14.2679C20.4078 14.5441 20.5716 15.1557 20.2955 15.634C20.0193 16.1123 19.4078 16.2761 18.9295 16C18.4512 15.7238 18.2873 15.1123 18.5634 14.634C18.8396 14.1557 19.4512 13.9918 19.9295 14.2679Z" fill="currentColor"/><path d="M17.3676 19.2942C17.8459 19.0181 18.0098 18.4065 17.7336 17.9282C17.4575 17.4499 16.8459 17.286 16.3676 17.5621C15.8893 17.8383 15.7254 18.4499 16.0016 18.9282C16.2777 19.4065 16.8893 19.5703 17.3676 19.2942Z" fill="currentColor"/><path d="M18.9269 7.99998C18.4487 8.27612 17.8371 8.11225 17.5609 7.63396C17.2848 7.15566 17.4487 6.54407 17.9269 6.26793C18.4052 5.99179 19.0168 6.15566 19.293 6.63396C19.5691 7.11225 19.4052 7.72384 18.9269 7.99998Z" fill="currentColor"/><path d="M9.25 14.75V20.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.2493 4.41452C14.2521 3.98683 13.1537 3.75 12 3.75C7.44365 3.75 3.75 7.44365 3.75 12C3.75 15.498 5.92698 18.4875 9 19.6876" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M19.2479 6.923L12.9979 3.32455C12.3802 2.96888 11.6198 2.96888 11.0021 3.32455L4.75208 6.92297C4.13211 7.27992 3.75 7.94083 3.75 8.65622V15.3439C3.75 16.0593 4.13213 16.7202 4.75213 17.0772L11.0021 20.6754C11.6198 21.031 12.3802 21.031 12.9979 20.6753L19.2479 17.0769C19.8679 16.7199 20.25 16.059 20.25 15.3436V8.65625C20.25 7.94086 19.8679 7.27995 19.2479 6.923Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/><path d="M15.25 12C15.25 13.7949 13.7949 15.25 12 15.25C10.2051 15.25 8.75 13.7949 8.75 12C8.75 10.2051 10.2051 8.75 12 8.75C13.7949 8.75 15.25 10.2051 15.25 12Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M7.878 5.21415L7.17474 5.05186C6.58003 4.91462 5.95657 5.09343 5.525 5.525C5.09343 5.95657 4.91462 6.58003 5.05186 7.17474L5.21415 7.878C5.40122 8.6886 5.06696 9.53036 4.37477 9.99182L3.51965 10.5619C3.03881 10.8825 2.75 11.4221 2.75 12C2.75 12.5779 3.03881 13.1175 3.51965 13.4381L4.37477 14.0082C5.06696 14.4696 5.40122 15.3114 5.21415 16.122L5.05186 16.8253C4.91462 17.42 5.09343 18.0434 5.525 18.475C5.95657 18.9066 6.58003 19.0854 7.17474 18.9481L7.878 18.7858C8.6886 18.5988 9.53036 18.933 9.99182 19.6252L10.5619 20.4804C10.8825 20.9612 11.4221 21.25 12 21.25C12.5779 21.25 13.1175 20.9612 13.4381 20.4804L14.0082 19.6252C14.4696 18.933 15.3114 18.5988 16.122 18.7858L16.8253 18.9481C17.42 19.0854 18.0434 18.9066 18.475 18.475C18.9066 18.0434 19.0854 17.42 18.9481 16.8253L18.7858 16.122C18.5988 15.3114 18.933 14.4696 19.6252 14.0082L20.4804 13.4381C20.9612 13.1175 21.25 12.5779 21.25 12C21.25 11.4221 20.9612 10.8825 20.4804 10.5619L19.6252 9.99182C18.933 9.53036 18.5988 8.6886 18.7858 7.878L18.9481 7.17473C19.0854 6.58003 18.9066 5.95657 18.475 5.525C18.0434 5.09343 17.42 4.91462 16.8253 5.05186L16.122 5.21415C15.3114 5.40122 14.4696 5.06696 14.0082 4.37477L13.4381 3.51965C13.1175 3.03881 12.5779 2.75 12 2.75C11.4221 2.75 10.8825 3.03881 10.5619 3.51965L9.99182 4.37477C9.53036 5.06696 8.6886 5.40122 7.878 5.21415Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14.75 12C14.75 13.5188 13.5188 14.75 12 14.75C10.4812 14.75 9.25 13.5188 9.25 12C9.25 10.4812 10.4812 9.25 12 9.25C13.5188 9.25 14.75 10.4812 14.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 733 B After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9.00003 10.4286L14 7.57141M9.00003 13.5714L14 16.4286M20.25 6C20.25 7.79493 18.7949 9.25 17 9.25C15.2051 9.25 13.75 7.79493 13.75 6C13.75 4.20507 15.2051 2.75 17 2.75C18.7949 2.75 20.25 4.20507 20.25 6ZM20.25 18C20.25 19.7949 18.7949 21.25 17 21.25C15.2051 21.25 13.75 19.7949 13.75 18C13.75 16.2051 15.2051 14.75 17 14.75C18.7949 14.75 20.25 16.2051 20.25 18ZM9.25 12C9.25 13.7949 7.79493 15.25 6 15.25C4.20507 15.25 2.75 13.7949 2.75 12C2.75 10.2051 4.20507 8.75 6 8.75C7.79493 8.75 9.25 10.2051 9.25 12Z" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 641 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15.75 6.75H21.25V12.25M20.7361 7.275L14.4142 13.5878C13.633 14.3679 12.3675 14.3675 11.5868 13.5868L10.4142 12.4142C9.63316 11.6332 8.36684 11.6332 7.58579 12.4142L2.75 17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
@@ -2,572 +2,429 @@
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Signed",
|
||||
"author": "Signed",
|
||||
"url": "https://git.reya.su/reya/signed",
|
||||
"themes": [
|
||||
{
|
||||
"is_default": true,
|
||||
"name": "Signed Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#F4F4F5",
|
||||
"accent.foreground": "#18181B",
|
||||
"accent.background": "#F0F0F0",
|
||||
"accent.foreground": "#202020",
|
||||
"accordion.background": "#FFFFFF",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E4E4E7",
|
||||
"button.background": "#FFFFFF",
|
||||
"button.active.background": "#E4E4E7",
|
||||
"button.foreground": "#18181B",
|
||||
"button.hover.background": "#F4F4F5",
|
||||
"button.danger.background": "#DC2626",
|
||||
"button.danger.active.background": "#B91C1C",
|
||||
"button.danger.foreground": "#FFFFFF",
|
||||
"button.danger.hover.background": "#EF4444",
|
||||
"button.info.background": "#0284C7",
|
||||
"button.info.active.background": "#0369A1",
|
||||
"button.info.foreground": "#FFFFFF",
|
||||
"button.info.hover.background": "#0EA5E9",
|
||||
"button.primary.background": "#C6FF4D",
|
||||
"button.primary.active.background": "#65A30D",
|
||||
"button.primary.foreground": "#0B0F0C",
|
||||
"button.primary.hover.background": "#B2EC3C",
|
||||
"button.secondary.background": "#F4F4F5",
|
||||
"button.secondary.active.background": "#D4D4D8",
|
||||
"button.secondary.foreground": "#18181B",
|
||||
"button.secondary.hover.background": "#E4E4E7",
|
||||
"button.success.background": "#2FBF71",
|
||||
"button.success.active.background": "#1F9A58",
|
||||
"button.success.foreground": "#0B0F0C",
|
||||
"button.success.hover.background": "#2AB568",
|
||||
"button.warning.background": "#D97706",
|
||||
"button.warning.active.background": "#B45309",
|
||||
"button.warning.foreground": "#FFFFFF",
|
||||
"button.warning.hover.background": "#F59E0B",
|
||||
"group_box.background": "#F4F4F5",
|
||||
"group_box.foreground": "#18181B",
|
||||
"group_box.title.foreground": "#18181B",
|
||||
"caret": "#18181B",
|
||||
"chart.1": "#65A30D",
|
||||
"chart.2": "#2FBF71",
|
||||
"chart.3": "#3F6212",
|
||||
"chart.4": "#7A8B5F",
|
||||
"chart.5": "#A1A1AA",
|
||||
"chart_bullish": "#2FBF71",
|
||||
"chart_bearish": "#DC2626",
|
||||
"danger.background": "#DC2626",
|
||||
"danger.active.background": "#B91C1C",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"danger.hover.background": "#EF4444",
|
||||
"description_list.label.background": "#F4F4F5",
|
||||
"description_list.label.foreground": "#71717A",
|
||||
"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": "#65A30D33",
|
||||
"drop_target.background": "#C6FF4D40",
|
||||
"foreground": "#18181B",
|
||||
"info.background": "#0284C7",
|
||||
"info.active.background": "#0369A1",
|
||||
"info.foreground": "#FFFFFF",
|
||||
"info.hover.background": "#0EA5E9",
|
||||
"input.border": "#D4D4D8",
|
||||
"link": "#65A30D",
|
||||
"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": "#4A7A0B",
|
||||
"link.hover": "#65A30D",
|
||||
"list.background": "#FFFFFF",
|
||||
"list.active.background": "#C6FF4D40",
|
||||
"list.active.border": "#65A30D80",
|
||||
"list.even.background": "#FAFAFA",
|
||||
"list.head.background": "#FAFAFA",
|
||||
"list.hover.background": "#F4F4F5",
|
||||
"muted.background": "#F4F4F5",
|
||||
"muted.foreground": "#71717A",
|
||||
"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.foreground": "#0B0F0C",
|
||||
"primary.hover.background": "#B2EC3C",
|
||||
"progress.bar.background": "#65A30D",
|
||||
"ring": "#65A30D",
|
||||
"scrollbar.background": "#FFFFFF00",
|
||||
"scrollbar.thumb.background": "#A1A1AA99",
|
||||
"scrollbar.thumb.hover.background": "#A1A1AA",
|
||||
"secondary.background": "#F4F4F5",
|
||||
"secondary.active.background": "#D4D4D8",
|
||||
"secondary.foreground": "#18181B",
|
||||
"secondary.hover.background": "#E4E4E7",
|
||||
"selection.background": "#C6FF4D59",
|
||||
"sidebar.background": "#FAFAFA",
|
||||
"sidebar.accent.background": "#C6FF4D40",
|
||||
"sidebar.accent.foreground": "#65A30D",
|
||||
"sidebar.border": "#E4E4E7",
|
||||
"sidebar.foreground": "#18181B",
|
||||
"sidebar.primary.background": "#F4F4F5",
|
||||
"sidebar.primary.foreground": "#18181B",
|
||||
"skeleton.background": "#E4E4E7",
|
||||
"slider.background": "#E4E4E7",
|
||||
"slider.thumb.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.active.background": "#1F9A58",
|
||||
"success.foreground": "#0B0F0C",
|
||||
"success.hover.background": "#2AB568",
|
||||
"switch.background": "#D4D4D8",
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#CECECE",
|
||||
"switch.thumb.background": "#FFFFFF",
|
||||
"tab.background": "#F4F4F5",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#EBFFC1",
|
||||
"tab.active.foreground": "#3F6212",
|
||||
"tab.foreground": "#71717A",
|
||||
"tab_bar.background": "#F4F4F5",
|
||||
"tab_bar.segmented.background": "#E4E4E7",
|
||||
"tab.foreground": "#646464",
|
||||
"tab_bar.background": "#F0F0F0",
|
||||
"tab_bar.segmented.background": "#F0F0F0",
|
||||
"table.background": "#FFFFFF",
|
||||
"table.active.background": "#C6FF4D40",
|
||||
"table.active.border": "#65A30D80",
|
||||
"table.even.background": "#FAFAFA",
|
||||
"table.head.background": "#FAFAFA",
|
||||
"table.head.foreground": "#71717A",
|
||||
"table.foot.background": "#FAFAFA",
|
||||
"table.foot.foreground": "#71717A",
|
||||
"table.hover.background": "#F4F4F5",
|
||||
"table.row.border": "#E4E4E7",
|
||||
"title_bar.background": "#FAFAFA",
|
||||
"title_bar.border": "#E4E4E7",
|
||||
"status_bar.background": "#FAFAFA",
|
||||
"status_bar.border": "#E4E4E7",
|
||||
"tiles.background": "#FFFFFF",
|
||||
"warning.background": "#D97706",
|
||||
"warning.active.background": "#B45309",
|
||||
"warning.foreground": "#FFFFFF",
|
||||
"warning.hover.background": "#F59E0B",
|
||||
"overlay": "#0000004D",
|
||||
"window.border": "#E4E4E7",
|
||||
"base.red": "#DC2626",
|
||||
"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.yellow": "#CA8A04",
|
||||
"base.blue": "#2563EB",
|
||||
"base.magenta": "#9333EA",
|
||||
"base.cyan": "#0891B2"
|
||||
"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.background": "#FFFFFF",
|
||||
"editor.foreground": "#383A42",
|
||||
"editor.active_line.background": "#F2F3F4",
|
||||
"editor.line_number": "#A0A1A7",
|
||||
"editor.active_line_number": "#383A42",
|
||||
"editor.invisible": "#A0A1A766",
|
||||
"conflict": "#DC2626",
|
||||
"created": "#16A34A",
|
||||
"deleted": "#DC2626",
|
||||
"error": "#DC2626",
|
||||
"error.background": "#FEF2F2",
|
||||
"error.border": "#F87171",
|
||||
"hidden": "#A0A1A7",
|
||||
"hint": "#A626A4",
|
||||
"hint.background": "#FAF5FF",
|
||||
"hint.border": "#C4B5FD",
|
||||
"ignored": "#A0A1A7",
|
||||
"info": "#4078F2",
|
||||
"info.background": "#EFF6FF",
|
||||
"info.border": "#93C5FD",
|
||||
"modified": "#C18401",
|
||||
"modified.background": "#FFFBEB",
|
||||
"predictive": "#A0A1A7",
|
||||
"renamed": "#A626A4",
|
||||
"success": "#16A34A",
|
||||
"success.background": "#F0FDF4",
|
||||
"unreachable": "#A0A1A7",
|
||||
"warning": "#C18401",
|
||||
"warning.background": "#FFFBEB",
|
||||
"warning.border": "#FCD34D",
|
||||
"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": "#986801"
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#986801"
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#A0A1A7",
|
||||
"font_style": "italic"
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#A0A1A7",
|
||||
"font_style": "italic"
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#986801"
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#4078F2"
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#C18401"
|
||||
"color": "#333333"
|
||||
},
|
||||
"emphasis": {
|
||||
"font_style": "italic"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"font_weight": 700
|
||||
},
|
||||
"function": {
|
||||
"color": "#4078F2"
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#A626A4"
|
||||
},
|
||||
"label": {
|
||||
"color": "#4078F2"
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#4078F2",
|
||||
"font_style": "underline"
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#4078F2",
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#986801"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#383A42"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#C18401"
|
||||
},
|
||||
"property": {
|
||||
"color": "#E45649"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#383A42"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#383A42"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#383A42"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#986801"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#A626A4"
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#50A14F"
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#A626A4"
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#50A14F"
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#50A14F"
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#986801"
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#E45649"
|
||||
},
|
||||
"tag.doctype": {
|
||||
"color": "#A0A1A7"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#383A42"
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#50A14F"
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#E45649",
|
||||
"font_weight": 700
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#0184BC"
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#383A42"
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E45649"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#0184BC"
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"is_default": true,
|
||||
"name": "Signed Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#18181B",
|
||||
"accent.foreground": "#FAFAFA",
|
||||
"accent.background": "#222222",
|
||||
"accent.foreground": "#EEEEEE",
|
||||
"accordion.background": "#0A0A0A",
|
||||
"background": "#0A0A0A",
|
||||
"border": "#27272A",
|
||||
"button.background": "#18181B",
|
||||
"button.active.background": "#3F3F46",
|
||||
"button.foreground": "#FAFAFA",
|
||||
"button.hover.background": "#27272A",
|
||||
"button.danger.background": "#EF4444",
|
||||
"button.danger.active.background": "#DC2626",
|
||||
"button.danger.foreground": "#FFFFFF",
|
||||
"button.danger.hover.background": "#F87171",
|
||||
"button.info.background": "#0EA5E9",
|
||||
"button.info.active.background": "#0284C7",
|
||||
"button.info.foreground": "#FFFFFF",
|
||||
"button.info.hover.background": "#38BDF8",
|
||||
"button.primary.background": "#C6FF4D",
|
||||
"button.primary.active.background": "#65A30D",
|
||||
"button.primary.foreground": "#0B0F0C",
|
||||
"button.primary.hover.background": "#B2EC3C",
|
||||
"button.secondary.background": "#18181B",
|
||||
"button.secondary.active.background": "#3F3F46",
|
||||
"button.secondary.foreground": "#FAFAFA",
|
||||
"button.secondary.hover.background": "#27272A",
|
||||
"button.success.background": "#2FBF71",
|
||||
"button.success.active.background": "#24A35D",
|
||||
"button.success.foreground": "#0B0F0C",
|
||||
"button.success.hover.background": "#2AB568",
|
||||
"button.warning.background": "#F59E0B",
|
||||
"button.warning.active.background": "#D97706",
|
||||
"button.warning.foreground": "#18181B",
|
||||
"button.warning.hover.background": "#FBBF24",
|
||||
"group_box.background": "#18181B",
|
||||
"group_box.foreground": "#FAFAFA",
|
||||
"group_box.title.foreground": "#FAFAFA",
|
||||
"caret": "#FAFAFA",
|
||||
"chart.1": "#C6FF4D",
|
||||
"chart.2": "#A6E22E",
|
||||
"chart.3": "#2FBF71",
|
||||
"chart.4": "#7A8B5F",
|
||||
"chart.5": "#A1A1AA",
|
||||
"chart_bullish": "#2FBF71",
|
||||
"chart_bearish": "#EF4444",
|
||||
"danger.background": "#EF4444",
|
||||
"danger.active.background": "#DC2626",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"danger.hover.background": "#F87171",
|
||||
"description_list.label.background": "#18181B",
|
||||
"description_list.label.foreground": "#A1A1AA",
|
||||
"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": "#C6FF4D33",
|
||||
"drop_target.background": "#C6FF4D2E",
|
||||
"foreground": "#FAFAFA",
|
||||
"info.background": "#0EA5E9",
|
||||
"info.active.background": "#0284C7",
|
||||
"info.foreground": "#FFFFFF",
|
||||
"info.hover.background": "#38BDF8",
|
||||
"input.border": "#3F3F46",
|
||||
"link": "#C6FF4D",
|
||||
"link.active": "#A6E22E",
|
||||
"link.hover": "#B2EC3C",
|
||||
"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": "#C6FF4D40",
|
||||
"list.active.border": "#C6FF4D80",
|
||||
"list.even.background": "#18181B",
|
||||
"list.head.background": "#18181B",
|
||||
"list.hover.background": "#27272A",
|
||||
"muted.background": "#18181B",
|
||||
"muted.foreground": "#A1A1AA",
|
||||
"popover.background": "#18181B",
|
||||
"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.foreground": "#0B0F0C",
|
||||
"primary.hover.background": "#B2EC3C",
|
||||
"progress.bar.background": "#C6FF4D",
|
||||
"ring": "#C6FF4D",
|
||||
"scrollbar.background": "#0A0A0A00",
|
||||
"scrollbar.thumb.background": "#3F3F46",
|
||||
"scrollbar.thumb.hover.background": "#52525B",
|
||||
"secondary.background": "#18181B",
|
||||
"secondary.active.background": "#3F3F46",
|
||||
"secondary.foreground": "#FAFAFA",
|
||||
"secondary.hover.background": "#27272A",
|
||||
"selection.background": "#C6FF4D59",
|
||||
"sidebar.background": "#0A0A0A",
|
||||
"sidebar.accent.background": "#C6FF4D40",
|
||||
"sidebar.accent.foreground": "#C6FF4D",
|
||||
"sidebar.border": "#27272A",
|
||||
"sidebar.foreground": "#FAFAFA",
|
||||
"sidebar.primary.background": "#18181B",
|
||||
"sidebar.primary.foreground": "#FAFAFA",
|
||||
"skeleton.background": "#27272A",
|
||||
"slider.background": "#27272A",
|
||||
"slider.thumb.background": "#C6FF4D",
|
||||
"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.active.background": "#24A35D",
|
||||
"success.foreground": "#0B0F0C",
|
||||
"success.hover.background": "#2AB568",
|
||||
"switch.background": "#3F3F46",
|
||||
"switch.thumb.background": "#FAFAFA",
|
||||
"tab.background": "#18181B",
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#484848",
|
||||
"switch.thumb.background": "#0A0A0A",
|
||||
"tab.background": "#00000000",
|
||||
"tab.active.background": "#19200A",
|
||||
"tab.active.foreground": "#C6FF4D",
|
||||
"tab.foreground": "#A1A1AA",
|
||||
"tab_bar.background": "#18181B",
|
||||
"tab_bar.segmented.background": "#27272A",
|
||||
"tab.foreground": "#B4B4B4",
|
||||
"tab_bar.background": "#191919",
|
||||
"tab_bar.segmented.background": "#191919",
|
||||
"table.background": "#0A0A0A",
|
||||
"table.active.background": "#C6FF4D40",
|
||||
"table.active.border": "#C6FF4D80",
|
||||
"table.even.background": "#18181B",
|
||||
"table.head.background": "#18181B",
|
||||
"table.head.foreground": "#A1A1AA",
|
||||
"table.foot.background": "#18181B",
|
||||
"table.foot.foreground": "#A1A1AA",
|
||||
"table.hover.background": "#27272A",
|
||||
"table.row.border": "#27272A",
|
||||
"title_bar.background": "#18181B",
|
||||
"title_bar.border": "#27272A",
|
||||
"status_bar.background": "#18181B",
|
||||
"status_bar.border": "#27272A",
|
||||
"tiles.background": "#0A0A0A",
|
||||
"warning.background": "#F59E0B",
|
||||
"warning.active.background": "#D97706",
|
||||
"warning.foreground": "#18181B",
|
||||
"warning.hover.background": "#FBBF24",
|
||||
"overlay": "#00000080",
|
||||
"window.border": "#27272A",
|
||||
"base.red": "#EF4444",
|
||||
"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.yellow": "#EAB308",
|
||||
"base.blue": "#3B82F6",
|
||||
"base.magenta": "#A855F7",
|
||||
"base.cyan": "#06B6D4"
|
||||
"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.background": "#0A0A0A",
|
||||
"editor.foreground": "#ABB2BF",
|
||||
"editor.active_line.background": "#18181B",
|
||||
"editor.line_number": "#71717A",
|
||||
"editor.active_line_number": "#FAFAFA",
|
||||
"editor.invisible": "#71717A66",
|
||||
"conflict": "#E06C75",
|
||||
"created": "#98C379",
|
||||
"deleted": "#E06C75",
|
||||
"error": "#E06C75",
|
||||
"error.background": "#3A1D1D",
|
||||
"error.border": "#E06C75",
|
||||
"hidden": "#5C6370",
|
||||
"hint": "#C678DD",
|
||||
"hint.background": "#2E2740",
|
||||
"hint.border": "#C678DD",
|
||||
"ignored": "#5C6370",
|
||||
"info": "#61AFEF",
|
||||
"info.background": "#1D2E3A",
|
||||
"info.border": "#61AFEF",
|
||||
"modified": "#E5C07B",
|
||||
"modified.background": "#3A320E",
|
||||
"predictive": "#5C6370",
|
||||
"renamed": "#C678DD",
|
||||
"success": "#98C379",
|
||||
"success.background": "#1D3A2E",
|
||||
"unreachable": "#5C6370",
|
||||
"warning": "#E5C07B",
|
||||
"warning.background": "#3A320E",
|
||||
"warning.border": "#E5C07B",
|
||||
"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": "#D19A66"
|
||||
"color": "#7FAEF9"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#D19A66"
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5C6370",
|
||||
"font_style": "italic"
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5C6370",
|
||||
"font_style": "italic"
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#D19A66"
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#E5C07B"
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#98C379"
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"emphasis": {
|
||||
"font_style": "italic"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"font_weight": 700
|
||||
},
|
||||
"function": {
|
||||
"color": "#61AFEF"
|
||||
"color": "#B3C5F3"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#C678DD"
|
||||
},
|
||||
"label": {
|
||||
"color": "#61AFEF"
|
||||
"color": "#87B1F6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#61AFEF",
|
||||
"font_style": "underline"
|
||||
"color": "#419CFF",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#61AFEF",
|
||||
"color": "#7faef9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#D19A66"
|
||||
},
|
||||
"operator": {
|
||||
"color": "#56B6C2"
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#E5C07B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#E06C75"
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#ABB2BF"
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#ABB2BF"
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#ABB2BF"
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#98C379"
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#ABB2BF"
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#98C379"
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#56B6C2"
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#E06C75"
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#98C379"
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#D19A66"
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#E06C75"
|
||||
},
|
||||
"tag.doctype": {
|
||||
"color": "#5C6370"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#ABB2BF"
|
||||
"color": "#419CFF"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#98C379"
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#61AFEF",
|
||||
"font_weight": 700
|
||||
"color": "#CC9E00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#E5C07B"
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#E06C75"
|
||||
"property": {
|
||||
"color": "#BCC4E0"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#D19A66"
|
||||
},
|
||||
"variant": {
|
||||
"color": "#E5C07B"
|
||||
"color": "#419CFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use gpui_component::IconNamed;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
@@ -7,6 +7,8 @@ use rust_embed::RustEmbed;
|
||||
#[folder = "assets"]
|
||||
#[include = "icons/**/*.svg"]
|
||||
#[include = "themes/**/*.json"]
|
||||
#[include = "backgrounds/**/*.jpg"]
|
||||
#[include = "backgrounds/**/*.png"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
@@ -31,17 +33,12 @@ impl AssetSource for Assets {
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Returns the embedded theme files as `(file name, JSON content)` pairs,
|
||||
/// e.g. `("signed.json", ...)`. The content is a `ThemeSet` that can be
|
||||
/// loaded into the [`ThemeRegistry`](gpui_component::ThemeRegistry).
|
||||
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());
|
||||
// Debug builds read files from disk (owned), release builds
|
||||
// embed them in the binary (borrowed).
|
||||
let content = match data.data {
|
||||
std::borrow::Cow::Borrowed(bytes) => {
|
||||
std::str::from_utf8(bytes).ok()?.to_owned()
|
||||
@@ -52,22 +49,6 @@ impl Assets {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CustomIconName {
|
||||
@@ -76,6 +57,8 @@ pub enum CustomIconName {
|
||||
Filter,
|
||||
GlobalOn,
|
||||
GlobalOff,
|
||||
GitFile,
|
||||
GitCommit,
|
||||
GitIssueDone,
|
||||
GitIssueOpen,
|
||||
GitIssueClosed,
|
||||
@@ -88,6 +71,12 @@ pub enum CustomIconName {
|
||||
GitBranch,
|
||||
Tag,
|
||||
Markdown,
|
||||
Share,
|
||||
Trending,
|
||||
Recent,
|
||||
Refresh,
|
||||
Grid,
|
||||
Init,
|
||||
}
|
||||
|
||||
impl IconNamed for CustomIconName {
|
||||
@@ -98,6 +87,8 @@ impl IconNamed for CustomIconName {
|
||||
CustomIconName::Filter => "icons/filter.svg",
|
||||
CustomIconName::GlobalOn => "icons/global-on.svg",
|
||||
CustomIconName::GlobalOff => "icons/global-off.svg",
|
||||
CustomIconName::GitCommit => "icons/git-commit.svg",
|
||||
CustomIconName::GitFile => "icons/git-file.svg",
|
||||
CustomIconName::GitIssueDone => "icons/git-issue-done.svg",
|
||||
CustomIconName::GitIssueOpen => "icons/git-issue-open.svg",
|
||||
CustomIconName::GitIssueClosed => "icons/git-issue-close.svg",
|
||||
@@ -110,6 +101,12 @@ impl IconNamed for CustomIconName {
|
||||
CustomIconName::GitBranch => "icons/git-branch.svg",
|
||||
CustomIconName::Tag => "icons/tag.svg",
|
||||
CustomIconName::Markdown => "icons/markdown.svg",
|
||||
CustomIconName::Share => "icons/share.svg",
|
||||
CustomIconName::Trending => "icons/trending.svg",
|
||||
CustomIconName::Refresh => "icons/refresh.svg",
|
||||
CustomIconName::Recent => "icons/recent.svg",
|
||||
CustomIconName::Grid => "icons/grid.svg",
|
||||
CustomIconName::Init => "icons/init.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
@@ -154,14 +151,14 @@ mod tests {
|
||||
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.
|
||||
// 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, a dim moss on dark — each
|
||||
// paired with readable, contrasting text.
|
||||
// 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
|
||||
|
||||
@@ -9,6 +9,7 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! The dock-area appearance: the outer frame, the split frames, and one
|
||||
//! dock's chrome. Ported from the vendored dock's `DockArea`/`Dock` render
|
||||
//! onto `gpui_base::dock::DockAreaRenderer`.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::ops::Deref as _;
|
||||
use std::rc::Rc;
|
||||
@@ -26,12 +22,7 @@ use crate::tab_panel::SignedTabGroupSkin;
|
||||
use crate::tiles::SignedTilesSkin;
|
||||
use crate::{TAB_BAR_HEIGHT, panel_handle};
|
||||
|
||||
/// What every part of the skin reads, and the dock area it belongs to.
|
||||
///
|
||||
/// The renderer is the only skin-owned object in the picture, so the settings
|
||||
/// the old `DockArea` carried live here. It is shared by reference with the
|
||||
/// per-container renderers, which are built once each and outlive any one
|
||||
/// frame.
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
@@ -57,16 +48,14 @@ impl SkinShared {
|
||||
&self.resizing_dock
|
||||
}
|
||||
|
||||
/// Redraw the area after a setting changed. The skin is not an entity, so
|
||||
/// nothing else would notice.
|
||||
/// 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`].
|
||||
///
|
||||
/// Install it at construction, where the area's own weak handle is available:
|
||||
/// Install it in the constructor, the only place the area's weak handle is available.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let dock = cx.new(|cx| {
|
||||
@@ -94,8 +83,7 @@ impl SignedDockSkin {
|
||||
&self.shared
|
||||
}
|
||||
|
||||
/// Whether tab bars offer the affordance that collapses a neighbouring
|
||||
/// dock.
|
||||
/// 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()
|
||||
}
|
||||
@@ -116,8 +104,9 @@ impl SignedDockSkin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload a dock's resize handle drags. It draws nothing: the handle
|
||||
/// itself is the affordance.
|
||||
/// Payload a dock's resize handle drags.
|
||||
///
|
||||
/// It draws nothing, the handle element is the visible affordance.
|
||||
#[derive(Clone)]
|
||||
struct ResizePanel;
|
||||
|
||||
@@ -148,9 +137,7 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
}
|
||||
|
||||
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
// `size_full` is what the old `StackPanel::render` carried; `flex_1`
|
||||
// is belt and braces so the frame never collapses to zero height in
|
||||
// an unsizing parent.
|
||||
// `size_full` and `flex_1` stop the frame collapsing in an unsizing parent.
|
||||
div()
|
||||
.id(("dock-split-frame", node.as_u64()))
|
||||
.size_full()
|
||||
@@ -170,8 +157,8 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
let placement = dock.placement();
|
||||
let open = dock.is_open();
|
||||
|
||||
// A closed left or right dock takes no space at all; a closed bottom
|
||||
// dock keeps a strip so its tab bar stays clickable.
|
||||
// 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();
|
||||
}
|
||||
@@ -187,8 +174,7 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
// Base never builds a dock for the centre.
|
||||
DockPlacement::Center => this,
|
||||
})
|
||||
// The closed bottom dock's strip is the tab bar itself, which is
|
||||
// a full tab bar tall.
|
||||
// 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)
|
||||
})
|
||||
@@ -201,10 +187,8 @@ impl DockAreaRenderer for SignedDockSkin {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The "unknown panel" message the old `InvalidPanel` drew.
|
||||
///
|
||||
/// It answers `dump` with the state it was handed, so a layout written by
|
||||
/// a build that knows the panel survives a load and save here.
|
||||
/// Placeholder for a panel this build cannot construct.
|
||||
/// It dumps the state it was handed, so the layout survives a load and save.
|
||||
fn build_placeholder(
|
||||
&self,
|
||||
state: &PanelState,
|
||||
@@ -247,11 +231,7 @@ impl SignedDockSkin {
|
||||
}
|
||||
|
||||
/// Turns the window's mouse stream into dock resizing.
|
||||
///
|
||||
/// A resize is driven by pointer moves that land anywhere in the window, not
|
||||
/// only on the handle, so it cannot be expressed as a listener on the handle
|
||||
/// itself. This element paints nothing and exists for its `paint` hook, which
|
||||
/// is the only place a window-level mouse listener can be registered.
|
||||
/// It draws nothing, the `paint` hook is the only window listener registration point.
|
||||
struct DockResizeTracker {
|
||||
dock: DockContext,
|
||||
shared: Rc<SkinShared>,
|
||||
@@ -317,10 +297,8 @@ impl Element for DockResizeTracker {
|
||||
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
||||
return;
|
||||
}
|
||||
// Dragging a closed dock's handle reopens it, as the old dock
|
||||
// did. The live state is read rather than the render-time
|
||||
// snapshot in `dock`, which would still say closed for the
|
||||
// rest of the frame and toggle it shut again on the next move.
|
||||
// Dragging a closed dock's handle reopens it.
|
||||
// Read the live state, the snapshot in `dock` would toggle it shut again.
|
||||
let open = shared
|
||||
.area()
|
||||
.upgrade()
|
||||
@@ -339,8 +317,8 @@ impl Element for DockResizeTracker {
|
||||
return;
|
||||
}
|
||||
shared.resizing_dock().set(None);
|
||||
// The size lives on the dock, not in the layout tree, so
|
||||
// nothing else tells a subscriber to persist it.
|
||||
// The size lives on the dock, not the layout tree.
|
||||
// Nothing else tells a subscriber to persist it.
|
||||
_ = shared
|
||||
.area()
|
||||
.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged));
|
||||
|
||||
@@ -7,12 +7,8 @@ use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Panel;
|
||||
|
||||
/// Stands in for a panel this build cannot construct — one whose `panel_name`
|
||||
/// no [`PanelRegistry`](gpui_base::dock::PanelRegistry) builder answers to.
|
||||
///
|
||||
/// It reports the original [`PanelState`] from
|
||||
/// [`dump`](gpui_base::dock::Panel::dump), so a layout written by a build that
|
||||
/// knows the panel survives a load and a save here rather than losing it.
|
||||
/// Stands in for a panel this build cannot construct.
|
||||
/// It returns the state it was handed, so the layout survives a load and save.
|
||||
pub(crate) struct InvalidPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
//! The Signed dock skin.
|
||||
//!
|
||||
//! The dock engine lives upstream: `gpui_base::dock` owns the layout tree,
|
||||
//! the drags, the zoom and the persistence, and `gpui_component::dock`
|
||||
//! supplies the default appearance. This crate is the appearance the app
|
||||
//! used to vendor from gpui-component — a 44px tab bar that doubles as the
|
||||
//! window title bar, with pill tabs, window controls, title-bar dragging and
|
||||
//! previous/next tab buttons — re-implemented against upstream's renderer
|
||||
//! traits.
|
||||
//!
|
||||
//! Everything `gpui_component::dock` exports is re-exported here, so the app
|
||||
//! keeps importing the dock from a single place.
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
App, Div, InteractiveElement as _, MouseButton, Pixels, Stateful,
|
||||
StatefulInteractiveElement as _, Window, WindowControlArea, px,
|
||||
};
|
||||
use gpui::{Context, Pixels, Window, px};
|
||||
use gpui_base::dock::PanelView;
|
||||
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
@@ -24,20 +11,40 @@ mod window_controls;
|
||||
|
||||
pub use dock_area::SignedDockSkin;
|
||||
pub use gpui_component::dock::{
|
||||
AnyDrag, BasePanel, BasePanelView, ClosePanel, DockArea, DockAreaState, DockContext, DockEvent,
|
||||
DockLayout, DockPlacement, DockState, DragPanel, DropIndicator, DropPlaceholderBounds,
|
||||
DropTarget, Panel, PanelControl, PanelEvent, PanelHandle, PanelInfo, PanelState, PanelStyle,
|
||||
PanelView, TitleStyle, ToggleZoom, panel_handle, register_panel,
|
||||
BasePanel, DockArea, DockEvent, DockLayout, DockPlacement, Panel, PanelEvent, panel_handle,
|
||||
};
|
||||
|
||||
/// Add an already-wrapped panel handle to the center of `area`.
|
||||
///
|
||||
/// Every panel entry point opens its panel there.
|
||||
pub fn add_center_panel(
|
||||
area: &mut DockArea,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<DockArea>,
|
||||
) {
|
||||
area.add_panel_view(panel, DockPlacement::Center, None, window, cx);
|
||||
}
|
||||
|
||||
/// Add an already-wrapped panel handle to the bottom dock of `area`.
|
||||
///
|
||||
/// Used for sub-views that hang under the center, such as the inbox's Unread
|
||||
/// and Archived lists.
|
||||
pub fn add_bottom_panel(
|
||||
area: &mut DockArea,
|
||||
panel: Arc<dyn PanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<DockArea>,
|
||||
) {
|
||||
area.add_panel_view(panel, DockPlacement::Bottom, None, window, cx);
|
||||
}
|
||||
|
||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||
|
||||
/// Minimal i18n shim replacing gpui-component's `rust_i18n::t!()`, keeping the
|
||||
/// same `Dock.*` keys resolved to English so the crate has no i18n dependency.
|
||||
/// i18n shim resolving `Dock.*` keys to English, so the crate has no i18n dependency.
|
||||
pub(crate) fn t(key: &'static str) -> &'static str {
|
||||
match key {
|
||||
"Dock.Unnamed" => "Unnamed",
|
||||
"Dock.Close" => "Close",
|
||||
"Dock.Zoom In" => "Zoom In",
|
||||
"Dock.Zoom Out" => "Zoom Out",
|
||||
@@ -46,54 +53,3 @@ pub(crate) fn t(key: &'static str) -> &'static str {
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
|
||||
/// State used to move the window when the title bar area is dragged.
|
||||
struct WindowDragState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
/// Make an element behave like a window title bar: dragging it moves the
|
||||
/// window, and double-clicking zooms the window (or performs the platform's
|
||||
/// default title-bar double-click action on macOS).
|
||||
///
|
||||
/// Only the bar's non-interactive areas should get this — tabs are draggable
|
||||
/// (to reorder panels) and must not move the window.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
|
||||
|
||||
this.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = true;
|
||||
}),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
|
||||
if state.should_move {
|
||||
state.should_move = false;
|
||||
window.start_window_move();
|
||||
}
|
||||
}))
|
||||
.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
if cfg!(target_os = "macos") {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
window.zoom_window();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
//! The Signed appearance for a tab group.
|
||||
//!
|
||||
//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed
|
||||
//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything
|
||||
//! visible is here, ported from the vendored dock: the pill tab bar that
|
||||
//! doubles as the window title bar (with window controls, title-bar
|
||||
//! dragging, and previous/next tab buttons), the toolbar, the ellipsis menu,
|
||||
//! the dock collapse affordance, the drop placeholder, and the styled drag
|
||||
//! preview.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -27,23 +17,20 @@ use gpui_base::dock::{
|
||||
use gpui_base::{ElementExt, InteractiveElementExt, Tab, Tabs};
|
||||
use gpui_component::animation::{Lerp as _, ease_out_cubic};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::dock::{ClosePanel, PanelControl, PanelHandle, ToggleZoom};
|
||||
use gpui_component::menu::DropdownMenu as _;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::{
|
||||
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, title_bar_drag_handlers,
|
||||
window_controls,
|
||||
};
|
||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||
|
||||
/// The size the styled drag preview occupies, reported to base so a drop
|
||||
/// placeholder knows where to fly in from.
|
||||
/// 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.));
|
||||
|
||||
/// A panel's title, or its registered name when it reached base without this
|
||||
/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
|
||||
/// A panel's title, or its registered name when the panel has no handle.
|
||||
pub(crate) fn panel_title(
|
||||
panel: &Arc<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
@@ -56,9 +43,7 @@ pub(crate) fn panel_title(
|
||||
}
|
||||
|
||||
/// The preview that follows the cursor while a panel is dragged.
|
||||
///
|
||||
/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
|
||||
/// appearance half, reintroduced here.
|
||||
/// Base's `DragPanel` is the payload and draws nothing, this is the appearance half.
|
||||
struct DragPanelPreview {
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
}
|
||||
@@ -83,12 +68,8 @@ impl Render for DragPanelPreview {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the zoom affordance goes for the group's displayed panel, or `None`
|
||||
/// when there is none to offer.
|
||||
///
|
||||
/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
|
||||
/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
|
||||
/// whether zooming happens at all, and base refuses a zoom that fails it.
|
||||
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
let panel = group.active_panel()?;
|
||||
panel
|
||||
@@ -97,8 +78,8 @@ fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The left-most, top-most tab group in a container — where a left dock's
|
||||
/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
|
||||
/// The left-most, top-most tab group in a container.
|
||||
/// A left dock's collapse button lives in this group.
|
||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
@@ -107,9 +88,8 @@ fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-most, top-most tab group. A vertical split stacks its children,
|
||||
/// so its *first* child is the top one; a horizontal split's last child is
|
||||
/// the right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
|
||||
/// The right-most, top-most tab group.
|
||||
/// A vertical split picks its first child, a horizontal split picks its last.
|
||||
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
@@ -122,26 +102,17 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One tab group's appearance.
|
||||
///
|
||||
/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once
|
||||
/// per container — so the tab bar's scroll position and the measured
|
||||
/// title-bar geometry belong to the group they describe.
|
||||
/// One tab group's appearance, built once per container so its geometry is its own.
|
||||
pub(crate) struct SignedTabGroupSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
/// The displayed tab the last frame drew, so a change scrolls the new tab
|
||||
/// into view.
|
||||
/// The tab shown last frame, so a change scrolls the new one into view.
|
||||
last_active_ix: Cell<Option<usize>>,
|
||||
/// Bounds of the title bar row (the wrapper around the tab bar), in
|
||||
/// window coordinates. Measured via `on_prepaint` to position the
|
||||
/// title-bar drag overlay.
|
||||
/// Bounds of the title bar row, measured to place the title-bar drag overlay.
|
||||
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the tab bar's trailing empty space (right after the last
|
||||
/// tab), which marks where the draggable region starts.
|
||||
/// Bounds of the empty strip after the last tab, where the drag region starts.
|
||||
title_bar_strip_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the
|
||||
/// draggable region ends.
|
||||
/// Bounds of the suffix area, where the drag region ends.
|
||||
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
}
|
||||
|
||||
@@ -157,9 +128,8 @@ impl SignedTabGroupSkin {
|
||||
}
|
||||
}
|
||||
|
||||
/// A group that is the left dock's whole content with a single panel
|
||||
/// draws no chrome at all — the vendored dock rendered such a panel bare,
|
||||
/// and the sidebar is one.
|
||||
/// A group that is the left dock's only group, with one panel, draws no chrome.
|
||||
/// The vendored dock rendered such a panel bare and the sidebar is one.
|
||||
fn is_plain_sidebar_group(&self, group: &TabGroupContext, cx: &mut App) -> bool {
|
||||
let Some(area) = self.shared.area().upgrade() else {
|
||||
return false;
|
||||
@@ -174,12 +144,8 @@ impl SignedTabGroupSkin {
|
||||
left == group.node() && group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// The bottom or right dock whose root tab group this group is, if any.
|
||||
///
|
||||
/// Base bars a dock's only group from being dragged or closed, so the
|
||||
/// dock cannot be emptied. A bottom/right panel is supposed to be
|
||||
/// closable and movable, though — the vendored dock allowed exactly that
|
||||
/// — so the skin recognizes the group and routes around the bar.
|
||||
/// 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);
|
||||
@@ -191,10 +157,8 @@ impl SignedTabGroupSkin {
|
||||
})
|
||||
}
|
||||
|
||||
/// The drag payload for the tab at `ix`, or `None` when this group must
|
||||
/// not be rearranged. A locked group is never draggable; a group that is
|
||||
/// a bottom/right dock's only content still is, because the center is
|
||||
/// always there to land in.
|
||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||
/// A locked group never is, a bottom or right dock root always is.
|
||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||
if group.is_locked() {
|
||||
return None;
|
||||
@@ -205,8 +169,8 @@ impl SignedTabGroupSkin {
|
||||
group.drag_panel(ix, cx)
|
||||
}
|
||||
|
||||
/// Whether a dock's collapse affordance belongs in *this* group's tab
|
||||
/// bar, and which way it points. `None` means this group draws none.
|
||||
/// A dock's collapse button for this group's bar, or `None` when it does not belong.
|
||||
/// The icon direction depends on whether the dock is open.
|
||||
fn dock_toggle_button(
|
||||
&self,
|
||||
placement: DockPlacement,
|
||||
@@ -219,8 +183,7 @@ impl SignedTabGroupSkin {
|
||||
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
// A dock that does not exist is not collapsible, so this covers the
|
||||
// old `left_dock.is_some()` test too.
|
||||
// A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test.
|
||||
if !area.is_dock_collapsible(placement) {
|
||||
return None;
|
||||
}
|
||||
@@ -269,10 +232,8 @@ impl SignedTabGroupSkin {
|
||||
)
|
||||
}
|
||||
|
||||
/// The previous/next tab buttons shown in the tab bar's leading prefix.
|
||||
///
|
||||
/// Unlike the dock toggle button they always render, but are disabled at
|
||||
/// the ends of the tab strip (or while the panel is collapsed).
|
||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||
/// Always rendered, disabled at the strip ends or when collapsed.
|
||||
fn render_prev_next_tab_buttons(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -314,8 +275,7 @@ impl SignedTabGroupSkin {
|
||||
)
|
||||
}
|
||||
|
||||
/// The trailing controls: the panel's own buttons, the zoom affordance,
|
||||
/// and the ellipsis menu.
|
||||
/// The trailing controls, the panel's own buttons, zoom and the ellipsis menu.
|
||||
fn render_toolbar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -331,9 +291,8 @@ impl SignedTabGroupSkin {
|
||||
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/right dock's only panel cannot be closed through the
|
||||
// group (base keeps a dock's last group), but the skin handles that
|
||||
// close by removing the whole dock, so the item is offered.
|
||||
// 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)));
|
||||
@@ -406,9 +365,8 @@ impl SignedTabGroupSkin {
|
||||
}
|
||||
|
||||
/// One tab of the pill strip.
|
||||
///
|
||||
/// While collapsed, tabs lose the active style and all interactions, and
|
||||
/// the strip becomes the way a closed bottom dock is opened again.
|
||||
/// While collapsed, tabs lose the active style and all interactions.
|
||||
/// The strip is also how a closed bottom dock is opened again.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
&self,
|
||||
@@ -442,8 +400,7 @@ impl SignedTabGroupSkin {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
})
|
||||
// Pill presentation: the selected tab is the filled pill, the
|
||||
// rest are transparent until hovered.
|
||||
// Pill style, the selected tab is the filled pill, others show only on hover.
|
||||
.styles(|styles| {
|
||||
styles.selected(|style| {
|
||||
style
|
||||
@@ -466,8 +423,7 @@ impl SignedTabGroupSkin {
|
||||
move |_, window, cx| {
|
||||
group.select_tab(ix, window, cx);
|
||||
|
||||
// Clicking the strip of a collapsed bottom dock is how it
|
||||
// is opened again.
|
||||
// Clicking the strip of a collapsed bottom dock reopens it.
|
||||
if is_bottom_dock && collapsed {
|
||||
_ = area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
@@ -518,9 +474,8 @@ impl SignedTabGroupSkin {
|
||||
})
|
||||
}
|
||||
|
||||
/// The strip after the last tab: a drop target for panels and host-owned
|
||||
/// drag items. Its left edge (right after the last tab) marks the start
|
||||
/// of the title-bar drag overlay.
|
||||
/// The strip after the last tab, a drop target for panels and other drag items.
|
||||
/// Its left edge marks where the title-bar drag overlay starts.
|
||||
fn render_empty_space(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -550,9 +505,8 @@ impl SignedTabGroupSkin {
|
||||
let group = group.clone();
|
||||
let node = group.node();
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
// A panel dropped past its own last tab lands in the
|
||||
// final slot; one from elsewhere is appended in the
|
||||
// background.
|
||||
// A panel dropped past its own last tab lands in the final slot.
|
||||
// A panel from elsewhere is appended in the background.
|
||||
let ix = (drag.source() == node).then(|| tabs_count - 1);
|
||||
group.drop_panel(drag.clone(), ix, false, window, cx);
|
||||
}
|
||||
@@ -573,39 +527,30 @@ impl SignedTabGroupSkin {
|
||||
impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
let control = zoom_control(group, cx);
|
||||
// An emptied group — its last panel was dragged away — draws nothing,
|
||||
// so an emptied dock does not leave a bare tab bar behind.
|
||||
// An emptied group draws nothing, so no bare tab bar is left behind.
|
||||
if group.panels().is_empty() {
|
||||
return div().id("tab-panel");
|
||||
}
|
||||
// Closing the only panel of a bottom/right dock would leave an empty
|
||||
// dock, which base refuses through the group. The skin removes the
|
||||
// whole dock instead — the vendored dock's close took its split
|
||||
// group away just the same.
|
||||
// Base refuses an empty dock, so closing its only panel removes the dock.
|
||||
let dock_to_remove = (group.panels().len() <= 1)
|
||||
.then(|| self.is_dock_root_group(group, cx))
|
||||
.flatten();
|
||||
let shared = self.shared.clone();
|
||||
|
||||
// `v_flex`, not `div`: gpui's default display is Block, and in block
|
||||
// layout a child's `flex_grow` is ignored — the content region below
|
||||
// the tab bar would resolve to zero height.
|
||||
// `v_flex`, a plain `div` ignores `flex_grow` and the content would collapse.
|
||||
v_flex()
|
||||
.id("tab-panel")
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.background)
|
||||
// A collapsed group is a strip of tabs with no content, and the
|
||||
// actions act on content.
|
||||
// A collapsed group has no content, so these actions are not registered.
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = group.clone();
|
||||
move |_: &ToggleZoom, window, cx| {
|
||||
// The affordance decides the control, so a panel that
|
||||
// offers none is not zoomed *in* by the keybinding
|
||||
// either. Zooming out is never refused: a panel that
|
||||
// stopped offering the control while zoomed would
|
||||
// otherwise strand the user with no way back.
|
||||
// A panel with no zoom control is not zoomed in by the keybinding.
|
||||
// Zooming out is never refused.
|
||||
// Otherwise a zoomed panel that lost its control would strand the user.
|
||||
if !group.is_zoomed() && control.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -639,8 +584,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id("active-panel")
|
||||
// A collapsed group draws its tab strip and nothing else, so the
|
||||
// content region must not claim any space.
|
||||
// A collapsed group draws its tab strip only, so the content claims no space.
|
||||
.when(!group.is_collapsed(), |this| this.flex_1())
|
||||
}
|
||||
|
||||
@@ -650,14 +594,12 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
// An emptied group draws no tab bar; the app prunes the emptied
|
||||
// bottom/right dock a moment later.
|
||||
// An emptied group draws no tab bar, the app prunes the emptied dock later.
|
||||
if group.panels().is_empty() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
// The sidebar group draws no chrome at all, like the vendored dock's
|
||||
// bare `DockItem::Panel`.
|
||||
// The sidebar group draws no chrome, like the vendored `DockItem::Panel`.
|
||||
if self.is_plain_sidebar_group(group, cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
@@ -671,12 +613,9 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
// macOS: the traffic lights overlay the window's top-left corner. Only
|
||||
// the group whose tab bar actually sits under them must reserve the
|
||||
// space: the left dock (sidebar) normally clears them, and when it is
|
||||
// closed or absent it is the center's left-most, top-most tab group
|
||||
// that is in the corner. A bottom or right dock is never there, and
|
||||
// neither is the right panel of a center split.
|
||||
// On macOS the traffic lights overlay the window's top-left corner.
|
||||
// Only the tab bar that sits under them reserves the space.
|
||||
// That is the center's top-left group when the left dock is closed or absent.
|
||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||
let area = area.read(cx);
|
||||
@@ -687,8 +626,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
== Some(group.node())
|
||||
});
|
||||
|
||||
// Bring a newly displayed tab into view. The group owns selection
|
||||
// now, so the skin notices the change rather than being told about it.
|
||||
// Bring a newly displayed tab into view.
|
||||
// The group owns selection, so the skin watches for the change itself.
|
||||
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
|
||||
let visible: Vec<usize> = group
|
||||
.panels()
|
||||
@@ -703,11 +642,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
self.scroll_handle.scroll_to_item(visible_ix);
|
||||
}
|
||||
|
||||
// The tab strip lays out its scrollable content at content width, so
|
||||
// the area after the last tab only spans `min_w_16` — the rest of the
|
||||
// tab bar has no element at all. Cover that dead zone with a
|
||||
// measured overlay so the whole non-interactive area can drag the
|
||||
// window. Its span is [last tab's right edge, suffix's left edge].
|
||||
// The tab strip ends at the last tab, the bar has no element after it.
|
||||
// Cover that dead zone with an overlay so it can drag the window.
|
||||
let drag_overlay = match (
|
||||
self.title_bar_bounds.get(),
|
||||
self.title_bar_strip_bounds.get(),
|
||||
@@ -742,8 +678,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
if !panel.visible(cx) {
|
||||
return None;
|
||||
}
|
||||
// A collapsed group shows no tab as active: the strip is a
|
||||
// way back in, not a selection.
|
||||
// Collapsed tabs never show as active, the strip only reopens the dock.
|
||||
if collapsed {
|
||||
active = false;
|
||||
}
|
||||
@@ -782,7 +717,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
// Right -1 for avoid border overlap with the first tab
|
||||
// -1 px so the border does not overlap the first tab.
|
||||
.right(-px(1.))
|
||||
.h_full()
|
||||
.gap_2()
|
||||
@@ -866,9 +801,7 @@ impl TabGroupRenderer for SignedTabGroupSkin {
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let (from, to) = (indicator.from(), indicator.to());
|
||||
// The placeholder animates from wherever it was to where the drop
|
||||
// would land, so its own element is positioned at the destination and
|
||||
// the animation only has to walk the difference back to zero.
|
||||
// The element sits at the drop target, the animation walks back from the source.
|
||||
let offset = from.origin() - to.origin();
|
||||
|
||||
Some(
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
//! The Signed appearance for a tiles canvas.
|
||||
//!
|
||||
//! `gpui_base::dock::TilesState` owns the geometry — snapping, the resize
|
||||
//! arithmetic, the undo stack, the zoom flag — and draws none of it. The tile
|
||||
//! frame, its title bar and its resize affordances are here, ported from
|
||||
//! gpui-component's `TilesSkin` (the vendored dock had no tiles canvas, so
|
||||
//! there is no local look to preserve).
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
@@ -18,6 +10,7 @@ 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::{
|
||||
@@ -25,8 +18,8 @@ use gpui_component::{
|
||||
};
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::t;
|
||||
use crate::tab_panel::panel_title;
|
||||
use crate::{PanelHandle, t};
|
||||
|
||||
/// How far a resize handle sticks out past the tile's edge.
|
||||
const HANDLE_OFFSET: Pixels = px(-4.);
|
||||
@@ -52,9 +45,7 @@ impl Render for DragResizing {
|
||||
}
|
||||
|
||||
/// One tiles canvas's appearance.
|
||||
///
|
||||
/// Built per canvas — `DockAreaRenderer::tiles_renderer` is called once per
|
||||
/// container — so the scroll position belongs to the canvas it scrolls.
|
||||
/// Built once per container, so its scroll position belongs to the canvas it scrolls.
|
||||
pub(crate) struct SignedTilesSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
scroll_handle: ScrollHandle,
|
||||
@@ -101,13 +92,8 @@ impl SignedTilesSkin {
|
||||
})
|
||||
}
|
||||
|
||||
/// The trailing controls of a tile's title bar.
|
||||
///
|
||||
/// A tile has no tab bar to hang a toolbar off, so this is where its zoom,
|
||||
/// close and ellipsis menu live. The entries use click handlers rather
|
||||
/// than the [`ToggleZoom`](crate::ToggleZoom) and
|
||||
/// [`ClosePanel`](crate::ClosePanel) actions: those are dispatched to a
|
||||
/// focused tab group, and a tile is not one.
|
||||
/// 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,
|
||||
@@ -215,21 +201,17 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().tile_radius)
|
||||
// Room for the title bar, which is positioned over the padding so
|
||||
// the panel below it is never covered. Base draws the panel view
|
||||
// as a plain child, so this is the only way to keep the two from
|
||||
// overlapping.
|
||||
// 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 installs the stored bounds on an ordinary tile and nothing
|
||||
// at all on a zoomed one — how a zoomed tile fills the dock is
|
||||
// this skin's decision.
|
||||
// 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 with the pointer anywhere, so both halves are
|
||||
// needed; each is a no-op unless this tile is the one moving.
|
||||
// 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| {
|
||||
@@ -276,8 +258,7 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
)
|
||||
.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 there is nothing
|
||||
// for a move to mean; base refuses the gesture too.
|
||||
// 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, {
|
||||
@@ -311,10 +292,8 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
) -> AnyElement {
|
||||
let bounds = tile.bounds();
|
||||
|
||||
// A passive full-tile box so each handle is positioned against the
|
||||
// tile rather than against whatever the flow put it next to. It
|
||||
// registers no interaction of its own, so it does not shadow the panel
|
||||
// underneath.
|
||||
// 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()
|
||||
@@ -378,9 +357,7 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The panel of a tile gets `size_full` here; base draws the panel as a
|
||||
/// plain child, so without it a panel that does not size itself has no
|
||||
/// size.
|
||||
/// 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()))
|
||||
@@ -388,12 +365,8 @@ impl TilesRenderer for SignedTilesSkin {
|
||||
.size_full()
|
||||
}
|
||||
|
||||
/// The canvas scrollbar.
|
||||
///
|
||||
/// It has to be an overlay rather than one of the frame's own children:
|
||||
/// the frame is the scroll container and base appends the tiles after
|
||||
/// whatever the frame carries, so a scrollbar placed there would paint and
|
||||
/// hit-test underneath every tile.
|
||||
/// The canvas scrollbar, as an overlay.
|
||||
/// Placed inside the frame it would end up underneath every tile.
|
||||
fn render_overlay(
|
||||
&self,
|
||||
content: Size<Pixels>,
|
||||
|
||||
@@ -147,8 +147,7 @@ pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoEle
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
// Like native windows apps, the controls span the title bar but never
|
||||
// grow past the tab bar height.
|
||||
// The controls span the title bar but never grow past the tab bar height.
|
||||
.when(cfg!(target_os = "windows"), |this| {
|
||||
this.max_h(TAB_BAR_HEIGHT)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
//! Render-path smoke tests: the skin reads the dock area while rendering, and
|
||||
//! GPUI panics if an entity is read while it is leased (being updated). These
|
||||
//! pin that the first frame — docks, groups, tab bars — renders without
|
||||
//! tripping the lease check.
|
||||
|
||||
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
|
||||
@@ -84,12 +79,10 @@ fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
|
||||
});
|
||||
});
|
||||
|
||||
// The first frame walks every render hook — the dock frame, each group's
|
||||
// tab bar, the toolbar — all of which read the dock area.
|
||||
// The first frame walks every render hook, all of which read the dock area.
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
// Emptying a dock leaves an empty group behind; its render must also be
|
||||
// safe (and draw nothing).
|
||||
// Emptying a dock leaves an empty group, its render must also be safe.
|
||||
cx.update(|window, cx| {
|
||||
area.update(cx, |area, cx| {
|
||||
area.remove_panel(bottom, window, cx);
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
//! Paths to locations used by Signed.
|
||||
//!
|
||||
//! Follows the same pattern as Zed's `paths` crate: platform-correct base
|
||||
//! directories, resolved once and cached, with an optional custom data dir
|
||||
//! override for portable/dev installs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The application name, used to derive platform-specific data, config and
|
||||
/// cache directory paths.
|
||||
/// The application name.
|
||||
///
|
||||
/// It derives the platform-specific data, config and cache directory paths.
|
||||
pub const APP_NAME: &str = "Signed";
|
||||
|
||||
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on
|
||||
/// Linux/FreeBSD and the macOS `~/.config` fallback.
|
||||
/// Lowercased form of [`APP_NAME`].
|
||||
///
|
||||
/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
|
||||
pub const APP_NAME_LOWERCASE: &str = "signed";
|
||||
|
||||
/// A custom data directory override, set only by [`set_custom_data_dir`].
|
||||
static CUSTOM_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// The resolved data directory.
|
||||
/// On macOS, this is `~/Library/Application Support/Signed`.
|
||||
/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`.
|
||||
@@ -35,39 +28,24 @@ pub fn home_dir() -> PathBuf {
|
||||
dirs::home_dir().expect("failed to determine home directory")
|
||||
}
|
||||
|
||||
/// Returns the current user's Desktop folder, falling back to the home
|
||||
/// directory (or an empty path) when it can't be determined.
|
||||
/// Returns the current user's Desktop folder.
|
||||
///
|
||||
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||
pub fn desktop_dir() -> PathBuf {
|
||||
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Sets a custom directory for all user data, overriding the default data
|
||||
/// directory. Must be called before any other path operation. The directory
|
||||
/// is created if it doesn't exist and canonicalized to an absolute path.
|
||||
/// Returns the current user's Documents folder.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or
|
||||
/// if the directory cannot be created/canonicalized.
|
||||
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
|
||||
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
|
||||
panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
|
||||
}
|
||||
|
||||
CUSTOM_DATA_DIR.get_or_init(|| {
|
||||
let path = PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
|
||||
path.canonicalize()
|
||||
.expect("failed to canonicalize custom data directory")
|
||||
})
|
||||
/// Falls back to the home directory or an empty path when it cannot be determined.
|
||||
pub fn documents_dir() -> PathBuf {
|
||||
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Returns the path to the configuration directory.
|
||||
pub fn config_dir() -> &'static PathBuf {
|
||||
CONFIG_DIR.get_or_init(|| {
|
||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.join("config")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
if cfg!(target_os = "windows") {
|
||||
dirs::config_dir()
|
||||
.expect("failed to determine RoamingAppData directory")
|
||||
.join(APP_NAME)
|
||||
@@ -87,9 +65,7 @@ pub fn config_dir() -> &'static PathBuf {
|
||||
/// Returns the path to the data directory.
|
||||
pub fn data_dir() -> &'static PathBuf {
|
||||
CURRENT_DATA_DIR.get_or_init(|| {
|
||||
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
|
||||
custom_dir.clone()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir()
|
||||
.join("Library/Application Support")
|
||||
.join(APP_NAME)
|
||||
@@ -110,50 +86,13 @@ pub fn data_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the cache directory.
|
||||
pub fn cache_dir() -> &'static PathBuf {
|
||||
static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
CACHE_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine caches directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(target_os = "windows") {
|
||||
dirs::cache_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") {
|
||||
flatpak_xdg_cache.into()
|
||||
} else {
|
||||
dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory")
|
||||
}
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} else {
|
||||
home_dir().join(".cache").join(APP_NAME_LOWERCASE)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the logs directory.
|
||||
pub fn logs_dir() -> &'static PathBuf {
|
||||
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
LOGS_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir().join("Library/Logs").join(APP_NAME)
|
||||
} else {
|
||||
data_dir().join("logs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the nostr database directory (LMDB).
|
||||
/// Returns the path to the nostr database directory, LMDB.
|
||||
pub fn nostr_dir() -> &'static PathBuf {
|
||||
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
|
||||
}
|
||||
|
||||
/// Returns the path to the local git clone cache (grasp mirrors).
|
||||
/// Returns the path to the local git clone cache, the grasp mirrors.
|
||||
pub fn repos_dir() -> &'static PathBuf {
|
||||
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
|
||||
@@ -164,9 +103,3 @@ pub fn settings_file() -> &'static PathBuf {
|
||||
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `keymap.json` file.
|
||||
pub fn keymap_file() -> &'static PathBuf {
|
||||
static KEYMAP_FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "settings"
|
||||
description = "Persisted application settings for Signed."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
paths = { path = "../paths" }
|
||||
|
||||
gpui.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
@@ -0,0 +1,5 @@
|
||||
mod settings;
|
||||
mod store;
|
||||
|
||||
pub use settings::*;
|
||||
pub use store::*;
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The default grasp servers,
|
||||
/// offered while the user has not published a grasp list.
|
||||
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
|
||||
"wss://relay.ngit.dev",
|
||||
"wss://gitnostr.com",
|
||||
"wss://git.shakespeare.diy",
|
||||
];
|
||||
|
||||
/// How the application picks its appearance.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppearanceMode {
|
||||
/// Follow the system appearance, light or dark, at runtime.
|
||||
#[default]
|
||||
System,
|
||||
/// Always use the light theme.
|
||||
Light,
|
||||
/// Always use the dark theme.
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// Theme configuration,
|
||||
/// fields mirror the gpui-component `Theme` surface customized at startup.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ThemeSettings {
|
||||
/// Name of the light theme in the theme registry.
|
||||
pub light_theme: String,
|
||||
/// 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)]
|
||||
#[serde(default)]
|
||||
pub struct GraspServersSettings {
|
||||
/// Servers offered while the user has not published a grasp list, kind `10317`.
|
||||
pub default_servers: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for GraspServersSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_servers: DEFAULT_GRASP_SERVERS
|
||||
.iter()
|
||||
.map(|server| (*server).to_owned())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local repository scanning.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LocalReposSettings {
|
||||
/// The directories scanned for local git repositories,
|
||||
/// defaults to the user's Desktop and Documents folders.
|
||||
pub scan_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn default_scan_paths() -> Vec<PathBuf> {
|
||||
vec![paths::desktop_dir(), paths::documents_dir()]
|
||||
}
|
||||
|
||||
impl Default for LocalReposSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scan_paths: default_scan_paths(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A remembered association between a local checkout folder and an announced repository,
|
||||
/// recorded when the user clones a repository or picks a folder in the New PR panel.
|
||||
///
|
||||
/// The panel can then prefill the folder later without asking again.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutRecord {
|
||||
/// Local folder of the checkout.
|
||||
pub path: PathBuf,
|
||||
/// Repository address as a string, `30617:<pubkey>:<id>`.
|
||||
pub addr: String,
|
||||
/// Unix seconds of the last use, for freshest-first ordering.
|
||||
pub last_used: u64,
|
||||
}
|
||||
|
||||
/// Remembered local checkouts, see [`CheckoutRecord`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CheckoutsSettings {
|
||||
/// The remembered records.
|
||||
/// The latest use of a path and repo pair replaces the older record.
|
||||
pub records: Vec<CheckoutRecord>,
|
||||
}
|
||||
|
||||
/// The create-repository dialog.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CreateRepositorySettings {
|
||||
/// The folder the create-repository dialog defaults to, the user's Desktop when unset.
|
||||
pub default_folder: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The complete set of persisted application settings.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// How the application picks its appearance.
|
||||
pub appearance: AppearanceMode,
|
||||
/// Theme configuration.
|
||||
pub theme: ThemeSettings,
|
||||
/// Default grasp servers.
|
||||
pub grasp_servers: GraspServersSettings,
|
||||
/// Local repository scanning.
|
||||
pub local_repos: LocalReposSettings,
|
||||
/// Remembered local checkouts.
|
||||
pub checkouts: CheckoutsSettings,
|
||||
/// The create-repository dialog.
|
||||
pub create_repository: CreateRepositorySettings,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_app_conventions() {
|
||||
let settings = Settings::default();
|
||||
assert_eq!(settings.appearance, AppearanceMode::System);
|
||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||
assert_eq!(settings.theme.dark_theme, "Signed Dark");
|
||||
assert_eq!(settings.theme.font_size, 16.0);
|
||||
assert_eq!(settings.theme.mono_font_size, 13.0);
|
||||
assert_eq!(settings.theme.radius, 2.0);
|
||||
assert_eq!(settings.theme.radius_lg, 6.0);
|
||||
assert!(!settings.theme.focus_ring);
|
||||
assert!(!settings.theme.shadow);
|
||||
assert_eq!(
|
||||
settings.grasp_servers.default_servers,
|
||||
DEFAULT_GRASP_SERVERS.map(String::from).to_vec()
|
||||
);
|
||||
assert_eq!(settings.local_repos.scan_paths.len(), 2);
|
||||
assert_eq!(
|
||||
settings.local_repos.scan_paths,
|
||||
vec![paths::desktop_dir(), paths::documents_dir()]
|
||||
);
|
||||
assert_eq!(settings.create_repository.default_folder, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip_preserves_everything() {
|
||||
let settings = Settings {
|
||||
appearance: AppearanceMode::Dark,
|
||||
theme: ThemeSettings {
|
||||
radius: 8.0,
|
||||
..Default::default()
|
||||
},
|
||||
create_repository: CreateRepositorySettings {
|
||||
default_folder: Some(PathBuf::from("/tmp/repos")),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
let parsed: Settings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_keys_fall_back_to_defaults() {
|
||||
let settings: Settings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(settings, Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_json_merges_with_defaults() {
|
||||
let settings: Settings =
|
||||
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||
assert_eq!(settings.theme.radius, 4.0);
|
||||
// The rest of the theme and the other groups keep their defaults.
|
||||
assert_eq!(settings.theme.light_theme, "Signed Light");
|
||||
assert_eq!(settings.grasp_servers, GraspServersSettings::default());
|
||||
assert_eq!(settings.create_repository.default_folder, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_serializes_to_snake_case_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::System).unwrap(),
|
||||
"\"system\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::Light).unwrap(),
|
||||
"\"light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AppearanceMode::Dark).unwrap(),
|
||||
"\"dark\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use gpui::{App, Context, Entity, Global};
|
||||
|
||||
use crate::Settings;
|
||||
|
||||
struct GlobalSettingsStore(Entity<SettingsStore>);
|
||||
|
||||
impl Global for GlobalSettingsStore {}
|
||||
|
||||
/// The application settings,
|
||||
/// loaded from disk at startup and saved whenever they change.
|
||||
///
|
||||
/// Installed as a global by the app so any part of the UI can read and edit them.
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
/// Retrieve the global settings store, created at startup by the app.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalSettingsStore>().0.clone()
|
||||
}
|
||||
|
||||
/// Install the store as a global.
|
||||
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalSettingsStore(entity));
|
||||
}
|
||||
|
||||
/// Load the settings from `path`,
|
||||
/// falls back to defaults when the file is missing or unreadable.
|
||||
///
|
||||
/// Missing keys merge with the defaults,
|
||||
/// older settings files keep working as new settings are added.
|
||||
pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
settings: Self::load(path.as_ref()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of the current settings.
|
||||
pub fn settings(&self) -> &Settings {
|
||||
&self.settings
|
||||
}
|
||||
|
||||
/// Mutate the settings, persist them to disk, and notify observers.
|
||||
pub fn edit(&mut self, f: impl FnOnce(&mut Settings), cx: &mut Context<Self>) {
|
||||
f(&mut self.settings);
|
||||
if let Err(err) = self.save() {
|
||||
log::error!(
|
||||
"failed to save settings to {}: {err:#}",
|
||||
self.path.display()
|
||||
);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Read the settings file, merging any missing fields with the defaults.
|
||||
fn load(path: &Path) -> Settings {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) => match serde_json::from_str::<Settings>(&contents) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"failed to parse settings file {}: {err}; using defaults",
|
||||
path.display()
|
||||
);
|
||||
Settings::default()
|
||||
}
|
||||
},
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(),
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"failed to read settings file {}: {err}; using defaults",
|
||||
path.display()
|
||||
);
|
||||
Settings::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the settings to disk, replacing the file atomically.
|
||||
fn save(&self) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&self.settings)?;
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
// `rename` cannot replace an existing file on Windows.
|
||||
if cfg!(target_os = "windows") && self.path.exists() {
|
||||
std::fs::remove_file(&self.path)?;
|
||||
}
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use gpui::{AppContext, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
|
||||
static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// A unique, temporary settings path for one test.
|
||||
fn temp_settings_path() -> PathBuf {
|
||||
let n = TEST_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
std::env::temp_dir().join(format!(
|
||||
"signed-settings-test-{}-{n}.json",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
|
||||
fn cleanup(path: &Path) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(path.with_extension("json.tmp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_loads_defaults() {
|
||||
let path = temp_settings_path();
|
||||
cleanup(&path);
|
||||
|
||||
let settings = SettingsStore::load(&path);
|
||||
assert_eq!(settings, Settings::default());
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_loads_defaults() {
|
||||
let path = temp_settings_path();
|
||||
std::fs::write(&path, "{ not json").unwrap();
|
||||
|
||||
let settings = SettingsStore::load(&path);
|
||||
assert_eq!(settings, Settings::default());
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let path = temp_settings_path();
|
||||
cleanup(&path);
|
||||
|
||||
let mut expected = Settings::default();
|
||||
expected.create_repository.default_folder = Some(PathBuf::from("/tmp/repos"));
|
||||
|
||||
let store = SettingsStore {
|
||||
path: path.clone(),
|
||||
settings: expected.clone(),
|
||||
};
|
||||
store.save().unwrap();
|
||||
|
||||
assert_eq!(SettingsStore::load(&path), expected);
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn edit_mutates_and_persists(cx: &mut TestAppContext) {
|
||||
let path = temp_settings_path();
|
||||
cleanup(&path);
|
||||
|
||||
let store = cx.update(|cx| cx.new(|cx| SettingsStore::new(path.clone(), cx)));
|
||||
cx.read(|cx| assert_eq!(store.read(cx).settings(), &Settings::default()));
|
||||
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.theme.radius = 12.0, cx);
|
||||
});
|
||||
|
||||
cx.read(|cx| assert_eq!(store.read(cx).settings().theme.radius, 12.0));
|
||||
assert_eq!(SettingsStore::load(&path).theme.radius, 12.0);
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,8 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Address of a NIP-34 repository announcement: `30617:<owner-pubkey>:<repo-id>`.
|
||||
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
|
||||
///
|
||||
/// The Rust Nostr SDK's [`Coordinate`] already provides parsing, formatting
|
||||
/// and hashing for this; the alias keeps the repository-specific vocabulary
|
||||
/// while reusing the SDK type.
|
||||
/// 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;
|
||||
|
||||
/// Build the address of a NIP-34 repository announcement.
|
||||
pub fn repo_addr(owner: PublicKey, id: impl Into<String>) -> RepoAddr {
|
||||
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
|
||||
}
|
||||
|
||||
/// Derive a repository identifier from a display name
|
||||
pub fn identifier_from_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '/' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identifier_from_name_slugs_like_gitworkshop() {
|
||||
assert_eq!(identifier_from_name("My Repo"), "My-Repo");
|
||||
assert_eq!(identifier_from_name("my-repo"), "my-repo");
|
||||
assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-");
|
||||
assert_eq!(identifier_from_name("a/b"), "a/b");
|
||||
assert_eq!(identifier_from_name("Café"), "Caf-");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note
|
||||
/// attached to an issue, patch or PR by its author or a repository
|
||||
/// maintainer. Not part of the NIP-34 draft; read support for interop.
|
||||
/// 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`: it
|
||||
/// references the root via a lowercase `e` tag and was authored by the root
|
||||
/// author or a maintainer.
|
||||
/// 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;
|
||||
@@ -22,8 +24,8 @@ fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) ->
|
||||
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||
}
|
||||
|
||||
/// Whether a kind-1985 label event declares the `#t` namespace and carries at
|
||||
/// least one `["l", "<value>", "#t"]` label.
|
||||
/// 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| {
|
||||
@@ -32,10 +34,12 @@ fn has_hashtag_labels(event: &Event) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The effective hashtag labels of `root`: the `t` tags on the event itself
|
||||
/// (self-reported by its author) plus all labels attached via authorized
|
||||
/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all
|
||||
/// valid label events contribute (no latest-wins semantics).
|
||||
/// 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
|
||||
@@ -61,10 +65,10 @@ pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -
|
||||
labels
|
||||
}
|
||||
|
||||
/// The effective subject/title override of `root`, from authorized kind-1985
|
||||
/// label events in the `#subject` namespace. Only the latest event wins
|
||||
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
||||
/// semantics). Returns `None` when no valid override exists.
|
||||
/// 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],
|
||||
@@ -103,8 +107,8 @@ pub fn subject_override(
|
||||
})
|
||||
}
|
||||
|
||||
/// The effective hashtag labels and subject override of `root` in one pass
|
||||
/// (mirrors ngit's `get_labels_and_subject`).
|
||||
/// 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],
|
||||
@@ -116,9 +120,9 @@ pub fn labels_and_subject(
|
||||
)
|
||||
}
|
||||
|
||||
/// The effective cover note of `root`: the latest authorized kind-1624 event
|
||||
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
||||
/// semantics). Returns `None` when no valid cover note exists.
|
||||
/// Effective cover note of `root`.
|
||||
///
|
||||
/// Returns `None` when no valid cover note exists.
|
||||
pub fn cover_note<'a>(
|
||||
root: &Event,
|
||||
cover_notes: &'a [Event],
|
||||
|
||||
@@ -2,10 +2,10 @@ use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Target of a `nostr://` clone URL (NIP-34 "Nostr Clone URL format").
|
||||
/// Target of a `nostr://` clone URL, as defined by NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CloneTarget {
|
||||
/// `nostr://<naddr1...>` — direct repository address.
|
||||
/// `nostr://<naddr1...>` encodes a direct repository address.
|
||||
Addr(RepoAddr),
|
||||
/// `nostr://<npub|nip05>/[relay-hint/]<identifier>`
|
||||
UserRepo {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// A NIP-22 comment thread: a top-level comment on the root event and its
|
||||
/// nested replies (oldest first at every level).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommentThread {
|
||||
/// The thread's top-level comment.
|
||||
pub comment: Event,
|
||||
/// Replies to [`Self::comment`], nested recursively.
|
||||
pub replies: Vec<CommentThread>,
|
||||
}
|
||||
|
||||
/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for
|
||||
/// comments without one.
|
||||
fn comment_parent(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "e")
|
||||
.and_then(Tag::content)
|
||||
.and_then(|id| EventId::parse(id).ok())
|
||||
}
|
||||
|
||||
/// Group the comments on a root event (issue / patch / PR) into NIP-22
|
||||
/// threads. A comment whose parent is the root itself starts a thread; other
|
||||
/// comments nest under their parent comment. Threads and replies are ordered
|
||||
/// oldest-first. Replies whose parent comment is missing (e.g. not fetched)
|
||||
/// are placed as top-level threads so they are not dropped.
|
||||
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||
// Index comments by their parent id. Comments without a parent tag are
|
||||
// treated as replying to the root event itself.
|
||||
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
|
||||
for comment in comments {
|
||||
let parent = comment_parent(comment).unwrap_or(root.id);
|
||||
children.entry(parent).or_default().push(comment);
|
||||
}
|
||||
for list in children.values_mut() {
|
||||
list.sort_by_key(|event| event.created_at);
|
||||
}
|
||||
|
||||
let mut visited: HashSet<EventId> = HashSet::new();
|
||||
|
||||
fn build(
|
||||
id: EventId,
|
||||
children: &HashMap<EventId, Vec<&Event>>,
|
||||
visited: &mut HashSet<EventId>,
|
||||
) -> Vec<CommentThread> {
|
||||
let Some(list) = children.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut threads = Vec::new();
|
||||
for event in list {
|
||||
// Guards against malformed reply cycles.
|
||||
if visited.insert(event.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: (*event).clone(),
|
||||
replies: build(event.id, children, visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
threads
|
||||
}
|
||||
|
||||
let mut threads = build(root.id, &children, &mut visited);
|
||||
|
||||
// Orphan replies: their parent comment is unknown, so they never appear
|
||||
// in the tree rooted at the root event; surface them as top-level threads.
|
||||
let mut orphans: Vec<&Event> = comments
|
||||
.iter()
|
||||
.filter(|event| !visited.contains(&event.id))
|
||||
.collect();
|
||||
orphans.sort_by_key(|event| event.created_at);
|
||||
for comment in orphans {
|
||||
if visited.insert(comment.id) {
|
||||
threads.push(CommentThread {
|
||||
comment: comment.clone(),
|
||||
replies: build(comment.id, &children, &mut visited),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
threads
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn comment(keys: &Keys, parent: Option<&Event>, content: &str, created_at: u64) -> Event {
|
||||
let tags = parent
|
||||
.map(|parent| vec![Tag::parse(["e", &parent.id.to_hex()]).expect("valid e tag")])
|
||||
.unwrap_or_default();
|
||||
EventBuilder::new(Kind::Comment, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn flatten(threads: &[CommentThread]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for thread in threads {
|
||||
out.push(thread.comment.content.clone());
|
||||
out.extend(flatten(&thread.replies));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nests_replies_under_their_parents() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
let a1 = comment(&keys, Some(&a), "a1", 200);
|
||||
let a2 = comment(&keys, Some(&a), "a2", 300);
|
||||
let b = comment(&keys, Some(&root), "b", 150);
|
||||
|
||||
let threads = comment_threads(&root, &[a2, b, a, a1]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "a1", "a2", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_without_a_parent_tag_attach_to_the_root() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
// Old-style comments carried no `e` tag at all.
|
||||
let orphan = comment(&keys, None, "no parent", 100);
|
||||
|
||||
let threads = comment_threads(&root, &[orphan]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["no parent"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_replies_are_surfaced_as_top_level_threads() {
|
||||
let keys = Keys::generate();
|
||||
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let a = comment(&keys, Some(&root), "a", 100);
|
||||
|
||||
// `missing` is not in the comment set; its reply should still show up.
|
||||
let missing = EventBuilder::new(Kind::Comment, "missing")
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
let reply_to_missing = comment(&keys, Some(&missing), "reply to missing", 200);
|
||||
|
||||
let threads = comment_threads(&root, &[a, reply_to_missing]);
|
||||
|
||||
assert_eq!(flatten(&threads), vec!["a", "reply to missing"]);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@ use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// NIP-09 deletion requests and NIP-62 vanish requests, used to hide
|
||||
/// deleted events before they reach the UI.
|
||||
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
||||
/// built from the kind-5 and kind-62 events in the local database.
|
||||
///
|
||||
/// Built from the kind-5 / kind-62 events stored in the local database;
|
||||
/// pass any event through [`Deletions::is_deleted`] before displaying it.
|
||||
/// Deleted events are hidden before they reach the UI.
|
||||
///
|
||||
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
||||
pub struct Deletions {
|
||||
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
||||
ids: HashSet<(EventId, PublicKey)>,
|
||||
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
||||
///
|
||||
/// All versions of the addressable event up to `cutoff` are deleted.
|
||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||
/// `(author, cutoff)` from kind-62 vanish requests.
|
||||
@@ -34,8 +36,8 @@ impl Deletions {
|
||||
.map(|c| (c, event.pubkey, event.created_at)),
|
||||
);
|
||||
} else if event.kind == Kind::RequestToVanish {
|
||||
// Client-side we can't verify which relay the request targeted,
|
||||
// so any vanish request is honored for the author's events.
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -48,10 +50,9 @@ impl Deletions {
|
||||
}
|
||||
|
||||
/// Whether the event is covered by a valid deletion or vanish request.
|
||||
/// A request is valid when its author matches the deleted event's author, per NIP-09.
|
||||
///
|
||||
/// A request is only valid when its author matches the deleted event's
|
||||
/// author (NIP-09); addressable events are deleted up to the request's
|
||||
/// `created_at`.
|
||||
/// Addressable events are deleted up to the request's `created_at`.
|
||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||
if self
|
||||
.vanished
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||
|
||||
/// Kinds that make up the activity of a repository.
|
||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
Kind::Comment,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPullRequestUpdate,
|
||||
Kind::GitIssue,
|
||||
Kind::Comment,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequestUpdate,
|
||||
COVER_NOTE_KIND,
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Git root kinds that make a comment or cover note count as git activity.
|
||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPatch,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitRepoAnnouncement,
|
||||
];
|
||||
|
||||
/// Value of the first tag named `name` on `event`.
|
||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
/// Kind named by the first tag `name` on `event`.
|
||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||
tag_value(event, name)?.parse::<Kind>().ok()
|
||||
}
|
||||
|
||||
/// Latest announcement event for a repository.
|
||||
pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
@@ -23,7 +60,7 @@ pub fn announcement(addr: &RepoAddr) -> Filter {
|
||||
.identifier(addr.identifier.clone())
|
||||
}
|
||||
|
||||
/// Latest state event (refs / HEAD) for a repository.
|
||||
/// Latest state event for a repository, carrying refs and HEAD.
|
||||
pub fn state(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::RepoState)
|
||||
@@ -31,17 +68,19 @@ pub fn state(addr: &RepoAddr) -> Filter {
|
||||
.identifier(addr.identifier.clone())
|
||||
}
|
||||
|
||||
/// All NIP-34 activity addressed to a repository (`#a` tag): issues, PRs,
|
||||
/// patches, statuses and comments (kind 1111).
|
||||
///
|
||||
/// Note: the `a` tag on status events is optional per NIP-34, so statuses
|
||||
/// published without it won't be matched here.
|
||||
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
||||
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
||||
/// The `a` tag is optional on status events per NIP-34.
|
||||
/// Statuses published without it are not matched here.
|
||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
||||
}
|
||||
|
||||
/// Status events (`1630..=1633`) referencing a specific root event (`#e` tag).
|
||||
pub fn statuses_for(root: EventId) -> Filter {
|
||||
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||
/// They are matched via the `#e` tag. One filter covers all roots.
|
||||
///
|
||||
/// A negentropy sync reconciles them in a single session, not one per root.
|
||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
Kind::GitStatusOpen,
|
||||
@@ -49,36 +88,32 @@ pub fn statuses_for(root: EventId) -> Filter {
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
])
|
||||
.event(root)
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing a
|
||||
/// specific root event (`#e` tag), fetched per root like comments and
|
||||
/// statuses because they carry no repository `a` tag.
|
||||
pub fn annotations_for(root: EventId) -> Filter {
|
||||
/// 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])
|
||||
.event(root)
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// A user's grasp list (kind `10317`).
|
||||
/// A user's grasp list, kind `10317`.
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitUserGraspList)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-22 comments (kind `1111`) referencing any of the given root events
|
||||
/// (issues, patches, PRs).
|
||||
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||
/// The roots are issues, patches and PRs.
|
||||
///
|
||||
/// Comments are not addressed to the repository — they carry no `a` tag with
|
||||
/// the repo coordinate — so they must be fetched by their root reference
|
||||
/// instead. NIP-22 defines the uppercase `E` tag as the root of the thread
|
||||
/// (used by ngit) while some clients (including Signed itself) reference the
|
||||
/// root with a lowercase `e` tag, so both are matched.
|
||||
///
|
||||
/// Returns two filters because `#E` and `#e` conditions would be ANDed if
|
||||
/// combined into one.
|
||||
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
if roots.is_empty() {
|
||||
@@ -94,33 +129,100 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
]
|
||||
}
|
||||
|
||||
/// All repositories announced by an author.
|
||||
pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||
/// NIP-22 comments on our issues, patches and pull requests.
|
||||
/// They are matched via the uppercase `P` and `K` tags, not authorship.
|
||||
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(public_key)
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
/// All repository announcements (for global discovery).
|
||||
/// Activity directed at us: comments on our roots, and git events tagging us
|
||||
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
/// Git activity authored by `me`, for "Continue where you left off".
|
||||
///
|
||||
/// Unbounded: intended for negentropy sync, which reconciles sets
|
||||
/// efficiently regardless of size. Local database queries with this
|
||||
/// filter are served by LMDB, so they stay fast as the database grows.
|
||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||
/// through [`is_git_activity`] before display.
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(
|
||||
ACTIVITY_KINDS
|
||||
.into_iter()
|
||||
.chain(std::iter::once(COVER_NOTE_KIND)),
|
||||
)
|
||||
.author(me)
|
||||
}
|
||||
|
||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||
fn is_git_comment(event: &Event) -> bool {
|
||||
event.kind == Kind::Comment
|
||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
||||
fn is_git_cover_note(event: &Event) -> bool {
|
||||
event.kind == COVER_NOTE_KIND
|
||||
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a status event references a git root, checked via its `k` tag.
|
||||
fn is_git_status(event: &Event) -> bool {
|
||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether `event` is git activity worth showing in the activity list.
|
||||
pub fn is_git_activity(event: &Event) -> bool {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => is_git_comment(event),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => is_git_status(event),
|
||||
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
||||
}
|
||||
}
|
||||
|
||||
/// All repository announcements, for global discovery.
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`).
|
||||
/// How far back deletion requests are fetched and stored.
|
||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||
|
||||
/// `now` minus [`DELETIONS_LOOKBACK`].
|
||||
/// Quantized to whole days so identical filters hash the same.
|
||||
///
|
||||
/// Unbounded, like [`all_announcements`]: deletion requests must be known
|
||||
/// before any other event can be shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new().kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
/// This lets the backend's sync dedup match identical filters.
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
/// Deletion events relevant to a single repository: requests authored by
|
||||
/// the repository owner and requests addressed to the repository
|
||||
/// coordinate (`#a` tag).
|
||||
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
||||
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||
///
|
||||
/// Deletion requests must be known before any other event is shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.since(deletions_since())
|
||||
}
|
||||
|
||||
/// Deletion events relevant to a single repository.
|
||||
///
|
||||
/// Requests authored by the repository owner.
|
||||
///
|
||||
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
@@ -129,3 +231,94 @@ pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>) -> 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 root_git_kinds_are_activity() {
|
||||
for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] {
|
||||
assert!(is_git_activity(&signed(&keys(1), kind, Vec::new())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_activity_depends_on_the_uppercase_k_tag() {
|
||||
let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]);
|
||||
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!(is_git_activity(&on_git));
|
||||
assert!(is_git_activity(&on_repo));
|
||||
assert!(!is_git_activity(&on_note));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::Comment,
|
||||
Vec::new()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
||||
let status = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||
);
|
||||
let cover = signed(
|
||||
&keys(1),
|
||||
COVER_NOTE_KIND,
|
||||
vec![kind_tag("k", Kind::GitPatch)],
|
||||
);
|
||||
let unrelated = signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
vec![kind_tag("k", Kind::Metadata)],
|
||||
);
|
||||
|
||||
assert!(is_git_activity(&status));
|
||||
assert!(is_git_activity(&cover));
|
||||
assert!(!is_git_activity(&unrelated));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::GitStatusClosed,
|
||||
Vec::new()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_kinds_are_not_activity() {
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::TextNote,
|
||||
Vec::new()
|
||||
)));
|
||||
assert!(!is_git_activity(&signed(
|
||||
&keys(1),
|
||||
Kind::GitPullRequestUpdate,
|
||||
Vec::new(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,855 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
|
||||
|
||||
/// Window before `now` that an advanced cutoff retreats to.
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
|
||||
/// Window before `now` that a mark-all cutoff retreats to.
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
/// A thread of notification and own-activity events sharing one root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
/// The root issue, patch or pull request the events belong to.
|
||||
pub root: EventId,
|
||||
/// The root event itself, when it is known locally.
|
||||
pub root_event: Option<Event>,
|
||||
/// Kind of the root event, when it is known locally.
|
||||
pub root_kind: Option<Kind>,
|
||||
/// Repository the root belongs to, from the root's `a` tag.
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Notification events directed at the user, newest first.
|
||||
pub events: Vec<Event>,
|
||||
/// The user's own events in the thread, newest first.
|
||||
pub own_events: Vec<Event>,
|
||||
/// Unread event ids, oldest first.
|
||||
pub unread_ids: Vec<EventId>,
|
||||
/// Whether every notification event in the thread is archived.
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Title of the thread, read from its root issue/patch/PR when known.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(activity_subject)
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Kind shown for the thread.
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_kind.or_else(|| {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
})
|
||||
}
|
||||
|
||||
/// Timestamp of the newest event in the thread.
|
||||
pub fn latest_activity(&self) -> Timestamp {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.chain(self.own_events.first())
|
||||
.chain(self.events.first())
|
||||
.map(|event| event.created_at)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Up to `limit` events of the thread, oldest first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||
let mut seen: HashSet<EventId> = HashSet::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
if let Some(root) = &self.root_event {
|
||||
seen.insert(root.id);
|
||||
events.push(root.clone());
|
||||
}
|
||||
|
||||
let mut rest: Vec<Event> = self
|
||||
.own_events
|
||||
.iter()
|
||||
.chain(self.events.iter())
|
||||
.filter(|event| seen.insert(event.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
rest.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
rest.truncate(limit.saturating_sub(events.len()));
|
||||
events.extend(rest);
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
events
|
||||
}
|
||||
|
||||
/// Whether the thread has an unread event still visible in the inbox.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Recompute the unread and archived flags from `state`.
|
||||
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||
self.unread_ids = self
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| !state.is_read(event))
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
|
||||
// A thread without notification events is never archived.
|
||||
self.archived =
|
||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
||||
}
|
||||
}
|
||||
|
||||
/// Root issue, patch or pull request of a notification event.
|
||||
///
|
||||
/// Returns `None` when the event is not git-related, or when its root is a
|
||||
/// coordinate rather than an event.
|
||||
///
|
||||
/// - issue (1621) / PR (1618): itself
|
||||
/// - patch (1617): its `e` parent patch, else itself
|
||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||
/// - PR update (1619): uppercase `E`
|
||||
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
if event.kind == COVER_NOTE_KIND {
|
||||
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
||||
}
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match first_e_id(event) {
|
||||
Some(parent) => resolve_thread_root(parent, lookup),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => {
|
||||
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => {
|
||||
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Group notification events and the user's own events into one item per thread.
|
||||
pub fn group<E, O, L>(
|
||||
events: E,
|
||||
own: O,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &L,
|
||||
) -> Vec<InboxItem>
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
O: IntoIterator<Item = Event>,
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = notification_root(&event, lookup) else {
|
||||
continue;
|
||||
};
|
||||
groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in own {
|
||||
let root = notification_root(&event, lookup).unwrap_or(event.id);
|
||||
own_groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
|
||||
let mut items: Vec<InboxItem> = roots
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
sort_newest_first(&mut events);
|
||||
sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
root_kind: root_event.as_ref().map(|event| event.kind),
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next()),
|
||||
root_event,
|
||||
events,
|
||||
own_events,
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
item.apply_state(state);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
b.latest_activity()
|
||||
.cmp(&a.latest_activity())
|
||||
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
||||
});
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
/// Sort thread events newest first, ties broken by id.
|
||||
fn sort_newest_first(events: &mut [Event]) {
|
||||
events.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Read and archive state of the inbox, a high-water-mark model.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)]
|
||||
pub read_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub read_ids: HashSet<EventId>,
|
||||
#[serde(default)]
|
||||
pub archived_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub archived_ids: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
/// Whether `event` is at or before the read cutoff, or marked read.
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Whether `event` is at or before the archived cutoff, or marked archived.
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Mark one event read. Events at or before the cutoff are already read.
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark one event archived. Events at or before the cutoff are already archived.
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = now - MARK_ALL_WINDOW;
|
||||
self.read_before = cutoff;
|
||||
self.read_ids = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && event.created_at > cutoff)
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Advance the read cutoff to the newest point that keeps unread events
|
||||
/// unread, then prune the id set.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||
self.read_before = cutoff;
|
||||
prune_ids(&mut self.read_ids, all, cutoff);
|
||||
}
|
||||
|
||||
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||
self.is_archived(event)
|
||||
});
|
||||
self.archived_before = cutoff;
|
||||
prune_ids(&mut self.archived_ids, all, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
|
||||
fn advance_cutoff<M>(
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
now: Timestamp,
|
||||
current: Timestamp,
|
||||
is_marked: M,
|
||||
) -> Timestamp
|
||||
where
|
||||
M: Fn(&Event) -> bool,
|
||||
{
|
||||
let fallback = now - ADVANCE_WINDOW;
|
||||
|
||||
let oldest = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && !is_marked(event))
|
||||
.map(|event| event.created_at)
|
||||
.min();
|
||||
|
||||
let candidate = match oldest {
|
||||
Some(at) if at < fallback => at - 1,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
candidate.max(current)
|
||||
}
|
||||
|
||||
/// Drop ids whose event is unknown or now covered by the cutoff.
|
||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||
let created_at: HashMap<EventId, Timestamp> = all
|
||||
.iter()
|
||||
.map(|event| (event.id, event.created_at))
|
||||
.collect();
|
||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||
}
|
||||
|
||||
/// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||
fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option<Event>) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = lookup(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
|
||||
fn parent_id(event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| e_tag_with_marker(tag, marker))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != "e" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slice = tag.as_slice();
|
||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||
|
||||
if is_mention {
|
||||
return None;
|
||||
}
|
||||
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag.
|
||||
fn nip10_root_id(event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| first_e_id(event))
|
||||
}
|
||||
|
||||
/// First `e` tag id, in document order.
|
||||
fn first_e_id(event: &Event) -> Option<EventId> {
|
||||
first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
/// First uppercase `E` tag id, in document order.
|
||||
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
|
||||
first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != name {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Event id from a four-element `e` tag carrying `marker`.
|
||||
fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option<EventId> {
|
||||
let slice = tag.as_slice();
|
||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
|
||||
.expect("valid e tag")
|
||||
}
|
||||
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
fn a_tag(owner: &PublicKey, id: &str) -> Tag {
|
||||
Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag")
|
||||
}
|
||||
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
fn issue(author: &Keys, at: u64) -> Event {
|
||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||
}
|
||||
|
||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
||||
signed(
|
||||
author,
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
||||
at,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_and_pull_request_are_their_own_root() {
|
||||
let events = [
|
||||
issue(&keys(1), 100),
|
||||
signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100),
|
||||
];
|
||||
let lookup = lookup(&events);
|
||||
for event in &events {
|
||||
assert_eq!(notification_root(event, &lookup), Some(event.id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_resolves_to_its_uppercase_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), comment.clone()];
|
||||
assert_eq!(
|
||||
notification_root(&comment, &lookup(&events)),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_without_root_pointer_has_no_root() {
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![e_tag(&issue(&keys(1), 100))],
|
||||
200,
|
||||
);
|
||||
assert_eq!(notification_root(&comment, &lookup(&[])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_patch_resolves_to_the_root_patch() {
|
||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||
let events = [root_patch.clone(), child_patch.clone()];
|
||||
assert_eq!(
|
||||
notification_root(&child_patch, &lookup(&events)),
|
||||
Some(root_patch.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_resolves_via_the_root_marker() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let status = signed(
|
||||
&keys(2),
|
||||
Kind::GitStatusClosed,
|
||||
vec![marked_e_tag(&issue, "root")],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), status.clone()];
|
||||
assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_update_resolves_via_uppercase_e() {
|
||||
let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100);
|
||||
let update = signed(
|
||||
&keys(2),
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![uppercase_e_tag(&pr)],
|
||||
200,
|
||||
);
|
||||
let events = [pr.clone(), update.clone()];
|
||||
assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_comment_chain_follows_to_the_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||
let events = [issue.clone(), reply, nested.clone()];
|
||||
assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_excludes_self_and_sorts_groups_newest_first() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
let other_issue = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")],
|
||||
200,
|
||||
);
|
||||
let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400);
|
||||
|
||||
let events = [issue.clone(), comment.clone(), other_issue.clone(), mine];
|
||||
let items = group(
|
||||
events,
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
// The issue itself plus the comment; the self-authored comment is out.
|
||||
assert_eq!(items[0].events.len(), 2);
|
||||
assert_eq!(items[1].root, other_issue.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reports_unread_oldest_first_and_archived() {
|
||||
let me = keys(1);
|
||||
let issue = issue(&keys(2), 100);
|
||||
let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300);
|
||||
|
||||
let events = [issue.clone(), older.clone(), newer.clone()];
|
||||
let items = group(
|
||||
events,
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&[]),
|
||||
);
|
||||
assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]);
|
||||
assert!(!items[0].archived);
|
||||
assert!(items[0].is_unread());
|
||||
|
||||
let state = InboxReadState {
|
||||
archived_before: Timestamp::from_secs(1000),
|
||||
..Default::default()
|
||||
};
|
||||
let items = group(
|
||||
[issue.clone(), older, newer],
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&state,
|
||||
&lookup(&[]),
|
||||
);
|
||||
assert!(items[0].archived);
|
||||
assert!(!items[0].unread_ids.is_empty());
|
||||
assert!(!items[0].is_unread());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_reads_root_kind_and_address_from_the_root_event() {
|
||||
let me = keys(1);
|
||||
let owner_keys = keys(2);
|
||||
let owner = owner_keys.public_key();
|
||||
let issue = signed(
|
||||
&owner_keys,
|
||||
Kind::GitIssue,
|
||||
vec![a_tag(&owner, "my-repo")],
|
||||
100,
|
||||
);
|
||||
let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
|
||||
let events = [issue.clone(), comment];
|
||||
let items = group(
|
||||
events.clone(),
|
||||
Vec::new(),
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&events),
|
||||
);
|
||||
|
||||
assert_eq!(items[0].root_kind, Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].address, issue.tags.coordinates().next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_merges_own_events_into_the_matching_thread() {
|
||||
let me = keys(1);
|
||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
||||
let mine = signed(
|
||||
&me,
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
150,
|
||||
);
|
||||
let reply = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
||||
let items = group(
|
||||
[reply.clone()],
|
||||
[issue.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&context),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
assert_eq!(
|
||||
items[0].root_event.as_ref().map(|event| event.id),
|
||||
Some(issue.id)
|
||||
);
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
// The own events are kept apart from the notifications, newest first.
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.timeline(5)
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_all_read_marks_known_recent_events() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let recent = issue(&keys(2), now.as_secs() - 1000);
|
||||
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
|
||||
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
|
||||
let mine = issue(&keys(1), now.as_secs() - 100);
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_all_read(
|
||||
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
|
||||
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
|
||||
assert!(state.is_read(&recent));
|
||||
assert!(state.is_read(&ancient));
|
||||
assert!(!state.is_read(&mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_never_moves_the_cutoff_backwards() {
|
||||
let me = keys(1);
|
||||
let unread = issue(&keys(2), 1_000);
|
||||
let all = [unread];
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(999_999_999),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(&all, me.public_key(), now);
|
||||
|
||||
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let five_days = 5 * 24 * 60 * 60;
|
||||
let old_unread = issue(&keys(2), now.as_secs() - five_days);
|
||||
// Read ids that fall before and after the new cutoff.
|
||||
let stale = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - five_days - 1000,
|
||||
);
|
||||
let fresh = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - 100_000,
|
||||
);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_ids: HashSet::from([stale.id, fresh.id]),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(
|
||||
&[old_unread.clone(), stale.clone(), fresh.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, old_unread.created_at - 1);
|
||||
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_archived_skips_events_at_or_before_the_cutoff() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let event = issue(&keys(2), now.as_secs() - 1000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
archived_before: now,
|
||||
..Default::default()
|
||||
};
|
||||
state.mark_archived(&event);
|
||||
assert!(state.archived_ids.is_empty());
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_archived(&event);
|
||||
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_state_recomputes_unread_and_archived() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let first = issue(&keys(2), now.as_secs() - 2000);
|
||||
let second = issue(&keys(2), now.as_secs() - 1000);
|
||||
let mut item = InboxItem {
|
||||
root: first.id,
|
||||
root_event: None,
|
||||
root_kind: None,
|
||||
address: None,
|
||||
events: vec![second.clone(), first.clone()],
|
||||
own_events: Vec::new(),
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
|
||||
let state = InboxReadState {
|
||||
read_before: first.created_at,
|
||||
..Default::default()
|
||||
};
|
||||
item.apply_state(&state);
|
||||
|
||||
assert_eq!(item.unread_ids, vec![second.id]);
|
||||
assert!(!item.archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_preserves_state() {
|
||||
let first = issue(&keys(1), 100);
|
||||
let second = issue(&keys(2), 200);
|
||||
let state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(150),
|
||||
read_ids: HashSet::from([second.id]),
|
||||
archived_before: Timestamp::from_secs(50),
|
||||
archived_ids: HashSet::from([first.id]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&state).expect("serialized");
|
||||
let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized");
|
||||
|
||||
assert_eq!(parsed, state);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
pub mod addr;
|
||||
pub mod annotations;
|
||||
pub mod clone_url;
|
||||
pub mod comments;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod inbox;
|
||||
pub mod model;
|
||||
pub mod state;
|
||||
pub mod status;
|
||||
|
||||
pub use addr::{RepoAddr, repo_addr};
|
||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||
pub use comments::{CommentThread, comment_threads};
|
||||
pub use deletions::Deletions;
|
||||
pub use model::{Announcement, activity_subject, pull_request_patch};
|
||||
pub use filters::{
|
||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||
};
|
||||
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
|
||||
pub use model::{
|
||||
Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of,
|
||||
fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches,
|
||||
};
|
||||
pub use state::{build_state, parse_state};
|
||||
pub use status::{RepoStatus, references_root, resolve_status};
|
||||
|
||||
@@ -1,37 +1,77 @@
|
||||
use gpui::SharedString;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
|
||||
use crate::{RepoAddr, repo_addr};
|
||||
|
||||
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Announcement {
|
||||
/// Repository ID (`d` tag).
|
||||
/// 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 (for latest-wins resolution).
|
||||
/// When the announcement was published, used for latest-wins resolution.
|
||||
pub created_at: Timestamp,
|
||||
pub name: Option<SharedString>,
|
||||
pub description: Option<SharedString>,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// 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 (`r` tag with `euc` marker).
|
||||
/// Earliest unique commit ID, the `r` tag with `euc` marker.
|
||||
pub euc: Option<String>,
|
||||
/// Other recognized maintainers.
|
||||
pub maintainers: Vec<PublicKey>,
|
||||
/// Value of a `u` tag, if any: this repository is a subordinate fork of
|
||||
/// the referenced upstream (NIP-34).
|
||||
pub upstream: Option<String>,
|
||||
/// Hashtags labelling the repository (`t` tags).
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Subject of a NIP-34 issue or pull request event: the `subject` tag,
|
||||
/// falling back to the first non-empty line of the content.
|
||||
pub fn activity_subject(event: &Event) -> SharedString {
|
||||
/// 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) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => addr.to_string(),
|
||||
None => 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) -> String {
|
||||
let subject = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -41,24 +81,18 @@ pub fn activity_subject(event: &Event) -> SharedString {
|
||||
});
|
||||
|
||||
subject
|
||||
.map(SharedString::from)
|
||||
.or_else(|| {
|
||||
event
|
||||
.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(SharedString::from)
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or(SharedString::from("Untitled"))
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
/// The patch set of a pull request: the root patch event (kind `1617`) the
|
||||
/// PR references via its `e` tag, plus every patch of the set chained to it
|
||||
/// with NIP-10 `e` reply tags, in series order (oldest first). When the PR
|
||||
/// has no `e` tag, falls back to the patch producing the PR's tip commit
|
||||
/// (its `commit`/`r` tag, per NIP-34) and walks the reply chain backward to
|
||||
/// the root.
|
||||
/// 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>(
|
||||
@@ -67,17 +101,18 @@ pub fn pull_request_patches<'a>(
|
||||
) -> 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 of the set replies to the
|
||||
// previous one). Among several replies (a revision), the newest wins.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// No `e` tag: the last patch of the set carries the PR's tip commit in
|
||||
// its `commit`/`r` tag; walk the reply chain backward to the root.
|
||||
// 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();
|
||||
};
|
||||
@@ -108,10 +143,7 @@ pub fn pull_request_patches<'a>(
|
||||
series
|
||||
}
|
||||
|
||||
/// The patch content of a pull request: the contents of every patch event of
|
||||
/// its patch set (see [`pull_request_patches`]) joined in series order,
|
||||
/// falling back to the PR's own content for older PRs that carried the
|
||||
/// patch inline.
|
||||
/// 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());
|
||||
@@ -125,7 +157,7 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The chain of patches replying to `root` (NIP-10 `e` tags), oldest first.
|
||||
/// 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 {
|
||||
@@ -147,8 +179,8 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
|
||||
series
|
||||
}
|
||||
|
||||
/// The `c` tag of an event (tip of the proposed branch), as hex.
|
||||
fn current_commit_of(event: &Event) -> Option<String> {
|
||||
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
||||
pub fn current_commit_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -158,8 +190,85 @@ fn current_commit_of(event: &Event) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `patch` produces `commit` (its `commit` or `r` tag), so clients
|
||||
/// can find existing patches for a specific commit.
|
||||
/// The `merge-base` tag of an event, the base commit a pull request diffs against.
|
||||
pub fn merge_base_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `clone` tag of an event, URLs the tip commit can be fetched from.
|
||||
pub fn clone_urls_of(event: &Event) -> Option<Vec<Url>> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `branch-name` tag of an event, the proposed branch's name.
|
||||
pub fn branch_name_of(event: &Event) -> Option<String> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author.
|
||||
///
|
||||
/// A pull request's tip is only mutable by its author, per NIP-34; updates
|
||||
/// from anyone else are ignored even if they are newer.
|
||||
pub fn latest_update<'a>(
|
||||
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)
|
||||
}
|
||||
|
||||
/// The announced forks of `base` a new pull request compare can be built from.
|
||||
///
|
||||
/// The user's own forks are listed first.
|
||||
pub fn fork_candidates<'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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -171,7 +280,9 @@ fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing.
|
||||
/// 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;
|
||||
@@ -182,19 +293,19 @@ impl Announcement {
|
||||
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 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<String> = None;
|
||||
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::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),
|
||||
@@ -203,14 +314,19 @@ impl Announcement {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it
|
||||
// manually (first value wins).
|
||||
// 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" {
|
||||
upstream = tag.content().map(str::to_owned);
|
||||
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,
|
||||
@@ -227,21 +343,40 @@ impl Announcement {
|
||||
}
|
||||
|
||||
/// The repository address of this announcement.
|
||||
pub fn addr(&self) -> crate::RepoAddr {
|
||||
crate::repo_addr(self.owner, self.id.clone())
|
||||
pub fn addr(&self) -> RepoAddr {
|
||||
repo_addr(self.owner, self.id.clone())
|
||||
}
|
||||
|
||||
/// The name of the repository, or a default if none is provided.
|
||||
pub fn name(&self) -> String {
|
||||
self.name.clone().unwrap_or("Untitled".into())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub fn description(&self) -> String {
|
||||
self.description
|
||||
.clone()
|
||||
.unwrap_or(SharedString::from("No description"))
|
||||
.unwrap_or("No description".to_string())
|
||||
}
|
||||
|
||||
/// The effective maintainers of this repository: the announced
|
||||
/// `maintainers` plus the announcement author, who asserts themselves as
|
||||
/// a maintainer of the primary project unless a `u` tag marks this
|
||||
/// repository as a subordinate fork (NIP-34).
|
||||
/// 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) {
|
||||
@@ -249,6 +384,16 @@ impl Announcement {
|
||||
}
|
||||
maintainers
|
||||
}
|
||||
|
||||
/// The `git clone` URLs for this repository, deduplicated.
|
||||
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)]
|
||||
@@ -375,15 +520,127 @@ mod tests {
|
||||
fn parses_upstream_tag() {
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
|
||||
&[
|
||||
"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!(
|
||||
announcement.upstream.as_deref(),
|
||||
Some("30617:abc:upstream|https://example.com/upstream.git")
|
||||
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]
|
||||
@@ -393,8 +650,8 @@ mod tests {
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let maintainers = announcement.effective_maintainers();
|
||||
|
||||
// The owner asserts themselves as a maintainer of the primary
|
||||
// project (NIP-34), alongside the announced co-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")));
|
||||
@@ -411,8 +668,8 @@ mod tests {
|
||||
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 not a maintainer of the primary project (NIP-34).
|
||||
// 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,
|
||||
@@ -440,7 +697,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_falls_back_to_inline_content() {
|
||||
// Older PRs carried the patch in the content; no linked patch event.
|
||||
// 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");
|
||||
@@ -467,8 +724,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
// NIP-34: a PR references the root patch; later patches of the set
|
||||
// reply to the previous one (NIP-10 `e` tags).
|
||||
// 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)]);
|
||||
@@ -520,8 +777,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
// PRs without an `e` tag: the last patch of the set carries the tip
|
||||
// commit in its `r` tag; walk the reply chain backward to the root.
|
||||
// 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(
|
||||
@@ -546,4 +803,221 @@ mod tests {
|
||||
vec!["patch-one", "patch-two"]
|
||||
);
|
||||
}
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||
|
||||
/// Build a signed event of `kind` with the given tags and `created_at`.
|
||||
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_current_commit_and_branch_name() {
|
||||
let pr = pr_root();
|
||||
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
|
||||
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_without_pr_tags() {
|
||||
let pr = signed_at(Kind::GitPullRequest, vec![], 100);
|
||||
assert_eq!(current_commit_of(&pr), None);
|
||||
assert_eq!(branch_name_of(&pr), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_update_picks_newest_revision_of_the_root() {
|
||||
let root = pr_root();
|
||||
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,
|
||||
)
|
||||
};
|
||||
// An update revising a different PR must be ignored even though it is newer.
|
||||
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 = latest_update(events.iter(), &root).expect("an update");
|
||||
|
||||
assert_eq!(latest.created_at.as_secs(), 300);
|
||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
// The tip of a PR is only mutable by its author.
|
||||
// A newer update from anyone else must not win.
|
||||
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_update_ignores_roots_without_revisions() {
|
||||
let root = pr_root();
|
||||
assert!(latest_update([&root].into_iter(), &root).is_none());
|
||||
}
|
||||
|
||||
const OWNER_KEYS: [&str; 3] = [
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||
];
|
||||
|
||||
/// Build a signed kind-30617 event for `owner` with the given tags.
|
||||
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
|
||||
vec![
|
||||
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
|
||||
.expect("parses"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_candidates_orders_own_forks_first() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
let clone = "https://grasp.example/npub1x/my-fork.git";
|
||||
|
||||
let base_addr = crate::repo_addr(
|
||||
PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"),
|
||||
"upstream",
|
||||
);
|
||||
// Newest first, as RepoListStore keeps them.
|
||||
// Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag.
|
||||
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 = fork_candidates(&all, &base_addr, Some(euc), Some(user));
|
||||
|
||||
// The user's fork comes first, then the other author's.
|
||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
||||
}
|
||||
|
||||
#[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 = crate::repo_addr(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 = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner));
|
||||
assert_eq!(forks.len(), 1);
|
||||
assert_eq!(forks[0].id, "mirror");
|
||||
|
||||
// Without a base EUC only `u`-tag forks match.
|
||||
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 = fork_candidates(&all, &base_addr, None, Some(base_owner));
|
||||
let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["u-fork"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Build a kind `30618` repository state event from refs and HEAD.
|
||||
/// Build a kind `30618` repository state event from refs and HEAD,
|
||||
/// it is published as `ref: refs/heads/<branch>`.
|
||||
///
|
||||
/// `refs` are `(refname, commit-id)` pairs (e.g. `refs/heads/main`); `head`
|
||||
/// is the short branch name HEAD points to, published as
|
||||
/// `ref: refs/heads/<branch>`. The `d` tag matches the repository id.
|
||||
/// 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 {
|
||||
@@ -20,8 +19,8 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E
|
||||
|
||||
/// 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.
|
||||
/// `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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Status of a root patch, pull request or issue (kinds `1630..=1633`).
|
||||
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RepoStatus {
|
||||
Open,
|
||||
@@ -30,9 +30,9 @@ impl RepoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether an event references the given root event via an `e` or `E`
|
||||
/// tag. NIP-10 / NIP-34 use the lowercase `e` tag; NIP-22 comments (kind
|
||||
/// `1111`) use the uppercase `E` tag for the root of the thread.
|
||||
/// 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
|
||||
@@ -41,8 +41,8 @@ pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
|
||||
/// Resolve the status of a root event per NIP-34:
|
||||
/// the most recent status event from the root author or a maintainer wins.
|
||||
/// Resolve the status of a root event per NIP-34.
|
||||
///
|
||||
/// Defaults to [`RepoStatus::Open`].
|
||||
pub fn resolve_status<'a, I>(
|
||||
status_events: I,
|
||||
|
||||
@@ -9,7 +9,11 @@ signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
gix-worktree = "0.56"
|
||||
gix-worktree-state = "0.34"
|
||||
anyhow.workspace = true
|
||||
diffy = "0.5"
|
||||
ignore = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::remote::{clone_repo, fetch_all};
|
||||
|
||||
/// On-disk cache of cloned repositories, keyed by owner pubkey / repo id.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl GitCache {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// The root directory holding the mirror clones.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Local path of the clone for a repository.
|
||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||
self.root
|
||||
.join(addr.public_key.to_hex())
|
||||
.join(sanitize_path_component(&addr.identifier))
|
||||
}
|
||||
|
||||
/// Open an existing clone.
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<gix::Repository>> {
|
||||
let path = self.repo_path(addr);
|
||||
match gix::open(&path) {
|
||||
Ok(repo) => Ok(Some(repo)),
|
||||
Err(gix::open::Error::NotARepository { .. }) => Ok(None),
|
||||
Err(gix::open::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the existing clone, fetching it first.
|
||||
pub fn ensure_clone<U: AsRef<str>>(
|
||||
&self,
|
||||
addr: &RepoAddr,
|
||||
clone_urls: &[U],
|
||||
) -> Result<gix::Repository> {
|
||||
let path = self.repo_path(addr);
|
||||
|
||||
if let Some(repo) = self.open(addr)? {
|
||||
fetch_all(&repo).ok();
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
clone_repo(clone_urls, &path)?;
|
||||
self.open(addr)?
|
||||
.ok_or_else(|| anyhow::anyhow!("clone finished but the repository cannot be opened"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an untrusted repository id or display name to a safe single path component.
|
||||
///
|
||||
/// Everything outside `[A-Za-z0-9._-]` becomes `_`.
|
||||
/// An id that maps to exactly `.` or `..` becomes `_`.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if sanitized == "." || sanitized == ".." {
|
||||
return "_".to_owned();
|
||||
}
|
||||
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// The refs namespace of a fork's import in the target mirror.
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
announcement.owner.to_hex(),
|
||||
sanitize_path_component(&announcement.id)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||
|
||||
/// The kind of a [`DiffLine`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
/// An unchanged context line, present on both sides.
|
||||
Context,
|
||||
/// A line added by the commit.
|
||||
Addition,
|
||||
/// A line removed by the commit.
|
||||
Deletion,
|
||||
}
|
||||
|
||||
/// One line of a file diff.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffLine {
|
||||
pub kind: DiffLineKind,
|
||||
/// 1-based line number in the old version, if the line exists there.
|
||||
pub old: Option<u32>,
|
||||
/// 1-based line number in the new version, if the line exists there.
|
||||
pub new: Option<u32>,
|
||||
/// Line content without the trailing newline.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A hunk of a file diff, like `@@ -a,b +c,d @@`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
/// 1-based start line in the old version.
|
||||
pub old_start: u32,
|
||||
/// Number of old lines covered by the hunk.
|
||||
pub old_lines: u32,
|
||||
/// 1-based start line in the new version.
|
||||
pub new_start: u32,
|
||||
/// Number of new lines covered by the hunk.
|
||||
pub new_lines: u32,
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
/// How a file changed in a commit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffStatus {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
Copied,
|
||||
}
|
||||
|
||||
/// The diff of one file in a commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
/// Path of the file relative to the repo root.
|
||||
///
|
||||
/// For renames and copies, this is the destination path.
|
||||
pub path: String,
|
||||
/// Previous path, for renames and copies.
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
/// Number of added lines, 0 for binary files.
|
||||
pub insertions: usize,
|
||||
/// Number of removed lines, 0 for binary files.
|
||||
pub deletions: usize,
|
||||
/// True if either version is binary, then `hunks` is empty.
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
/// The changes of one commit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
/// The changes of the commit `id`, short or full, in the repository at `workdir`.
|
||||
///
|
||||
/// Compared against its first parent, the empty tree for the root commit.
|
||||
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
|
||||
commit_diff(&gix::open(workdir)?, id)
|
||||
}
|
||||
|
||||
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
|
||||
let commit_id = repo.rev_parse_single(id.as_bytes())?;
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
let new_tree = commit.tree()?;
|
||||
let old_tree = match commit.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
tree_diff(repo, old_tree.as_ref(), &new_tree)
|
||||
}
|
||||
|
||||
/// The changes between two commits, `base`..`tip`, like `git diff base tip`.
|
||||
///
|
||||
/// Directories and submodules are skipped, files are sorted by path.
|
||||
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let base_tree = repo
|
||||
.rev_parse_single(base.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
let tip_tree = repo
|
||||
.rev_parse_single(tip.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
tree_diff(&repo, Some(&base_tree), &tip_tree)
|
||||
}
|
||||
/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`].
|
||||
fn tree_diff(
|
||||
repo: &gix::Repository,
|
||||
old_tree: Option<&gix::Tree<'_>>,
|
||||
new_tree: &gix::Tree<'_>,
|
||||
) -> Result<CommitDiff> {
|
||||
use gix::diff::blob::platform::prepare_diff::Operation;
|
||||
use gix::object::tree::diff::Change;
|
||||
use gix::objs::tree::EntryKind;
|
||||
|
||||
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||
|
||||
let mut cache = repo.diff_resource_cache_for_tree_diff()?;
|
||||
let mut files = Vec::new();
|
||||
|
||||
for change in changes {
|
||||
let attached = Change::from_change_ref(change.to_ref(), repo, repo);
|
||||
|
||||
// Skip directory trees and submodule gitlinks, only files are listed.
|
||||
let (path, old_path, status) = match attached {
|
||||
Change::Addition {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Added)
|
||||
}
|
||||
Change::Deletion {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Deleted)
|
||||
}
|
||||
Change::Modification {
|
||||
location,
|
||||
previous_entry_mode,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
previous_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
(location.to_owned(), None, DiffStatus::Modified)
|
||||
}
|
||||
Change::Rewrite {
|
||||
location,
|
||||
source_location,
|
||||
source_entry_mode,
|
||||
entry_mode,
|
||||
copy,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit)
|
||||
&& !matches!(
|
||||
source_entry_mode.kind(),
|
||||
EntryKind::Tree | EntryKind::Commit
|
||||
) =>
|
||||
{
|
||||
let status = if copy {
|
||||
DiffStatus::Copied
|
||||
} else {
|
||||
DiffStatus::Renamed
|
||||
};
|
||||
(
|
||||
location.to_owned(),
|
||||
Some(source_location.to_owned()),
|
||||
status,
|
||||
)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Always diff with the built-in algorithm.
|
||||
// External diff drivers would shell out, out of scope for a read-only viewer.
|
||||
let platform = attached.diff(&mut cache)?;
|
||||
platform
|
||||
.resource_cache
|
||||
.options
|
||||
.skip_internal_diff_if_external_is_configured = true;
|
||||
let outcome = platform.resource_cache.prepare_diff()?;
|
||||
|
||||
let (binary, hunks, insertions, deletions) = match outcome.operation {
|
||||
Operation::InternalDiff { algorithm } => {
|
||||
let input = outcome.interned_input();
|
||||
let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input);
|
||||
|
||||
let mut hunks = Vec::new();
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let collector = HunkCollector {
|
||||
hunks: &mut hunks,
|
||||
insertions: &mut insertions,
|
||||
deletions: &mut deletions,
|
||||
};
|
||||
gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, Default::default())
|
||||
.consume()?;
|
||||
(false, hunks, insertions, deletions)
|
||||
}
|
||||
Operation::SourceOrDestinationIsBinary => (true, Vec::new(), 0, 0),
|
||||
Operation::ExternalCommand { .. } => unreachable!("external diff drivers are disabled"),
|
||||
};
|
||||
|
||||
files.push(FileDiff {
|
||||
path: String::from_utf8_lossy(&path).into_owned(),
|
||||
old_path: old_path.map(|p| String::from_utf8_lossy(&p).into_owned()),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary,
|
||||
hunks,
|
||||
});
|
||||
}
|
||||
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// Collects the hunks of one blob diff while tracking per-line numbers.
|
||||
struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
}
|
||||
|
||||
impl ConsumeHunk for HunkCollector<'_> {
|
||||
type Out = ();
|
||||
|
||||
fn consume_hunk(
|
||||
&mut self,
|
||||
header: HunkHeader,
|
||||
lines: &[(GixLineKind, &[u8])],
|
||||
) -> std::io::Result<()> {
|
||||
let mut old_ln = header.before_hunk_start;
|
||||
let mut new_ln = header.after_hunk_start;
|
||||
let mut out = Vec::with_capacity(lines.len());
|
||||
|
||||
for (kind, content) in lines {
|
||||
let text = String::from_utf8_lossy(content).into_owned();
|
||||
let line = match kind {
|
||||
GixLineKind::Context => {
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Context,
|
||||
old: Some(old_ln),
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Remove => {
|
||||
*self.deletions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Deletion,
|
||||
old: Some(old_ln),
|
||||
new: None,
|
||||
text,
|
||||
};
|
||||
old_ln += 1;
|
||||
line
|
||||
}
|
||||
GixLineKind::Add => {
|
||||
*self.insertions += 1;
|
||||
let line = DiffLine {
|
||||
kind: DiffLineKind::Addition,
|
||||
old: None,
|
||||
new: Some(new_ln),
|
||||
text,
|
||||
};
|
||||
new_ln += 1;
|
||||
line
|
||||
}
|
||||
};
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
self.hunks.push(DiffHunk {
|
||||
old_start: header.before_hunk_start,
|
||||
old_lines: header.before_hunk_len,
|
||||
new_start: header.after_hunk_start,
|
||||
new_lines: header.after_hunk_len,
|
||||
lines: out,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(self) {}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// In-memory object cache for history walks, see [`open_with_cache`].
|
||||
///
|
||||
/// Without one, a walk re-decodes the same commit objects from the object database.
|
||||
/// Sized generously: a walk can cover a large portion of the repository's history.
|
||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Metadata of a commit, as shown in the repository browser's file header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
/// Shortened commit id, 7+ hex chars, disambiguated if needed.
|
||||
pub id: String,
|
||||
/// First line of the commit message.
|
||||
pub summary: String,
|
||||
/// Rest of the commit message after the title.
|
||||
///
|
||||
/// `None` for single-line commit messages.
|
||||
pub description: Option<String>,
|
||||
/// Author name.
|
||||
pub author: String,
|
||||
/// Author time, seconds since the Unix epoch.
|
||||
pub time: i64,
|
||||
}
|
||||
|
||||
/// Open the repository at `workdir` with an in-memory object cache.
|
||||
///
|
||||
/// Only history walks use it, they re-decode the same commit objects repeatedly.
|
||||
/// Single-object reads open the repository plain.
|
||||
pub(crate) fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
||||
let mut repo = gix::open(workdir)?;
|
||||
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] from a commit, with author, message title, body and shortened id.
|
||||
///
|
||||
/// The diff panel fetches the full commit on demand.
|
||||
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
file_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
/// A [`FileCommit`] without the message body, for history lists that never display it.
|
||||
///
|
||||
/// Skipping the body saves an allocation per listed commit.
|
||||
fn file_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
file_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body.
|
||||
fn file_commit_with_description(
|
||||
commit: &gix::Commit<'_>,
|
||||
include_description: bool,
|
||||
) -> Result<FileCommit> {
|
||||
let author = commit.author()?;
|
||||
let message = commit.message()?;
|
||||
|
||||
Ok(FileCommit {
|
||||
id: commit.id().shorten_or_id().to_string(),
|
||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
description: if include_description {
|
||||
message
|
||||
.body
|
||||
.map(|body| String::from_utf8_lossy(body).trim().to_string())
|
||||
.filter(|body| !body.is_empty())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the most recent commit that changed `rel`, a path relative to the worktree.
|
||||
///
|
||||
/// `Ok(None)` when no commit touched the file, e.g. an untracked file.
|
||||
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||
let rel = rel.to_path_buf();
|
||||
Ok(last_commits(repo, std::slice::from_ref(&rel))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, commit)| commit))
|
||||
}
|
||||
|
||||
/// Newest commit touching each of `rels`, like `git log -1 -- <rel>` per path.
|
||||
/// `rels` are paths relative to the worktree.
|
||||
///
|
||||
/// Paths without any commit, like untracked files, are absent from the result.
|
||||
pub fn worktree_last_commits(
|
||||
workdir: &Path,
|
||||
rels: &[PathBuf],
|
||||
) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
last_commits(&open_with_cache(workdir)?, rels)
|
||||
}
|
||||
|
||||
/// The walk behind [`last_commit`] and [`worktree_last_commits`].
|
||||
///
|
||||
/// Stops as soon as every pending path has its commit.
|
||||
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// De-duplicate while preserving order.
|
||||
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||
|
||||
for rel in rels {
|
||||
if seen.insert(rel.as_path()) {
|
||||
pending.push(rel.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut found = Vec::new();
|
||||
for info in walk.all()? {
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
let info = info?;
|
||||
let commit = info.object()?;
|
||||
let tree = commit.tree()?;
|
||||
let parent_tree = match info.parent_ids().next() {
|
||||
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Compare each unresolved path against this commit and its first parent.
|
||||
// Resolved paths leave the pending set.
|
||||
let mut ix = 0;
|
||||
while ix < pending.len() {
|
||||
let rel = &pending[ix];
|
||||
let blob = tree.lookup_entry_by_path(rel)?;
|
||||
let parent_blob = match &parent_tree {
|
||||
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
||||
{
|
||||
found.push((rel.clone(), file_commit(&commit)?));
|
||||
pending.swap_remove(ix);
|
||||
} else {
|
||||
ix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Cap on [`CommitList::commits`]. The virtual list renders a window at a time,
|
||||
/// the tab badge shows the real count.
|
||||
///
|
||||
/// A huge history is never fully materialized in memory.
|
||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||
|
||||
/// Commits reachable from `HEAD`, newest first, possibly capped.
|
||||
pub struct CommitList {
|
||||
/// Number of commits reachable from HEAD.
|
||||
pub total: usize,
|
||||
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||
///
|
||||
/// Returns an empty list for a repository without any commits yet.
|
||||
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(CommitList {
|
||||
total: 0,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let walk = repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
));
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut total = 0;
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
total += 1;
|
||||
if commits.len() < MAX_LISTED_COMMITS {
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
/// Like [`all_commits`], but opens the repository at `workdir` first.
|
||||
///
|
||||
/// For non-bare clones the clone root is the worktree.
|
||||
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||
all_commits(&open_with_cache(workdir)?)
|
||||
}
|
||||
|
||||
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
|
||||
pub fn worktree_commit_range_commits(
|
||||
workdir: &Path,
|
||||
base: &str,
|
||||
tip: &str,
|
||||
) -> Result<Vec<FileCommit>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let repo = open_with_cache(workdir)?;
|
||||
let base_id = repo.rev_parse_single(base.as_bytes())?;
|
||||
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
|
||||
let walk = repo
|
||||
.rev_walk([tip_id])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
CommitTimeOrder::NewestFirst,
|
||||
))
|
||||
.with_hidden([base_id]);
|
||||
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for info in walk.all()? {
|
||||
let info = info?;
|
||||
commits.push(file_commit_summary(&info.object()?)?);
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
/// The commit HEAD points to, like `git log -1`.
|
||||
///
|
||||
/// `Ok(None)` for a repository without commits yet, an unborn HEAD.
|
||||
pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
||||
let Some(head) = repo.head_id().ok() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let commit = head.object()?.into_commit();
|
||||
Ok(Some(file_commit(&commit)?))
|
||||
}
|
||||
|
||||
/// Full metadata of the commit `id`, short or full, in the repository at `workdir`.
|
||||
/// Like [`head_commit`] for an arbitrary commit.
|
||||
///
|
||||
/// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn worktree_commit(workdir: &Path, id: &str) -> Result<Option<FileCommit>> {
|
||||
let repo = gix::open(workdir)?;
|
||||
match repo.rev_parse_single(id.as_bytes()) {
|
||||
Ok(commit_id) => {
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
Ok(Some(file_commit(&commit)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||
use diffy::{Hunk, Line};
|
||||
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
use crate::history::FileCommit;
|
||||
|
||||
/// Apply a `git format-patch` patch or series with `git am`,
|
||||
/// uses the git CLI because it handles the mbox format natively.
|
||||
///
|
||||
/// TODO: Replaced with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("am")
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git am`")?;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin piped")
|
||||
.write_all(patch.as_bytes())?;
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
|
||||
/// Fails when the range has no commits.
|
||||
///
|
||||
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
|
||||
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git format-patch`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git format-patch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
|
||||
if patch.trim().is_empty() {
|
||||
bail!("no commits between {base} and {tip}");
|
||||
}
|
||||
|
||||
Ok(patch)
|
||||
}
|
||||
|
||||
/// Split a `git format-patch` series into its individual patches, mbox messages.
|
||||
///
|
||||
/// A single patch yields one element.
|
||||
/// A malformed input yields one element covering it.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
let mut starts = vec![0usize];
|
||||
let mut search_from = 1;
|
||||
|
||||
while let Some(rel) = patch[search_from..].find("\nFrom ") {
|
||||
let ix = search_from + rel + 1;
|
||||
let hex = patch[ix + 5..]
|
||||
.split(|c: char| !c.is_ascii_hexdigit())
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if hex.len() == 40 {
|
||||
starts.push(ix);
|
||||
}
|
||||
search_from = ix + 1;
|
||||
}
|
||||
|
||||
starts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &start)| {
|
||||
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
|
||||
&patch[start..end]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
|
||||
/// `diff --git` headers, rename and copy detection, binary detection, and
|
||||
/// C-style quoted or octal-escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(file_diff(file?)?);
|
||||
}
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// The [`FileDiff`] of one parsed file patch.
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
||||
// component is dropped, the same way `git apply -p1` does.
|
||||
// Rename and copy paths come from their own headers, unprefixed.
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
operation => {
|
||||
stripped = operation.strip_prefix(1);
|
||||
&stripped
|
||||
}
|
||||
};
|
||||
|
||||
let (path, old_path, status) = match operation {
|
||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
||||
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
|
||||
FileOperation::Rename { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
||||
}
|
||||
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
|
||||
};
|
||||
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let mut hunks = Vec::new();
|
||||
|
||||
let patch = file.patch();
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let hunk = hunk_diff(hunk);
|
||||
insertions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Addition)
|
||||
.count();
|
||||
deletions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Deletion)
|
||||
.count();
|
||||
hunks.push(hunk);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FileDiff {
|
||||
path: path.to_owned(),
|
||||
old_path: old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary: patch.is_binary(),
|
||||
hunks,
|
||||
})
|
||||
}
|
||||
|
||||
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
|
||||
///
|
||||
/// `diffy` reports only the hunk header ranges. The per-line numbers are
|
||||
/// counted from them the way the header encodes them: context lines advance
|
||||
/// both sides, deletions only the old, insertions only the new.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
let mut old = old_range.start() as u32;
|
||||
let mut new = new_range.start() as u32;
|
||||
let mut lines = Vec::with_capacity(hunk.lines().len());
|
||||
|
||||
for line in hunk.lines() {
|
||||
let (kind, text) = match line {
|
||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||
};
|
||||
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(old), Some(new));
|
||||
old += 1;
|
||||
new += 1;
|
||||
numbers
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
let number = Some(new);
|
||||
new += 1;
|
||||
(None, number)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
let number = Some(old);
|
||||
old += 1;
|
||||
(number, None)
|
||||
}
|
||||
};
|
||||
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old: old_no,
|
||||
new: new_no,
|
||||
text: line_text(text),
|
||||
});
|
||||
}
|
||||
|
||||
DiffHunk {
|
||||
old_start: old_range.start() as u32,
|
||||
old_lines: old_range.len() as u32,
|
||||
new_start: new_range.start() as u32,
|
||||
new_lines: new_range.len() as u32,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of a parsed line without its line ending.
|
||||
///
|
||||
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
|
||||
fn line_text(text: &str) -> String {
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||
}
|
||||
|
||||
/// Commits of a `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
let lines: Vec<&str> = patch.lines().collect();
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
// A patch starts with its `From <id> <date>` envelope line.
|
||||
let Some(rest) = lines[i].strip_prefix("From ") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(id) = rest.split_whitespace().next() else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if id.len() != 40 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut author = String::new();
|
||||
let mut summary = String::new();
|
||||
let mut time = 0i64;
|
||||
|
||||
// Envelope headers run up to the blank line before the commit message.
|
||||
i += 1;
|
||||
while i < lines.len() && !lines[i].is_empty() {
|
||||
let header = lines[i];
|
||||
if let Some(value) = header.strip_prefix("From: ") {
|
||||
author = name_from_address(value);
|
||||
} else if let Some(value) = header.strip_prefix("Subject: ") {
|
||||
summary = strip_patch_prefix(value);
|
||||
} else if let Some(value) = header.strip_prefix("Date: ") {
|
||||
time = gix::date::parse(value.trim(), None)
|
||||
.map(|t| t.seconds)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
commits.push(FileCommit {
|
||||
id: id.to_string(),
|
||||
summary,
|
||||
description: None,
|
||||
author,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
/// The name part of a `From: Name <email>` header value.
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
None => from.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the patch prefix from a `Subject:` header.
|
||||
///
|
||||
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let Some(end) = rest.find(']') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||
rest[end + 1..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use gix::interrupt::IS_INTERRUPTED;
|
||||
use gix::progress::Discard;
|
||||
|
||||
/// Clone into `path` from the first working URL in `clone_urls`.
|
||||
///
|
||||
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
|
||||
pub fn clone_repo<U: AsRef<str>>(clone_urls: &[U], path: &Path) -> Result<()> {
|
||||
if path.exists() {
|
||||
bail!("destination {} already exists", path.display());
|
||||
}
|
||||
|
||||
try_each_url(clone_urls, "clone", |url| {
|
||||
let repo = clone(url, path)?;
|
||||
// The initial clone uses the default refspecs. Also fetch the `refs/nostr/*` PR refs.
|
||||
fetch_all(&repo).ok();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*` namespace.
|
||||
pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![
|
||||
gix::refspec::parse(
|
||||
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)?
|
||||
.to_owned(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
repo.find_remote("origin")?
|
||||
.connect(gix::remote::Direction::Fetch)?
|
||||
.prepare_fetch(Discard, options)?
|
||||
.receive(Discard, &IS_INTERRUPTED)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `commit` to `reference` on the server at `url`, from `repo_path`.
|
||||
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["push"])
|
||||
.arg(url)
|
||||
.arg(format!("{commit}:{reference}"))
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git push`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrite a grasp server URL to the https URL the git transport actually uses.
|
||||
///
|
||||
/// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs.
|
||||
/// The transport is git smart HTTP, so the scheme is rewritten for gix.
|
||||
fn transport_url(url: &str) -> String {
|
||||
url.strip_prefix("grasp://")
|
||||
.map(|rest| format!("https://{rest}"))
|
||||
.unwrap_or_else(|| url.to_owned())
|
||||
}
|
||||
|
||||
/// Run `attempt` against each URL in `urls` until one succeeds.
|
||||
///
|
||||
/// Returns the last error wrapped in `failed to {verb} from any mirror`,
|
||||
/// or `no clone URLs provided` when the list is empty.
|
||||
fn try_each_url<U: AsRef<str>, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(&str) -> Result<()>,
|
||||
{
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for url in urls {
|
||||
match attempt(url.as_ref()) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some(e) => Err(e).context(format!("failed to {verb} from any mirror")),
|
||||
None => bail!("no clone URLs provided"),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
|
||||
let url = transport_url(url);
|
||||
let url = gix::url::parse(url).context("invalid clone URL")?;
|
||||
|
||||
let mut prepare = gix::prepare_clone(url, path)?;
|
||||
let (mut checkout, _fetch) = prepare.fetch_then_checkout(Discard, &IS_INTERRUPTED)?;
|
||||
let (repo, _checkout) = checkout.main_worktree(Discard, &IS_INTERRUPTED)?;
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
/// Push the `main` branch of the repository at `repo_path` to a grasp server.
|
||||
pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push every local branch and tag of the repository at `repo_path` to a grasp server.
|
||||
///
|
||||
/// This mirrors an initialized repository's whole history.
|
||||
pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
push_refspecs(
|
||||
repo_path,
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`.
|
||||
fn push_refspecs(
|
||||
repo_path: &Path,
|
||||
base_url: &str,
|
||||
owner: &str,
|
||||
repo_id: &str,
|
||||
refspecs: &[&str],
|
||||
) -> Result<()> {
|
||||
let url = format!("{base_url}/{owner}/{repo_id}.git");
|
||||
|
||||
let mut args: Vec<&str> = Vec::with_capacity(refspecs.len() + 2);
|
||||
args.push("push");
|
||||
args.push(&url);
|
||||
args.extend_from_slice(refspecs);
|
||||
|
||||
let output = git_output(repo_path, &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `url` advertises every ref in `expected` at the given commit.
|
||||
///
|
||||
/// Extra advertised refs are ignored: the question is whether the data this
|
||||
/// push wanted to land is already there, not whether the remote is an exact mirror.
|
||||
/// This is the convergence probe for a push that lost the compare-and-swap race
|
||||
/// to the grasp server's own background ref alignment.
|
||||
pub fn remote_has_refs(repo_path: &Path, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||
if expected.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let repo = gix::open(repo_path)?;
|
||||
let url = transport_url(url);
|
||||
|
||||
// A URL-created remote has no configured fetch refspecs, and `ref_map` only
|
||||
// keeps refs that match one. Match each expected ref by its exact name,
|
||||
// like `git ls-remote <url> <name>` would; ref maps never write to the repository.
|
||||
let refspecs = expected
|
||||
.iter()
|
||||
.map(|(name, _)| {
|
||||
gix::refspec::parse(
|
||||
gix::bstr::BStr::new(format!("+{name}:{name}").as_bytes()),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.map(|spec| spec.to_owned())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.context("invalid refspec")?;
|
||||
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: refspecs,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (refs, _) = repo
|
||||
.remote_at(url.as_str())
|
||||
.with_context(|| format!("cannot use remote {url}"))?
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.with_context(|| format!("cannot connect to {url}"))?
|
||||
.ref_map(Discard, options)
|
||||
.with_context(|| format!("listing refs of {url} failed"))?;
|
||||
|
||||
// Peeled tag entries carry the tag object in their direct oid, so mapping
|
||||
// each advertised ref to its direct oid matches `git ls-remote` while
|
||||
// skipping the duplicated `^{}` lines.
|
||||
let advertised: HashMap<String, String> = refs
|
||||
.remote_refs
|
||||
.iter()
|
||||
.filter_map(|reference| {
|
||||
let (name, object, _peeled) = reference.unpack();
|
||||
object.map(|oid| (String::from_utf8_lossy(name).into_owned(), oid.to_string()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(expected
|
||||
.iter()
|
||||
.all(|(name, oid)| advertised.get(name.as_str()) == Some(oid)))
|
||||
}
|
||||
|
||||
/// Add `origin` pointing at `url` when the repository has no remote yet.
|
||||
///
|
||||
/// No-op if `origin` already exists.
|
||||
pub fn ensure_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
if repo.find_remote("origin").is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `git remote add` also configures the default fetch refspec.
|
||||
edit_local_config(&repo, |config| {
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Point `origin` at `url`, replacing an existing remote,
|
||||
/// used after a clone whose `origin` points at the cloned-from path.
|
||||
///
|
||||
/// A working copy cloned from a local mirror is re-targeted at the grasp server.
|
||||
pub fn set_origin(repo_path: &Path, url: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
let had_origin = repo.find_remote("origin").is_ok();
|
||||
|
||||
edit_local_config(&repo, |config| {
|
||||
// Replaces the existing url, like `git remote set-url origin <url>`.
|
||||
// A pre-existing fetch refspec is left untouched.
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
|
||||
if !had_origin {
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply `edit` to the repository-local configuration and persist it.
|
||||
///
|
||||
/// The config file is locked while it is read, edited and written back,
|
||||
/// like git would when running `git config` or `git remote`.
|
||||
fn edit_local_config(
|
||||
repo: &gix::Repository,
|
||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let config_path = repo.common_dir().join("config");
|
||||
|
||||
let mut lock = gix::lock::File::acquire_to_update_resource(
|
||||
&config_path,
|
||||
gix::lock::acquire::Fail::Immediately,
|
||||
None,
|
||||
)
|
||||
.context("failed to lock repository config")?;
|
||||
|
||||
let mut config =
|
||||
match gix::config::File::from_path_no_includes(config_path, gix::config::Source::Local) {
|
||||
Ok(config) => config,
|
||||
// A repository without a config file yet starts from scratch.
|
||||
Err(gix::config::file::init::from_paths::Error::Io { source, .. })
|
||||
if source.kind() == std::io::ErrorKind::NotFound =>
|
||||
{
|
||||
gix::config::File::default()
|
||||
}
|
||||
Err(error) => return Err(error).context("failed to read repository config"),
|
||||
};
|
||||
|
||||
edit(&mut config)?;
|
||||
|
||||
config
|
||||
.write_to(&mut lock)
|
||||
.context("failed to write repository config")?;
|
||||
|
||||
lock.commit().context("failed to save repository config")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch `refspec` into `repo_path` from the first working URL in `urls`.
|
||||
/// When no URL works, the last error is returned.
|
||||
///
|
||||
/// Never touches the checked-out refs or the worktree.
|
||||
pub fn fetch_repo_refs<U: AsRef<str>>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> {
|
||||
let repo = gix::open(repo_path)?;
|
||||
let refspec = gix::refspec::parse(
|
||||
gix::bstr::BStr::new(refspec),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.context("invalid fetch refspec")?
|
||||
.to_owned();
|
||||
|
||||
try_each_url(urls, "fetch", |url| {
|
||||
let url = transport_url(url);
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![refspec.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
repo.remote_at(url.as_str())
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.connect(gix::remote::Direction::Fetch)
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.prepare_fetch(Discard, options)
|
||||
.with_context(|| format!("fetch from {url} failed"))?
|
||||
.receive(Discard, &IS_INTERRUPTED)
|
||||
.with_context(|| format!("fetch from {url} failed"))?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The URL of the `origin` remote of the repository at `workdir`.
|
||||
///
|
||||
/// `None` when it has no `origin` yet.
|
||||
pub fn origin_url(workdir: &Path) -> Result<Option<String>> {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Ok(remote) = repo.find_remote("origin") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(remote
|
||||
.url(gix::remote::Direction::Fetch)
|
||||
.map(|url| url.to_string()))
|
||||
}
|
||||
|
||||
/// Run `git -C dir args`, disabling the terminal prompt and capturing stderr.
|
||||
///
|
||||
/// `what` names the command in the spawn error.
|
||||
pub(crate) fn git_output(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use crate::history::open_with_cache;
|
||||
use crate::worktree::{force_checkout, worktree_dirty};
|
||||
|
||||
/// The merge base of two revisions in the repository at `repo_path`,
|
||||
/// revisions may be branch names, remote-tracking refs or commit ids.
|
||||
///
|
||||
/// `Ok(None)` when the revisions share no common ancestor.
|
||||
///
|
||||
/// Unresolvable revisions are errors.
|
||||
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
|
||||
let repo = open_with_cache(repo_path)?;
|
||||
let a = repo.rev_parse_single(a.as_bytes())?;
|
||||
let b = repo.rev_parse_single(b.as_bytes())?;
|
||||
match repo.merge_base(a, b) {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
// No common ancestor, a valid outcome for a proposal.
|
||||
Err(gix::repository::merge_base::Error::NotFound { .. }) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commit HEAD points to in the repository at `repo_path`.
|
||||
///
|
||||
/// `None` when the repository has no commits yet, an unborn HEAD.
|
||||
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
|
||||
let Ok(repo) = gix::open(repo_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match repo.head_id() {
|
||||
Ok(id) => Ok(Some(id.to_string())),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The commits in `base..HEAD` of the repository at `repo_path`, oldest first.
|
||||
/// This is the order `git am` creates them.
|
||||
///
|
||||
/// `HEAD` alone when `base` is `None`.
|
||||
pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>> {
|
||||
let repo = match gix::open(repo_path) {
|
||||
Ok(repo) => repo,
|
||||
Err(_) if base.is_none() => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let head = match repo.head_id() {
|
||||
Ok(head) => head,
|
||||
Err(_) if base.is_none() => return Ok(Vec::new()),
|
||||
Err(e) => return Err(e).context("repository has no commits"),
|
||||
};
|
||||
|
||||
let Some(base) = base else {
|
||||
// `HEAD` alone when no base is given.
|
||||
return Ok(vec![head.to_string()]);
|
||||
};
|
||||
|
||||
let base = repo.rev_parse_single(base.as_bytes())?;
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for info in repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||
))
|
||||
.with_hidden([base])
|
||||
.all()?
|
||||
{
|
||||
commits.push(info?.id().to_string());
|
||||
}
|
||||
|
||||
// Oldest first, like `git rev-list --reverse`, the order `git am` creates them.
|
||||
commits.reverse();
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// The identity written to reflogs and commits created by this crate itself.
|
||||
///
|
||||
/// Like `git -c user.name=… -c user.email=…` per invocation: the repository works
|
||||
/// without a global git identity, and `gix` runs no hooks and never signs.
|
||||
pub(crate) fn repository_signature() -> (gix::actor::Signature, gix::date::parse::TimeBuf) {
|
||||
let seconds = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default();
|
||||
|
||||
let signature = gix::actor::Signature {
|
||||
name: gix::bstr::BString::from("Signed"),
|
||||
email: gix::bstr::BString::from("signed@localhost"),
|
||||
time: gix::date::Time { seconds, offset: 0 },
|
||||
};
|
||||
|
||||
(signature, gix::date::parse::TimeBuf::default())
|
||||
}
|
||||
|
||||
/// Create a repository at `path` with an initial `main` branch.
|
||||
/// Write a `README.md` from `name` and `description`, then create the initial commit.
|
||||
///
|
||||
/// Returns the initial commit id.
|
||||
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
std::fs::create_dir_all(path)
|
||||
.with_context(|| format!("failed to create {}", path.display()))?;
|
||||
|
||||
let repo = gix::init(path)?;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// The initial branch is `main`, regardless of `init.defaultBranch` in
|
||||
// the user's git configuration: point the unborn HEAD there.
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
repo.edit_references_as(
|
||||
[RefEdit {
|
||||
change: Change::Update {
|
||||
log: LogChange {
|
||||
mode: RefLog::AndReference,
|
||||
force_create_reflog: false,
|
||||
message: "checkout: moving to main".into(),
|
||||
},
|
||||
expected: PreviousValue::Any,
|
||||
new: gix::refs::Target::Symbolic(
|
||||
gix::refs::FullName::try_from("refs/heads/main")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?,
|
||||
),
|
||||
},
|
||||
name: head,
|
||||
deref: false,
|
||||
}],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
let readme = if description.trim().is_empty() {
|
||||
format!("# {name}\n")
|
||||
} else {
|
||||
format!("# {name}\n\n{description}\n")
|
||||
};
|
||||
|
||||
std::fs::write(path.join("README.md"), &readme).context("failed to write README.md")?;
|
||||
|
||||
let blob = repo.write_object(gix::objs::Blob {
|
||||
data: readme.into_bytes(),
|
||||
})?;
|
||||
|
||||
let tree = repo.write_object(gix::objs::Tree {
|
||||
entries: vec![gix::objs::tree::Entry {
|
||||
mode: gix::objs::tree::EntryKind::Blob.into(),
|
||||
filename: gix::bstr::BString::from("README.md"),
|
||||
oid: blob.into(),
|
||||
}],
|
||||
})?;
|
||||
|
||||
let commit = repo.commit_as(
|
||||
signature,
|
||||
signature,
|
||||
"HEAD",
|
||||
"Initial commit",
|
||||
tree,
|
||||
Vec::<gix::ObjectId>::new(),
|
||||
)?;
|
||||
|
||||
// Populate the index so the fresh repository is clean,
|
||||
// as `git add` and`git commit` would leave it.
|
||||
let mut index = repo.index_from_tree(&tree)?;
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
let commit = commit.to_string();
|
||||
if commit.len() != 40 {
|
||||
bail!("unexpected initial commit id: {commit}");
|
||||
}
|
||||
|
||||
Ok(commit)
|
||||
}
|
||||
|
||||
/// The earliest unique commit of the repository at `repo_path`.
|
||||
/// Used as the NIP-34 announcement's `euc` marker.
|
||||
///
|
||||
/// `None` for a repository without commits.
|
||||
pub fn root_commit(repo_path: &Path) -> Result<Option<String>> {
|
||||
let Ok(repo) = gix::open(repo_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Ok(head) = repo.head_id() else {
|
||||
// An unborn HEAD with no commits yet has no root commit.
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for info in repo
|
||||
.rev_walk([head])
|
||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||
gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
|
||||
))
|
||||
.all()?
|
||||
{
|
||||
let info = info?;
|
||||
if info.parent_ids().next().is_none() {
|
||||
let id = info.id().to_string();
|
||||
return Ok((id.len() == 40).then_some(id));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Full ref names under `prefix`, sorted lexicographically, like `git for-each-ref`.
|
||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||
///
|
||||
/// Returns an empty list when nothing matches.
|
||||
pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<Vec<String>> {
|
||||
let pattern = prefix.trim_end_matches('/');
|
||||
let repo = gix::open(repo_path)?;
|
||||
let mut names = Vec::new();
|
||||
|
||||
for reference in repo.references()?.all()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
let name = String::from_utf8_lossy(reference.name().as_bstr()).into_owned();
|
||||
|
||||
// Match the pattern itself and everything beneath it, like `git for-each-ref`.
|
||||
let under_pattern = name
|
||||
.strip_prefix(pattern)
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
|
||||
if under_pattern {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort lexicographically, like `git for-each-ref`.
|
||||
names.sort();
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Delete every ref under `prefix` of the repository at `repo_path`.
|
||||
/// `prefix` is a ref namespace like `refs/fork/<owner>/<id>`.
|
||||
pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> {
|
||||
use gix::refs::transaction::{Change, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
let refs = refs_with_prefix(repo_path, prefix)?;
|
||||
if refs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let repo = gix::open(repo_path)?;
|
||||
let edits: Vec<RefEdit> = refs
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let full = gix::refs::FullName::try_from(name.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name {name}: {e}"))?;
|
||||
Ok(RefEdit {
|
||||
change: Change::Delete {
|
||||
expected: PreviousValue::Any,
|
||||
log: RefLog::AndReference,
|
||||
},
|
||||
name: full,
|
||||
deref: false,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
// Delete all refs with the given prefix.
|
||||
repo.edit_references(edits)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to at `workdir`,
|
||||
/// `None` when detached or unreadable, like `git branch --show-current`.
|
||||
pub fn worktree_current_branch(workdir: &Path) -> Option<String> {
|
||||
let repo = gix::open(workdir).ok()?;
|
||||
let head = repo.head().ok()?;
|
||||
let name = head.referent_name()?;
|
||||
Some(String::from_utf8_lossy(name.shorten()).into_owned())
|
||||
}
|
||||
|
||||
/// Whether the reference `name` exists in the repository at `workdir`.
|
||||
pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return false;
|
||||
};
|
||||
repo.find_reference(name).is_ok()
|
||||
}
|
||||
|
||||
/// Fast-forward local branches that trail their remote-tracking counterpart.
|
||||
///
|
||||
/// Returns whether any branch moved.
|
||||
pub fn fast_forward_branches(workdir: &Path) -> Result<bool> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let current = worktree_current_branch(workdir);
|
||||
let heads = refs_with_prefix(workdir, "refs/heads")?;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
let mut moved = false;
|
||||
|
||||
for head in heads {
|
||||
let Some(branch) = head.strip_prefix("refs/heads/") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let remote = format!("refs/remotes/origin/{branch}");
|
||||
// No remote-tracking counterpart means the remote lacks this branch.
|
||||
let Ok(mut remote_reference) = repo.find_reference(&remote) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(mut local_reference) = repo.find_reference(&head) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(remote_oid) = remote_reference.peel_to_id() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(local_oid) = local_reference.peel_to_id() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let remote_oid = remote_oid.detach();
|
||||
let local_oid = local_oid.detach();
|
||||
|
||||
if local_oid == remote_oid {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only fast-forward.
|
||||
// Local-only commits or diverged history must never be rewritten by a refresh.
|
||||
let Ok(base) = repo.merge_base(local_oid, remote_oid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if base != local_oid {
|
||||
continue;
|
||||
}
|
||||
|
||||
let full = gix::refs::FullName::try_from(head.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
let edit = |new: gix::refs::Target| {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
RefEdit {
|
||||
change: Change::Update {
|
||||
log: LogChange {
|
||||
mode: RefLog::AndReference,
|
||||
force_create_reflog: false,
|
||||
message: format!("merge {remote}: Fast-forward").into(),
|
||||
},
|
||||
expected: PreviousValue::ExistingMustMatch(gix::refs::Target::Object(
|
||||
local_oid,
|
||||
)),
|
||||
new,
|
||||
},
|
||||
name: full.clone(),
|
||||
deref: false,
|
||||
}
|
||||
};
|
||||
|
||||
if current.as_deref() == Some(branch) {
|
||||
// Merge so the checked-out worktree follows the branch.
|
||||
// Only proceed on a clean worktree, like `git merge --ff-only`.
|
||||
if worktree_dirty(workdir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id;
|
||||
|
||||
// Check out the remote tree, discarding local changes.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
// Update the branch reference to point to the remote tree.
|
||||
repo.edit_references_as(
|
||||
[edit(gix::refs::Target::Object(remote_oid))],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
moved = true;
|
||||
} else {
|
||||
// Update the branch reference to point to the remote tree.
|
||||
repo.edit_references_as(
|
||||
[edit(gix::refs::Target::Object(remote_oid))],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Short names of local branches, `refs/heads/*`, of `repo`, sorted alphabetically.
|
||||
pub fn repo_branches(repo: &gix::Repository) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.local_branches()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Short names of tags, `refs/tags/*`, of `repo`, sorted alphabetically.
|
||||
pub fn repo_tags(repo: &gix::Repository) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
for reference in repo.references()?.tags()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
names.push(String::from_utf8_lossy(reference.name().shorten()).into_owned());
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Short names of local branches, `refs/heads/*`, sorted alphabetically.
|
||||
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||
repo_branches(&gix::open(workdir)?)
|
||||
}
|
||||
|
||||
/// Short name of the branch HEAD points to, or `None` when detached.
|
||||
///
|
||||
/// Detached after checking out a tag or a commit directly.
|
||||
pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
||||
let head = repo.head()?;
|
||||
let Some(name) = head.referent_name() else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
|
||||
}
|
||||
|
||||
/// Branch, tag and HEAD refs of a repository.
|
||||
///
|
||||
/// Ready for a NIP-34 kind-30618 repository state announcement.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoRefState {
|
||||
/// `(full refname, commit id)` pairs for heads and tags, sorted.
|
||||
pub refs: Vec<(String, String)>,
|
||||
/// Short branch name HEAD points to, or `None` when detached.
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
/// Collect the refs of `repo`.
|
||||
///
|
||||
/// Local branches and tags become `(refname, commit-id)` pairs.
|
||||
/// Also reports the branch HEAD points to.
|
||||
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||
let mut refs = Vec::new();
|
||||
|
||||
for reference in repo.references()?.local_branches()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
refs.push((
|
||||
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||
reference.id().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for reference in repo.references()?.tags()? {
|
||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
refs.push((
|
||||
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||
reference.id().to_string(),
|
||||
));
|
||||
}
|
||||
refs.sort();
|
||||
|
||||
let head = match repo.head() {
|
||||
Ok(head) => head
|
||||
.referent_name()
|
||||
.filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
|
||||
.map(|name| String::from_utf8_lossy(name.shorten()).into_owned()),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
Ok(RepoRefState { refs, head })
|
||||
}
|
||||
|
||||
/// [`repo_ref_state`] for the repository at `workdir`.
|
||||
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
|
||||
repo_ref_state(&gix::open(workdir)?)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
/// Maximum directory nesting depth when scanning for local repositories.
|
||||
///
|
||||
/// Pathological trees can't stall the scan.
|
||||
const SCAN_MAX_DEPTH: usize = 12;
|
||||
|
||||
/// Walk `root` recursively and collect the paths of git repositories below it,
|
||||
/// honouring `.gitignore` (and `.ignore`) files.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.max_depth(Some(SCAN_MAX_DEPTH))
|
||||
// Honour `.gitignore` even when the scan root is not itself a repository.
|
||||
.require_git(false)
|
||||
.build();
|
||||
|
||||
let mut repos: Vec<PathBuf> = walker
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_dir()))
|
||||
.map(ignore::DirEntry::into_path)
|
||||
.filter(|dir| dir.join(".git").exists())
|
||||
.filter_map(|dir| dir.canonicalize().ok())
|
||||
.collect();
|
||||
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
|
||||
// A repository nested inside another, like a submodule worktree, is not reported.
|
||||
let mut roots: Vec<PathBuf> = Vec::with_capacity(repos.len());
|
||||
for repo in repos {
|
||||
if !roots.iter().any(|kept| repo.starts_with(kept)) {
|
||||
roots.push(repo);
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use gix::progress::Discard;
|
||||
|
||||
use crate::history::{FileCommit, head_commit};
|
||||
use crate::repo::{current_branch, repository_signature};
|
||||
|
||||
/// Whether the worktree of `workdir` has uncommitted changes.
|
||||
///
|
||||
/// Best-effort: any read failure is reported as clean.
|
||||
pub fn worktree_dirty(workdir: &Path) -> bool {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Changes to tracked files, staged or not; untracked files are excluded.
|
||||
match repo.is_dirty() {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
|
||||
// Untracked files surface as `DirectoryContents` items of the index-vs-worktree walk,
|
||||
// tracked files only appear there when modified.
|
||||
let Ok(platform) = repo.status(Discard) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(mut changes) = platform.into_index_worktree_iter(Vec::<gix::bstr::BString>::new())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for change in changes.by_ref() {
|
||||
match change {
|
||||
Ok(gix::status::index_worktree::Item::DirectoryContents { .. }) => return true,
|
||||
Ok(_) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `workdir`.
|
||||
///
|
||||
/// Best-effort: 0 when the range cannot be computed.
|
||||
pub fn worktree_commits_ahead(workdir: &Path, base: &str, branch: &str) -> u32 {
|
||||
let Ok(repo) = gix::open(workdir) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let (Some(base), Some(branch)) = (resolve_commit(&repo, base), resolve_commit(&repo, branch))
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let Ok(walk) = repo.rev_walk([branch]).with_hidden([base]).all() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
walk.filter_map(Result::ok).count().min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Resolve `rev` to a commit id, accepting full refs,
|
||||
/// symbolic refs and the bare branch names callers pass, like git's DWIM.
|
||||
fn resolve_commit<'a>(repo: &'a gix::Repository, rev: &str) -> Option<gix::Id<'a>> {
|
||||
if let Ok(id) = repo.rev_parse_single(rev.as_bytes()) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
// Branch names arrive bare, like git resolving `main`.
|
||||
if rev.contains('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
repo.rev_parse_single(format!("refs/heads/{rev}").as_bytes())
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Relative paths of all entries in the worktree, files and directories.
|
||||
///
|
||||
/// The `.git` directory is skipped.
|
||||
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||
|
||||
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
||||
collect_entries(workdir, workdir, &mut entries)?;
|
||||
|
||||
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
||||
b_is_dir
|
||||
.cmp(a_is_dir)
|
||||
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
||||
});
|
||||
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
||||
}
|
||||
|
||||
/// Read a file from the worktree.
|
||||
///
|
||||
/// Returns `Ok(None)` if the path is missing or not a regular file.
|
||||
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||
let path = workdir.join(rel);
|
||||
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the README file in the repository root.
|
||||
///
|
||||
/// Falls back to any other file whose name starts with `readme`.
|
||||
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
for entry in std::fs::read_dir(workdir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if name.to_ascii_lowercase().starts_with("readme") {
|
||||
candidates.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by_key(|path| {
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||
match ext.as_deref() {
|
||||
Some("md") => 0,
|
||||
Some("markdown") => 1,
|
||||
Some("mdown") => 2,
|
||||
Some("mkdn") => 3,
|
||||
Some(_) => 5,
|
||||
None => 4,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||
}
|
||||
|
||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||
pub struct WorktreeSnapshot {
|
||||
/// Relative paths of all worktree entries, directories first.
|
||||
pub entries: Vec<PathBuf>,
|
||||
/// README path relative to the worktree, if any.
|
||||
pub readme_path: Option<PathBuf>,
|
||||
/// Contents of the README, if any.
|
||||
pub readme: Option<Vec<u8>>,
|
||||
/// Branch HEAD points to, `None` when detached, for example on a tag.
|
||||
pub current_branch: Option<String>,
|
||||
/// Commit HEAD points to, if any, see [`head_commit`].
|
||||
pub head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
/// Snapshot the worktree after a branch or tag switch.
|
||||
///
|
||||
/// Collects entries, the README, the branch HEAD points to and its commit.
|
||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let readme_path = find_readme(&repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => worktree_read(&repo, path)?,
|
||||
None => None,
|
||||
};
|
||||
Ok(WorktreeSnapshot {
|
||||
entries: worktree_entries(&repo)?,
|
||||
readme_path,
|
||||
readme,
|
||||
current_branch: current_branch(&repo)?,
|
||||
head_commit: head_commit(&repo)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check out `tree` into the worktree of `repo`
|
||||
pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> {
|
||||
let workdir = repo
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
let mut index = repo.index_from_tree(tree)?;
|
||||
|
||||
// Files the previous index tracked but `tree` no longer contains are removed,
|
||||
// like git deleting files that vanish between branches.
|
||||
if let Ok(previous) = repo.index_or_empty() {
|
||||
let keep: HashSet<PathBuf> = index
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(String::from_utf8_lossy(entry.path(&index)).into_owned()))
|
||||
.collect();
|
||||
for entry in previous.entries() {
|
||||
let rel = entry.path(&previous);
|
||||
let rel = PathBuf::from(String::from_utf8_lossy(rel).into_owned());
|
||||
|
||||
if keep.contains(&rel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = workdir.join(&rel);
|
||||
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("failed to remove {}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut options =
|
||||
repo.checkout_options(gix_worktree::stack::state::attributes::Source::IdMapping)?;
|
||||
options.overwrite_existing = true;
|
||||
|
||||
let objects = repo.objects.clone().into_arc()?;
|
||||
let files = gix::progress::Discard;
|
||||
let bytes = gix::progress::Discard;
|
||||
|
||||
// Check out the index into the worktree.
|
||||
gix_worktree_state::checkout(
|
||||
&mut index,
|
||||
workdir,
|
||||
objects,
|
||||
&files,
|
||||
&bytes,
|
||||
&gix::interrupt::IS_INTERRUPTED,
|
||||
options,
|
||||
)?;
|
||||
|
||||
// Write the index to disk.
|
||||
index.write(gix::index::write::Options::default())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point `HEAD` at `target` and record the switch in the reflog.
|
||||
fn move_head(
|
||||
repo: &gix::Repository,
|
||||
signature: gix::actor::SignatureRef<'_>,
|
||||
target: gix::refs::Target,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
|
||||
|
||||
let head = gix::refs::FullName::try_from("HEAD")
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
// Update the reference, creating a reflog entry.
|
||||
repo.edit_references_as(
|
||||
[RefEdit {
|
||||
change: Change::Update {
|
||||
log: LogChange {
|
||||
mode: RefLog::AndReference,
|
||||
force_create_reflog: false,
|
||||
message: message.into(),
|
||||
},
|
||||
expected: PreviousValue::Any,
|
||||
new: target,
|
||||
},
|
||||
name: head,
|
||||
deref: false,
|
||||
}],
|
||||
Some(signature),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the local branch `name`, HEAD stays attached to it.
|
||||
pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let full = format!("refs/heads/{name}");
|
||||
|
||||
let branch = gix::refs::FullName::try_from(full.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?;
|
||||
|
||||
let mut reference = repo.find_reference(&full)?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the branch, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
gix::refs::Target::Symbolic(branch),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the branch's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check out the tag `name`, HEAD becomes detached at the tagged commit.
|
||||
pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> {
|
||||
let repo = gix::open(workdir)?;
|
||||
let full = format!("refs/tags/{name}");
|
||||
|
||||
let mut reference = repo.find_reference(&full)?;
|
||||
|
||||
let commit = reference.peel_to_id()?;
|
||||
let tree = reference.peel_to_tree()?.id;
|
||||
|
||||
let (signature, mut time_buf) = repository_signature();
|
||||
let signature = signature.to_ref(&mut time_buf);
|
||||
|
||||
// Move HEAD to the tag, creating a reflog entry.
|
||||
move_head(
|
||||
&repo,
|
||||
signature,
|
||||
gix::refs::Target::Object(commit.detach()),
|
||||
&format!("checkout: moving to {name}"),
|
||||
)?;
|
||||
|
||||
// Check out the tag's tree, replacing index + worktree.
|
||||
force_checkout(&repo, &tree)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == ".git" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_dir = entry.file_type()?.is_dir();
|
||||
let path = entry.path();
|
||||
let rel = path.strip_prefix(root)?.to_path_buf();
|
||||
out.push((rel, is_dir));
|
||||
|
||||
if is_dir {
|
||||
collect_entries(root, &path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -5,9 +5,6 @@ edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
nostr-connect.workspace = true
|
||||
nostr-gossip-memory.workspace = true
|
||||
|
||||
@@ -12,11 +12,6 @@ use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::signer::UniversalSigner;
|
||||
|
||||
/// Open (or create) the LMDB database at `db_path` and build a client
|
||||
/// configured for Signed, together with a fresh signer.
|
||||
///
|
||||
/// The SDK manages its own internal tokio runtime; the returned client can be
|
||||
/// driven by GPUI's executors.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
|
||||
let signer = UniversalSigner::new(Keys::generate());
|
||||
@@ -26,7 +21,7 @@ pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, Universal
|
||||
Ok(with_database(signer, database))
|
||||
}
|
||||
|
||||
/// In-memory database on wasm (no LMDB available).
|
||||
/// 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());
|
||||
|
||||
@@ -31,8 +31,7 @@ impl UniversalSignerError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased signer whose inner signer can be swapped in-place
|
||||
/// (e.g. after login/logout). All clones see the swap.
|
||||
/// A type-erased signer whose inner signer can be swapped in-place.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UniversalSigner {
|
||||
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A lightweight "something changed" signal for the UI.
|
||||
///
|
||||
/// Heavy data stays in the database; consumers re-query on receipt.
|
||||
/// A lightweight change notification for the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Update {
|
||||
pub kind: Kind,
|
||||
/// First `a` tag value of the event, if any (e.g. the repository coordinate).
|
||||
/// First `a` tag value of the event, if any, for example the repository coordinate.
|
||||
pub coordinate: Option<Coordinate>,
|
||||
pub author: PublicKey,
|
||||
pub event_id: EventId,
|
||||
}
|
||||
|
||||
impl Update {
|
||||
@@ -21,7 +18,6 @@ impl Update {
|
||||
kind: event.kind,
|
||||
coordinate,
|
||||
author: event.pubkey,
|
||||
event_id: event.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ publish.workspace = true
|
||||
signed_core = { path = "../signed_core" }
|
||||
signed_git = { path = "../signed_git" }
|
||||
signed_nostr = { path = "../signed_nostr" }
|
||||
settings = { path = "../settings" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
nostr.workspace = true
|
||||
@@ -18,8 +19,13 @@ bitcoin_hashes = "1"
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rustls = "0.23"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -7,17 +7,12 @@ struct GlobalGitStore(GitCache);
|
||||
|
||||
impl Global for GlobalGitStore {}
|
||||
|
||||
/// Global access to the on-disk git clone cache (grasp mirrors).
|
||||
///
|
||||
/// Installed at startup via [`GitStore::set_global`]; see also
|
||||
/// [`signed_state::init`].
|
||||
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitStore(GitCache);
|
||||
|
||||
impl GitStore {
|
||||
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||
/// Replaces any previously installed store (see [`signed_state::init`], which
|
||||
/// installs an empty one).
|
||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||
let store = Self::new(root);
|
||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||
@@ -25,10 +20,6 @@ impl GitStore {
|
||||
}
|
||||
|
||||
/// The app-wide clone cache.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if [`GitStore::set_global`] was never called.
|
||||
pub fn global(cx: &App) -> Self {
|
||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{AppContext, Context, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
/// The user's persisted inbox read state.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox {
|
||||
state: InboxReadState,
|
||||
/// Set once the stored state has been read for the current user.
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
/// The current read/archive cutoffs.
|
||||
pub fn state(&self) -> &InboxReadState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Whether the stored state has been read for the current user.
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.loaded
|
||||
}
|
||||
|
||||
/// Mark the events of one notification group read, then bound the id sets.
|
||||
pub fn mark_read(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
for event in group {
|
||||
self.state.mark_read(event);
|
||||
}
|
||||
self.state.advance_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Archive one notification group. Archived events are always read too.
|
||||
pub fn mark_archived(
|
||||
&mut self,
|
||||
group: &[Event],
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
for event in group {
|
||||
self.state.mark_archived(event);
|
||||
self.state.mark_read(event);
|
||||
}
|
||||
|
||||
let now = Timestamp::now();
|
||||
self.state.advance_archived(all, me, now);
|
||||
self.state.advance_read(all, me, now);
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Mark every known notification read.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
|
||||
self.state.mark_all_read(all, me, Timestamp::now());
|
||||
self.persist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Load the stored state for current user.
|
||||
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
cx.notify();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let work = cx.background_spawn(async move { load_state(&client, me).await });
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// Clear the state of the signed-out user.
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.state = InboxReadState::default();
|
||||
self.loaded = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sign the state with a random key and store it locally.
|
||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let state = self.state.clone();
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
if let Err(error) = save_state(&client, me, &state).await {
|
||||
log::warn!("failed to save inbox state: {error}");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the inbox home screen's threads for `me` from the local database.
|
||||
pub async fn query_inbox(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
) -> Result<(Vec<InboxItem>, usize), Error> {
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?;
|
||||
|
||||
let mut activity = Vec::new();
|
||||
for event in client
|
||||
.database()
|
||||
.query(filters::authored_activity(me))
|
||||
.await?
|
||||
{
|
||||
if deletions.is_deleted(&event) || !filters::is_git_activity(&event) {
|
||||
continue;
|
||||
}
|
||||
by_id.entry(event.id).or_insert_with(|| event.clone());
|
||||
activity.push(event);
|
||||
}
|
||||
|
||||
let items = inbox::group(notification_events, activity, me, state, &|id| {
|
||||
by_id.get(&id).cloned()
|
||||
});
|
||||
|
||||
let unread_count = items.iter().filter(|item| item.is_unread()).count();
|
||||
|
||||
Ok((items, unread_count))
|
||||
}
|
||||
|
||||
/// `d` tag identifying the inbox state event of `me`.
|
||||
fn inbox_state_d_tag(me: PublicKey) -> String {
|
||||
format!("signed-inbox-state:{}", me.to_hex())
|
||||
}
|
||||
|
||||
/// Newest stored state for `me`.
|
||||
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(inbox_state_d_tag(me));
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match serde_json::from_str(&event.content) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable inbox state {}: {error}", event.id);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign with a random key and store locally.
|
||||
async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(inbox_state_d_tag(me))])
|
||||
.finalize(&Keys::generate())?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Notification events and a lookup of every ancestor they reference.
|
||||
async fn fetch_notifications(
|
||||
client: &Client,
|
||||
me: PublicKey,
|
||||
deletions: &Deletions,
|
||||
) -> Result<(Vec<Event>, HashMap<EventId, Event>), Error> {
|
||||
let mut notifications: Vec<Event> = Vec::new();
|
||||
let mut by_id: HashMap<EventId, Event> = HashMap::new();
|
||||
|
||||
for filter in filters::notifications(me) {
|
||||
for event in client.database().query(filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if by_id.insert(event.id, event.clone()).is_none() {
|
||||
notifications.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pending: Vec<EventId> = notifications.iter().flat_map(event_references).collect();
|
||||
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
|
||||
|
||||
loop {
|
||||
// Keep only ids not walked yet, and remember them.
|
||||
pending.retain(|id| seen.insert(*id));
|
||||
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let ancestors = client
|
||||
.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(event_references(&event).filter(|id| !seen.contains(id)));
|
||||
by_id.entry(event.id).or_insert(event);
|
||||
}
|
||||
|
||||
pending = next;
|
||||
}
|
||||
|
||||
Ok((notifications, by_id))
|
||||
}
|
||||
|
||||
/// Event ids referenced by `event` through its `e` and `E` tags.
|
||||
fn event_references(event: &Event) -> impl Iterator<Item = EventId> + '_ {
|
||||
event.tags.iter().filter_map(|tag| {
|
||||
if tag.kind() != "e" && tag.kind() != "E" {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
@@ -1,60 +1,60 @@
|
||||
mod backend;
|
||||
mod checkouts;
|
||||
mod git_store;
|
||||
mod inbox;
|
||||
mod profile;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
mod repo_list;
|
||||
mod repos;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use backend::{Backend, BackendEvent};
|
||||
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
|
||||
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
|
||||
pub use git_store::GitStore;
|
||||
use gpui::{App, AppContext, Entity};
|
||||
use gpui::{App, AppContext};
|
||||
pub use inbox::{Inbox, query_inbox};
|
||||
pub use nostr_sdk::prelude::Timestamp;
|
||||
pub use profile::{Profile, ProfileStore};
|
||||
pub use refresh::{RefreshGate, RefreshRequest};
|
||||
pub use repo::RepoStore;
|
||||
pub use repo_list::RepoListStore;
|
||||
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
|
||||
use signed_nostr::new_backend;
|
||||
pub use utils::shorten_pubkey;
|
||||
|
||||
/// Initialize the backend and stores, and install them as globals. Call once
|
||||
/// at startup, before opening any window that uses the stores.
|
||||
/// Initialize the backend and stores, and install them as globals.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
||||
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.ok();
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
repos_root: impl Into<PathBuf>,
|
||||
scan_paths: Vec<PathBuf>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
// rustls uses the `aws_lc_rs` provider by default.
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
|
||||
let path = db_path.as_ref().to_path_buf();
|
||||
let (client, signer) = cx.foreground_executor().block_on(async move {
|
||||
let path = db_path.as_ref().to_path_buf();
|
||||
new_backend(path)
|
||||
.await
|
||||
.expect("failed to initialize nostr backend")
|
||||
});
|
||||
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
|
||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// The clone cache is only meaningful on native platforms; the wasm
|
||||
// build registers an empty store so `GitStore::global` still works.
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
|
||||
entity
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(repos_root, cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
}
|
||||
|
||||
/// Initialize the backend with an in-memory database on wasm.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
pub fn init(cx: &mut App) {
|
||||
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);
|
||||
|
||||
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
|
||||
entity
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Error;
|
||||
use flume::{Receiver, RecvTimeoutError, Sender};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
|
||||
use flume::{Receiver, Sender};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
||||
WeakEntity,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
|
||||
/// A user profile (kind `0` metadata), as plain data for the UI.
|
||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
@@ -60,24 +63,18 @@ impl Profile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Message from the fetch task to the main thread.
|
||||
enum Dispatch {
|
||||
/// A batched sync finished; re-read seen profiles from the database.
|
||||
Synced,
|
||||
}
|
||||
|
||||
/// How long to wait for more requests before firing a batched sync.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Global profile cache. Profiles are fetched in batches and kept as plain
|
||||
/// data; the whole store notifies on change.
|
||||
/// Global profile cache.
|
||||
///
|
||||
/// Profiles are fetched in batches and kept as plain data.
|
||||
pub struct ProfileStore {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
/// Public keys we've already requested this session (main thread only).
|
||||
/// Public keys requested this session, main thread only.
|
||||
seen: RefCell<HashSet<PublicKey>>,
|
||||
/// Sender for queuing fetch requests, batched by a background task.
|
||||
sender: Sender<PublicKey>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -99,8 +96,13 @@ impl ProfileStore {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||
BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => {
|
||||
this.apply_author(update.author, cx);
|
||||
BackendEvent::NostrUpdate(updates) => {
|
||||
for update in updates
|
||||
.iter()
|
||||
.filter(|update| update.kind == Kind::Metadata)
|
||||
{
|
||||
this.apply_author(update.author, cx);
|
||||
}
|
||||
}
|
||||
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
@@ -111,40 +113,34 @@ impl ProfileStore {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Fetch requests are queued on a channel and synced in batches by a
|
||||
// background task.
|
||||
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
||||
let client = backend.read(cx).client();
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
cx.spawn(async move |_this, cx| {
|
||||
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||
})
|
||||
.detach();
|
||||
|
||||
tasks.push(cx.background_spawn(async move {
|
||||
Self::handle_requests(&client, &dispatch_tx, &receiver).await
|
||||
}));
|
||||
|
||||
// Re-read seen profiles from the database after each batch sync.
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await {
|
||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
||||
log::warn!("profile store dropped before initial load could run: {error}");
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
});
|
||||
|
||||
let mut store = Self {
|
||||
Self {
|
||||
profiles: HashMap::new(),
|
||||
seen: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
tasks,
|
||||
_subscription: subscription,
|
||||
};
|
||||
|
||||
store.load(cx);
|
||||
store
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a profile. Returns a placeholder (default metadata) and queues a
|
||||
/// fetch if the profile isn't cached yet.
|
||||
/// Get a profile.
|
||||
///
|
||||
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
|
||||
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
@@ -163,13 +159,15 @@ impl ProfileStore {
|
||||
|
||||
/// Load recently seen profiles from the local database.
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread; only plain profiles cross back.
|
||||
// Parse off the main thread.
|
||||
// Only plain profiles cross back.
|
||||
let profiles: Vec<Profile> = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
@@ -181,7 +179,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -192,18 +190,21 @@ impl ProfileStore {
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of an author from the local database.
|
||||
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread; only the profile crosses back.
|
||||
// Parse off the main thread.
|
||||
// Only the profile crosses back.
|
||||
let profile = events
|
||||
.into_iter()
|
||||
.max_by_key(|e| e.created_at)
|
||||
@@ -215,7 +216,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profile)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let profile = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -226,11 +227,13 @@ impl ProfileStore {
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of every requested author from the local
|
||||
/// database (used after a sync, which produces no NostrUpdate events).
|
||||
/// Re-read the latest metadata of every requested author from the local database.
|
||||
///
|
||||
/// Used after a sync, which produces no NostrUpdate events.
|
||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
|
||||
@@ -238,7 +241,8 @@ impl ProfileStore {
|
||||
return;
|
||||
}
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
@@ -269,7 +273,7 @@ impl ProfileStore {
|
||||
Ok::<_, Error>(profiles)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let profiles = work.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -280,47 +284,61 @@ impl ProfileStore {
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
});
|
||||
task.detach();
|
||||
}
|
||||
|
||||
/// Sync metadata for requested authors in batches, debounced to collect
|
||||
/// requests. Runs on a background thread; results are dispatched to the
|
||||
/// main thread, which re-reads the database.
|
||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||
///
|
||||
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
||||
async fn handle_requests(
|
||||
this: WeakEntity<ProfileStore>,
|
||||
client: &Client,
|
||||
dispatch: &Sender<Dispatch>,
|
||||
receiver: &Receiver<PublicKey>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<(), Error> {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
// Wait for the first request of a batch.
|
||||
match receiver.recv_timeout(BATCH_TIMEOUT) {
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Ok(()),
|
||||
Err(RecvTimeoutError::Timeout) => continue,
|
||||
};
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// Collect everything that arrives within the debounce window.
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
while let Ok(public_key) = receiver.recv_deadline(deadline) {
|
||||
batch.insert(public_key);
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
let recv = receiver.recv_async();
|
||||
futures::pin_mut!(recv);
|
||||
match futures::future::select(recv, timer).await {
|
||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
||||
futures::future::Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||
|
||||
// Negentropy-sync with the bootstrap relays. Synced events are
|
||||
// written to the database directly (no NostrUpdate), so re-apply
|
||||
// from the database afterwards.
|
||||
// Negentropy-sync with the bootstrap relays.
|
||||
// Synced events are written to the database directly, no NostrUpdate.
|
||||
// Re-apply from the database afterwards.
|
||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||
Ok(_) => {
|
||||
if dispatch.send(Dispatch::Synced).is_err() {
|
||||
log::warn!("profile dispatch channel closed, dropping sync result");
|
||||
}
|
||||
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||
}
|
||||
Err(e) => log::warn!("profile sync failed: {e}"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/// Refresh coalescing shared by the event stores.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RefreshGate {
|
||||
/// A run is in flight.
|
||||
running: bool,
|
||||
/// A request arrived while a run was in flight.
|
||||
dirty: bool,
|
||||
/// The debounce timer is pending.
|
||||
debouncing: bool,
|
||||
}
|
||||
|
||||
/// What a refresh request decided.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshRequest {
|
||||
/// No run or timer covers the request, start the debounce timer.
|
||||
Schedule,
|
||||
/// A run or pending timer already covers the request.
|
||||
Fold,
|
||||
}
|
||||
|
||||
impl RefreshGate {
|
||||
/// Whether a run is in flight.
|
||||
pub fn running(&self) -> bool {
|
||||
self.running
|
||||
}
|
||||
|
||||
/// Whether the debounce timer is pending.
|
||||
pub fn debouncing(&self) -> bool {
|
||||
self.debouncing
|
||||
}
|
||||
|
||||
/// A new refresh request arrived.
|
||||
///
|
||||
/// Folded into a follow-up run while one is in flight, dropped while the
|
||||
/// debounce timer is pending, otherwise starts the timer.
|
||||
pub fn request(&mut self) -> RefreshRequest {
|
||||
if self.running {
|
||||
self.dirty = true;
|
||||
RefreshRequest::Fold
|
||||
} else if self.debouncing {
|
||||
RefreshRequest::Fold
|
||||
} else {
|
||||
self.debouncing = true;
|
||||
RefreshRequest::Schedule
|
||||
}
|
||||
}
|
||||
|
||||
/// The debounce timer fired and the run starts now.
|
||||
pub fn begin(&mut self) {
|
||||
self.debouncing = false;
|
||||
self.running = true;
|
||||
}
|
||||
|
||||
/// The run ended. Whether a request arrived while it ran.
|
||||
pub fn finish(&mut self) -> bool {
|
||||
self.running = false;
|
||||
std::mem::take(&mut self.dirty)
|
||||
}
|
||||
|
||||
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
|
||||
pub fn abort(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{AppContext, Context, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query, so bursts of
|
||||
/// events (e.g. sync progress ticks) collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||
|
||||
/// Store listing repository announcements (global discovery or per-author).
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository
|
||||
/// (announcements, state updates, patches, PRs, issues, statuses).
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
author: Option<PublicKey>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Create a store. If `author` is `None`, all announcements are listed.
|
||||
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(update) => {
|
||||
// Deletions may target anything we list; always refresh.
|
||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||
true
|
||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||
// Activity (patches, issues, ...) is addressed to repos via
|
||||
// `a` tags, so its author isn't the repo owner; always refresh.
|
||||
true
|
||||
} else {
|
||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||
let is_repo_state = update.kind == Kind::RepoState;
|
||||
let tracked = is_announcement || is_repo_state;
|
||||
tracked && this.author.is_none_or(|a| a == update.author)
|
||||
}
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
event.kind == Kind::GitRepoAnnouncement
|
||||
&& this.author.is_none_or(|a| a == event.pubkey)
|
||||
}
|
||||
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if relevant {
|
||||
this.refresh(cx);
|
||||
}
|
||||
});
|
||||
|
||||
let mut store = Self {
|
||||
announcements: Arc::new(Vec::new()),
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
author,
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
_subscription: subscription,
|
||||
tasks: Vec::new(),
|
||||
};
|
||||
|
||||
store.subscribe_remote(cx);
|
||||
store.refresh(cx);
|
||||
store
|
||||
}
|
||||
|
||||
/// Scope the list to an author (or clear the scope with `None`).
|
||||
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||
self.author = author;
|
||||
self.subscribe_remote(cx);
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let author = self.author;
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
backend.sync_bootstrap(filter, cx);
|
||||
// Deletion requests (NIP-09/62) must be known before any
|
||||
// announcement can be shown.
|
||||
backend.sync_bootstrap(filters::deletions(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-query the local database. Latest announcement per repository wins.
|
||||
///
|
||||
/// Debounced: a short delay collapses bursts of requests (e.g. sync
|
||||
/// progress ticks), and requests that arrive while a query is running
|
||||
/// are folded into one follow-up query. The query and processing run on
|
||||
/// a background thread; only the results are applied on the main thread.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
return;
|
||||
}
|
||||
if self.debouncing {
|
||||
return;
|
||||
}
|
||||
self.debouncing = true;
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.debouncing = false;
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// One query + apply cycle (debounced entry point).
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
let author = self.author;
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
// Dedup and sort off the main thread; only the final list
|
||||
// crosses back into the entity.
|
||||
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(announcement) = Announcement::from_event(&event) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let addr = announcement.addr();
|
||||
|
||||
match by_repo.get(&addr) {
|
||||
Some(existing) if existing.created_at >= announcement.created_at => {}
|
||||
_ => {
|
||||
by_repo.insert(addr, announcement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||
|
||||
// Last activity per repository: state updates plus all NIP-34
|
||||
// activity events (patches, PRs, issues, statuses).
|
||||
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||
.iter()
|
||||
.map(|a| (a.addr(), a.created_at))
|
||||
.collect();
|
||||
|
||||
let state_filter = Filter::new().kind(Kind::RepoState);
|
||||
for event in client.database().query(state_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = event.tags.identifier() else {
|
||||
continue;
|
||||
};
|
||||
let addr = repo_addr(event.pubkey, id);
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
*entry = (*entry).max(event.created_at);
|
||||
}
|
||||
|
||||
// Bound the activity query to a recent window; older repos fall
|
||||
// back to their announcement / state timestamps.
|
||||
let activity_filter = Filter::new()
|
||||
.kinds(filters::ACTIVITY_KINDS)
|
||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||
for event in client.database().query(activity_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
if addr.kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
// Skip events for repos we don't list, so the map can't
|
||||
// grow beyond the number of announcements.
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
*entry = (*entry).max(event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((announcements, last_activity))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (announcements, last_activity) = match work.await {
|
||||
Ok(results) => results,
|
||||
// Database errors are transient; keep the last list.
|
||||
Err(_) => {
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.announcements = Arc::new(announcements);
|
||||
this.last_activity = Arc::new(last_activity);
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
if this.refresh_dirty {
|
||||
this.refresh_dirty = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running are
|
||||
// coalesced into one follow-up refresh.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
use signed_git::find_git_repos;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||
|
||||
struct GlobalLocalReposStore(Entity<LocalReposStore>);
|
||||
|
||||
impl Global for GlobalLocalReposStore {}
|
||||
|
||||
/// Store of the git repositories discovered under a set of scan paths.
|
||||
pub struct LocalReposStore {
|
||||
/// The directories being scanned.
|
||||
pub roots: Arc<Vec<PathBuf>>,
|
||||
/// Git repositories discovered under [`Self::roots`], sorted by path.
|
||||
pub repos: Arc<Vec<PathBuf>>,
|
||||
/// A scan is currently running.
|
||||
pub scanning: bool,
|
||||
/// A scan was requested while one was already running.
|
||||
scan_dirty: bool,
|
||||
}
|
||||
|
||||
impl LocalReposStore {
|
||||
/// Retrieve the global local-repositories store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalLocalReposStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalLocalReposStore(entity));
|
||||
}
|
||||
|
||||
/// Create a store scanning `roots` right away.
|
||||
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
|
||||
log::warn!("local repos store dropped before initial scan could run: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
roots: Arc::new(roots),
|
||||
repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
scan_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a repository that has just been published to NIP-34.
|
||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
self.repos = Arc::new(
|
||||
self.repos
|
||||
.iter()
|
||||
.filter(|repo| repo.as_path() != path)
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Re-run the scan.
|
||||
pub fn rescan(&mut self, cx: &mut Context<Self>) {
|
||||
if self.scanning {
|
||||
self.scan_dirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.scanning = true;
|
||||
cx.notify();
|
||||
|
||||
let roots = self.roots.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let mut repos = Vec::new();
|
||||
for root in roots.iter() {
|
||||
repos.extend(find_git_repos(root));
|
||||
}
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
repos
|
||||
});
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||
let repos = work.await;
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.repos = Arc::new(repos);
|
||||
this.scanning = false;
|
||||
cx.notify();
|
||||
|
||||
let dirty = this.scan_dirty;
|
||||
this.scan_dirty = false;
|
||||
dirty
|
||||
})?;
|
||||
|
||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
task.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
///
|
||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||
|
||||
struct GlobalRepoListStore(Entity<RepoListStore>);
|
||||
|
||||
impl Global for GlobalRepoListStore {}
|
||||
|
||||
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RepoActivityCounts {
|
||||
/// Root `30611` issue events addressed to the repository.
|
||||
pub issues: u32,
|
||||
/// Root `3063` pull request events addressed to the repository.
|
||||
///
|
||||
/// PR updates are not new PRs and do not count.
|
||||
pub pull_requests: u32,
|
||||
/// `1617` patch events addressed to the repository.
|
||||
pub commits: u32,
|
||||
}
|
||||
|
||||
impl RepoActivityCounts {
|
||||
/// Total issues, pull requests and commits, the popularity ranking key.
|
||||
pub fn score(self) -> u32 {
|
||||
self.issues + self.pull_requests + self.commits
|
||||
}
|
||||
}
|
||||
|
||||
/// Store listing the discovered repository announcements, newest first.
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues, pull requests and commits per repository.
|
||||
///
|
||||
/// Used for the Popular ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global repository list store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalRepoListStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store listing all announcements.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||
// Deletions may target anything we list, always refresh.
|
||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||
true
|
||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||
// Activity events are addressed to repos via `a` tags.
|
||||
// Their author is not the repo owner, always refresh.
|
||||
true
|
||||
} else {
|
||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||
let is_repo_state = update.kind == Kind::RepoState;
|
||||
is_announcement || is_repo_state
|
||||
}
|
||||
}),
|
||||
BackendEvent::Published(event) => {
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||
|
||||
// Locally published deletions are already in the local database.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
let deletion =
|
||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||
|
||||
announcement || deletion
|
||||
}
|
||||
// Only a completed sync refreshes the list.
|
||||
// Progress ticks would re-scan the whole database several times
|
||||
// per sync to reveal entries incrementally.
|
||||
BackendEvent::Synced => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if relevant {
|
||||
this.refresh(cx);
|
||||
}
|
||||
});
|
||||
|
||||
cx.defer(move |cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.subscribe_remote(cx);
|
||||
this.refresh_initial(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
announcements: Arc::new(Vec::new()),
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
counts: Arc::new(HashMap::new()),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// The announcements of `user`, newest first.
|
||||
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
|
||||
self.announcements
|
||||
.iter()
|
||||
.filter(|a| a.owner == *user)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
backend.sync_bootstrap(filters::deletions(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot initial load.
|
||||
///
|
||||
/// Query the local database immediately, no debounce.
|
||||
/// Stored announcements appear as soon as the app opens.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.refresh.debouncing());
|
||||
if self.refresh.running() {
|
||||
self.refresh.request();
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refresh.request() != RefreshRequest::Schedule {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
|
||||
this.update(cx, |this, cx| this.run_refresh(cx))
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refresh.begin();
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = filters::all_announcements();
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
// Dedup and sort off the main thread.
|
||||
// Only the final list crosses back into the entity.
|
||||
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(announcement) = Announcement::from_event(&event) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let addr = announcement.addr();
|
||||
|
||||
match by_repo.get(&addr) {
|
||||
Some(existing) if existing.created_at >= announcement.created_at => {}
|
||||
_ => {
|
||||
by_repo.insert(addr, announcement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||
|
||||
// Last activity per repository.
|
||||
// State updates count, and all NIP-34 activity events.
|
||||
// The activity events are patches, PRs, issues and statuses.
|
||||
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||
.iter()
|
||||
.map(|a| (a.addr(), a.created_at))
|
||||
.collect();
|
||||
|
||||
let state_filter = Filter::new().kind(Kind::RepoState);
|
||||
for event in client.database().query(state_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = event.tags.identifier() else {
|
||||
continue;
|
||||
};
|
||||
let addr = repo_addr(event.pubkey, id);
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
*entry = (*entry).max(event.created_at);
|
||||
}
|
||||
|
||||
// Bound the activity query to a recent window.
|
||||
// Older repos fall back to their announcement or state timestamps.
|
||||
let activity_filter = Filter::new()
|
||||
.kinds(filters::ACTIVITY_KINDS)
|
||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||
for event in client.database().query(activity_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
if addr.kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
*entry = (*entry).max(event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
// Popularity counts per repository, issues, pull requests and patches.
|
||||
// Unbounded, unlike the windowed activity query above, so totals are exact.
|
||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||
let count_filter =
|
||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||
for event in client.database().query(count_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let entry = counts.entry(addr).or_default();
|
||||
match event.kind {
|
||||
Kind::GitIssue => entry.issues += 1,
|
||||
Kind::GitPullRequest => entry.pull_requests += 1,
|
||||
Kind::GitPatch => entry.commits += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((announcements, last_activity, counts))
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let (announcements, last_activity, counts) = match work.await {
|
||||
Ok(results) => results,
|
||||
// Database errors are transient, keep the last list.
|
||||
Err(_) => {
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refresh.abort();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.announcements = Arc::new(announcements);
|
||||
this.last_activity = Arc::new(last_activity);
|
||||
this.counts = Arc::new(counts);
|
||||
cx.notify();
|
||||
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running.
|
||||
// They are coalesced into one follow-up refresh.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "signed_ui"
|
||||
description = "Reusable UI components and elements for Signed, built on gpui-base and gpui-component."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-component.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
@@ -0,0 +1,66 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClipboardItem, Div, ElementId, SharedString, div};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||
|
||||
/// A muted command row with a copy button.
|
||||
pub fn copy_row<E, T>(copy_id: E, command: T, cx: &App) -> Div
|
||||
where
|
||||
E: Into<ElementId>,
|
||||
T: Into<SharedString>,
|
||||
{
|
||||
let command = command.into();
|
||||
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_xs()
|
||||
.child(command.clone()),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new(copy_id)
|
||||
.tooltip("Copy")
|
||||
.value(command.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// One row of a copy menu, with a small title above the compact label.
|
||||
pub fn menu_copy_row(
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
label: String,
|
||||
copy: String,
|
||||
) -> PopupMenuItem {
|
||||
let row_copy = copy.clone();
|
||||
PopupMenuItem::element(move |_window, _cx| {
|
||||
let button_copy = copy.clone();
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().flex_shrink_0().w_20().font_semibold().child(title))
|
||||
.child(div().flex_1().text_ellipsis().child(label.clone())),
|
||||
)
|
||||
.child(Clipboard::new(id).tooltip("Copy").value(button_copy))
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(row_copy.clone()));
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
Anchor, AnyElement, App, DismissEvent, ElementId, Entity, Focusable, SharedString,
|
||||
StyleRefinement, Window, px,
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// A split dropdown button built on `gpui_base::Popover`.
|
||||
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
|
||||
/// The action and the caret are caller-supplied elements, so the look stays in the app.
|
||||
/// This component only owns the popover wiring.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
action: Option<AnyElement>,
|
||||
caret: Option<CaretBuilder>,
|
||||
menu: Option<MenuBuilder>,
|
||||
}
|
||||
|
||||
type MenuBuilder =
|
||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
|
||||
|
||||
impl DropdownButton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopRight,
|
||||
action: None,
|
||||
caret: None,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The action half of the button.
|
||||
/// It keeps its own icon, label, tooltip and click handler.
|
||||
pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
|
||||
self.action = Some(action.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// The menu built by `builder`.
|
||||
/// Matches gpui-component's `DropdownButton::dropdown_menu` signature.
|
||||
/// Existing menu code keeps working.
|
||||
pub fn dropdown_menu(
|
||||
mut self,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.menu = Some(Box::new(builder));
|
||||
self
|
||||
}
|
||||
|
||||
/// Which corner of the caret the menu anchors to.
|
||||
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
|
||||
#[allow(dead_code)] // API knob, current call sites use the default anchor.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for DropdownButton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the [`PopupMenu`] entity of one popover between renders.
|
||||
/// Dismissal drops it, so the menu is rebuilt with fresh items on the next open.
|
||||
#[derive(Default)]
|
||||
struct DropdownMenuState {
|
||||
menu: Option<Entity<PopupMenu>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for DropdownButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
debug_assert!(
|
||||
self.menu.is_some(),
|
||||
"a DropdownButton needs a `dropdown_menu`"
|
||||
);
|
||||
|
||||
// The popover needs its own id.
|
||||
// The container and the popover both register keyed state on this window.
|
||||
let popover_id = SharedString::from(format!("{}-popover", self.id));
|
||||
let anchor = self.anchor;
|
||||
let menu_state =
|
||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
let caret = self.caret.unwrap_or_else(|| {
|
||||
let id = popover_id.clone();
|
||||
Box::new(move |is_open, _, cx| {
|
||||
let caret = default_caret(id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.gap_0p5()
|
||||
.when_some(self.action, |this, action| this.child(action))
|
||||
.when_some(self.menu, |this, builder| {
|
||||
this.child(
|
||||
Popover::new(popover_id)
|
||||
.anchor(anchor)
|
||||
// The menu dismisses itself on outside click or Escape.
|
||||
// The subscription below closes the popover along with it.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(caret)
|
||||
.content(
|
||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
None => {
|
||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||
builder(menu, window, cx)
|
||||
});
|
||||
menu_state
|
||||
.update(cx, |state, _| state.menu = Some(menu.clone()));
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
|
||||
let popover_state = cx.entity();
|
||||
window
|
||||
.subscribe(&menu, cx, {
|
||||
let menu_state = menu_state.clone();
|
||||
move |_, _: &DismissEvent, window, cx| {
|
||||
popover_state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = None;
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The default caret, a chevron button the height of a medium button.
|
||||
/// It is tinted by the theme and styled for hover and menu-open states.
|
||||
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
BaseButton::new(id)
|
||||
.h(px(32.))
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.hover(|style| style.bg(cx.theme().secondary_hover))
|
||||
.styles(|this| {
|
||||
this.selected(|style| style.bg(cx.theme().secondary_active))
|
||||
.disabled(|style| style.opacity(0.5))
|
||||
})
|
||||
.child(Icon::new(IconName::ChevronDown).xsmall())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::div;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dropdown_button_builder_state() {
|
||||
let button = DropdownButton::new("issues")
|
||||
.action(div())
|
||||
.anchor(Anchor::BottomLeft)
|
||||
.dropdown_menu(|menu, _, _| menu);
|
||||
|
||||
assert!(button.action.is_some());
|
||||
// The caret is `None` until render, which falls back to the default.
|
||||
assert!(button.caret.is_none());
|
||||
assert!(button.menu.is_some());
|
||||
assert_eq!(button.anchor, Anchor::BottomLeft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
mod dropdown_button;
|
||||
mod nav_item;
|
||||
mod pixel_avatar;
|
||||
mod placeholder;
|
||||
mod segment_button;
|
||||
mod setting;
|
||||
mod status_badge;
|
||||
mod title_bar;
|
||||
mod tree_row;
|
||||
mod user_avatar;
|
||||
|
||||
pub mod copy_row;
|
||||
pub mod util;
|
||||
|
||||
pub use copy_row::{copy_row, menu_copy_row};
|
||||
pub use dropdown_button::DropdownButton;
|
||||
pub use nav_item::NavItem;
|
||||
pub use pixel_avatar::PixelAvatar;
|
||||
pub use placeholder::placeholder;
|
||||
pub use segment_button::{CountBadge, SegmentButton};
|
||||
pub use setting::{SelectOption, setting_block, setting_row};
|
||||
pub use status_badge::status_badge;
|
||||
pub use title_bar::title_bar_drag_handlers;
|
||||
pub use tree_row::tree_row;
|
||||
pub use user_avatar::UserAvatar;
|
||||
pub use util::middle_truncate;
|
||||
@@ -0,0 +1,79 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex};
|
||||
|
||||
/// A single navigation entry in a sidebar.
|
||||
/// It has an arbitrary leading element, such as an icon or avatar, and a text label.
|
||||
/// Hover highlights the row.
|
||||
/// It can carry a trailing suffix, such as a status icon, and an optional click handler.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct NavItem {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: gpui::AnyElement,
|
||||
label: SharedString,
|
||||
/// Trailing element at the right edge of the row, after the ellipsized label.
|
||||
suffix: Option<gpui::AnyElement>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl NavItem {
|
||||
pub fn new<I, L, N>(id: I, label: L, icon: N) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
N: IntoElement,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
icon: icon.into_any_element(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
suffix: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A trailing element rendered at the right edge of the row
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NavItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(self.icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(self.label),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().flex_shrink_0().child(suffix))
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.when_some(self.on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Pixels, Window, div, px};
|
||||
use gpui::{App, Pixels, StyleRefinement, Window, div, px};
|
||||
use gpui_base::StyledExt;
|
||||
use gpui_component::{ActiveTheme, Colorize};
|
||||
|
||||
/// Number of rows and columns in the pixel grid.
|
||||
@@ -8,30 +9,41 @@ const GRID_SIZE: usize = 8;
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells, so a sparse roll still yields a
|
||||
/// recognizable shape (each left-half cell is mirrored to a right-half one).
|
||||
/// Minimum number of filled left-half cells.
|
||||
/// A sparse roll still yields a recognizable shape.
|
||||
/// Each left-half cell is mirrored to a right-half one.
|
||||
const MIN_FILLED: usize = 5;
|
||||
|
||||
/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal
|
||||
/// mirror symmetry, seeded from a stable string such as the repository id and
|
||||
/// owner public key. The same seed always renders the same avatar.
|
||||
/// Side length of the avatar in pixels, no setter.
|
||||
const AVATAR_SIZE: Pixels = px(16.);
|
||||
|
||||
/// A deterministic, offline pixel-art avatar.
|
||||
/// An 8×8 grid with horizontal mirror symmetry.
|
||||
/// Seeded from a stable string such as the repository id and owner public key.
|
||||
/// The same seed always renders the same avatar.
|
||||
#[derive(IntoElement)]
|
||||
pub(crate) struct PixelAvatar {
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Pixels,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Create an avatar seeded from `seed`. The seed should be a stable string
|
||||
/// unique to the entity the avatar represents.
|
||||
pub(crate) fn new(seed: impl AsRef<str>) -> Self {
|
||||
/// Create an avatar seeded from `seed`.
|
||||
/// The seed should be a stable string unique to the entity the avatar represents.
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: px(16.),
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
@@ -64,10 +76,11 @@ impl RenderOnce for PixelAvatar {
|
||||
}
|
||||
|
||||
div()
|
||||
.refine_style(&self.style)
|
||||
.grid()
|
||||
.grid_cols(GRID_SIZE as u16)
|
||||
.grid_rows(GRID_SIZE as u16)
|
||||
.size(self.size)
|
||||
.size(AVATAR_SIZE)
|
||||
.flex_shrink_0()
|
||||
.overflow_hidden()
|
||||
.bg(main.opacity(0.16))
|
||||
@@ -75,8 +88,9 @@ impl RenderOnce for PixelAvatar {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the 8×8 cell pattern for `seed`. Cells are `0` (empty), `1`
|
||||
/// (main color) or `2` (accent shade); the right half mirrors the left half.
|
||||
/// Generate the 8×8 cell pattern for `seed`.
|
||||
/// Cells are `0` for empty, `1` for main color and `2` for accent shade.
|
||||
/// The right half mirrors the left half.
|
||||
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
|
||||
@@ -92,8 +106,8 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse rolls can come out nearly empty; top the pattern up to the
|
||||
// minimum fill, scanning from a seeded starting cell.
|
||||
// Sparse rolls can come out nearly empty.
|
||||
// Top the pattern up to the minimum fill, scanning from a seeded starting cell.
|
||||
if filled < MIN_FILLED {
|
||||
let half = GRID_SIZE * GRID_SIZE / 2;
|
||||
let start = (rng.next() % half as u64) as usize;
|
||||
@@ -120,7 +134,7 @@ fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, v
|
||||
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash; stable across platforms and runs.
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
@@ -0,0 +1,19 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, div};
|
||||
use gpui_component::{ActiveTheme, v_flex};
|
||||
|
||||
/// A centered muted placeholder message, filling its parent.
|
||||
pub fn placeholder(message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(message.to_string()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div, px, relative};
|
||||
use gpui_base::{Button as BaseButton, StyledExt};
|
||||
use gpui_component::ActiveTheme;
|
||||
|
||||
/// A small count badge shown after a label.
|
||||
/// Used on segmented filter buttons, like `All 12`, and on tabs.
|
||||
/// Rendered from theme tokens and sized for the compact header buttons it lives on.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CountBadge {
|
||||
count: usize,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl CountBadge {
|
||||
pub fn new(count: usize) -> Self {
|
||||
Self {
|
||||
count,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for CountBadge {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CountBadge {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.refine_style(&self.style)
|
||||
.h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(self.count.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`].
|
||||
/// The selected state renders the button pressed, styled from theme button tokens.
|
||||
/// Built on the unstyled `gpui_base::Button`, like the app's other custom controls.
|
||||
/// The `primary` variant uses the primary button tokens.
|
||||
/// It suits call-to-action buttons such as `New issue` and `New PR`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct SegmentButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: Option<gpui::AnyElement>,
|
||||
label: SharedString,
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
primary: bool,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl SegmentButton {
|
||||
pub fn new<I, L>(id: I, label: L) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
icon: None,
|
||||
count: None,
|
||||
selected: false,
|
||||
primary: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading icon, e.g. `Icon::new(CustomIconName::GitIssueDone)`.
|
||||
pub fn icon(mut self, icon: impl IntoElement) -> Self {
|
||||
self.icon = Some(icon.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// A count shown in a badge after the label.
|
||||
pub fn count(mut self, count: usize) -> Self {
|
||||
self.count = Some(count);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the button reflects an active filter/tab.
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the primary button tokens, for call-to-action buttons.
|
||||
pub fn primary(mut self) -> Self {
|
||||
self.primary = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for SegmentButton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SegmentButton {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
selected,
|
||||
primary,
|
||||
on_click,
|
||||
} = self;
|
||||
|
||||
let theme = cx.theme();
|
||||
let fg = if primary {
|
||||
theme.button_primary_foreground
|
||||
} else {
|
||||
theme.button_foreground
|
||||
};
|
||||
let base = if primary {
|
||||
theme.button_primary
|
||||
} else {
|
||||
theme.button_active
|
||||
};
|
||||
let hover = if primary {
|
||||
theme.button_primary_hover
|
||||
} else {
|
||||
theme.button_hover
|
||||
};
|
||||
let active = if primary {
|
||||
theme.button_primary_active
|
||||
} else {
|
||||
theme.button_active
|
||||
};
|
||||
|
||||
BaseButton::new(id)
|
||||
.refine_style(&style)
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.when_some(icon, |this, icon| this.child(icon))
|
||||
.child(div().text_sm().child(label))
|
||||
.when_some(count, |this, count| this.child(CountBadge::new(count)))
|
||||
.text_color(fg)
|
||||
.rounded(theme.radius)
|
||||
.hover(move |this| this.bg(hover))
|
||||
.active(move |this| this.bg(active))
|
||||
.selected(selected)
|
||||
.when(primary, |this| this.bg(base))
|
||||
.when(selected, |this| this.bg(active))
|
||||
.when_some(on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, div};
|
||||
use gpui_component::searchable_list::SearchableListItem;
|
||||
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
|
||||
|
||||
/// A dropdown option with a display label and a stored value.
|
||||
/// The trigger and menu render the `label`.
|
||||
/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
|
||||
#[derive(Clone)]
|
||||
pub struct SelectOption {
|
||||
value: SharedString,
|
||||
label: SharedString,
|
||||
}
|
||||
|
||||
impl SelectOption {
|
||||
/// Create an option with the given stored `value` and display `label`.
|
||||
pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value of this option.
|
||||
pub fn value(&self) -> &SharedString {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// The display label of this option.
|
||||
pub fn label(&self) -> &SharedString {
|
||||
&self.label
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchableListItem for SelectOption {
|
||||
type Value = SharedString;
|
||||
|
||||
fn title(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings row with the label and description on the left and the control on the right.
|
||||
pub fn setting_row(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
) -> impl IntoElement {
|
||||
let title = title.into();
|
||||
let description = description.into();
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(div().text_sm().font_semibold().child(title))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(description),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_40()
|
||||
.flex_shrink_0()
|
||||
.justify_end()
|
||||
.items_center()
|
||||
.child(control),
|
||||
)
|
||||
}
|
||||
|
||||
/// A full-width settings block with title and subtitle in one header.
|
||||
/// `gap_3` separates the header from the control below.
|
||||
pub fn setting_block(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
) -> impl IntoElement {
|
||||
let title = title.into();
|
||||
let description = description.into();
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.child(div().text_sm().font_semibold().child(title))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(description),
|
||||
),
|
||||
)
|
||||
.child(control)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App};
|
||||
use gpui_component::tooltip::Tooltip;
|
||||
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
|
||||
use signed_core::RepoStatus;
|
||||
|
||||
/// The status badge shown next to an issue or pull request.
|
||||
/// It has an icon and a colored square, with a tooltip describing the status.
|
||||
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"open",
|
||||
"Issue is open",
|
||||
cx.theme().secondary,
|
||||
cx.theme().secondary_foreground,
|
||||
),
|
||||
RepoStatus::Closed => (
|
||||
CustomIconName::GitIssueClosed,
|
||||
"closed",
|
||||
"Issue is closed",
|
||||
cx.theme().danger,
|
||||
cx.theme().danger_foreground,
|
||||
),
|
||||
RepoStatus::Draft => (
|
||||
CustomIconName::GitIssueOngoing,
|
||||
"draft",
|
||||
"Issue is draft",
|
||||
cx.theme().accent,
|
||||
cx.theme().accent_foreground,
|
||||
),
|
||||
RepoStatus::Applied => (
|
||||
CustomIconName::GitIssueDone,
|
||||
"applied",
|
||||
"Issue is completed",
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id(label)
|
||||
.flex_shrink_0()
|
||||
.size_7()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(bg)
|
||||
.child(Icon::new(icon).small().text_color(fg))
|
||||
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use gpui::{
|
||||
App, Div, InteractiveElement as _, MouseButton, Stateful, StatefulInteractiveElement as _,
|
||||
Window, WindowControlArea,
|
||||
};
|
||||
|
||||
/// State used to move the window when the title bar area is dragged.
|
||||
struct WindowDragState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
/// Make an element behave like a window title bar.
|
||||
/// Dragging it moves the window.
|
||||
/// Double-clicking zooms the window.
|
||||
/// On macOS it runs the platform's default title-bar double-click action.
|
||||
/// Only the bar's non-interactive areas should get this.
|
||||
/// Tabs are draggable to reorder panels and must not move the window.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
|
||||
|
||||
this.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = true;
|
||||
}),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
|
||||
if state.should_move {
|
||||
state.should_move = false;
|
||||
window.start_window_move();
|
||||
}
|
||||
}))
|
||||
.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
if cfg!(target_os = "macos") {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
window.zoom_window();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Window, div, px};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::tree::TreeEntry;
|
||||
use gpui_component::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
/// One row of a file tree, an icon and a name indented by depth.
|
||||
/// Clicking a file runs `on_click`.
|
||||
/// Folders expand and collapse via the tree itself.
|
||||
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
{
|
||||
let item = entry.item();
|
||||
let is_folder = entry.is_folder();
|
||||
|
||||
let icon = if is_folder {
|
||||
if entry.is_expanded() {
|
||||
IconName::FolderOpen
|
||||
} else {
|
||||
IconName::FolderClosed
|
||||
}
|
||||
} else {
|
||||
IconName::File
|
||||
};
|
||||
|
||||
ListItem::new(ix)
|
||||
.pl(px(8.) + px(14.) * entry.depth() as f32)
|
||||
.selected(selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
.child(Icon::new(icon).small())
|
||||
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
// Folders expand/collapse via the tree itself.
|
||||
if is_folder {
|
||||
return;
|
||||
}
|
||||
on_click(window, cx);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, SharedString, StyleRefinement, Window};
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::{ActiveTheme, Sizable, Size, StyledExt};
|
||||
|
||||
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
|
||||
/// It shows the user's picture or falls back to name initials.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UserAvatar {
|
||||
name: SharedString,
|
||||
picture: Option<SharedString>,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl UserAvatar {
|
||||
/// Create an avatar for `name`.
|
||||
/// The name seeds the initials fallback shown when no picture is set.
|
||||
pub fn new(name: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
picture: None,
|
||||
size: Size::Small,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's picture URL, if known.
|
||||
pub fn picture(mut self, picture: Option<impl Into<SharedString>>) -> Self {
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for UserAvatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for UserAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for UserAvatar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
Avatar::new()
|
||||
.name(self.name)
|
||||
.when_some(self.picture, |this, url| this.src(url))
|
||||
.rounded(cx.theme().radius)
|
||||
.refine_style(&self.style)
|
||||
.with_size(self.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// `[head chars]...[tail chars]` middle truncation.
|
||||
///
|
||||
/// Values too short for the ellipsis to save space are left alone.
|
||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||
let len = value.chars().count();
|
||||
if len <= head + tail + 3 {
|
||||
return value.to_string();
|
||||
}
|
||||
let head: String = value.chars().take(head).collect();
|
||||
let tail: String = value.chars().skip(len - tail).collect();
|
||||
format!("{head}...{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn middle_truncates_long_values_only() {
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
|
||||
10,
|
||||
10,
|
||||
),
|
||||
"a008def157...ad57a3564d"
|
||||
);
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit",
|
||||
10,
|
||||
10
|
||||
),
|
||||
"30617:a008...3564d:ngit"
|
||||
);
|
||||
// Too short to save space with the ellipsis, left alone.
|
||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Format a timestamp as a short relative time (e.g. "3h ago").
|
||||
/// Format a timestamp as a short relative time, e.g. `3h ago`.
|
||||
pub fn relative_time(timestamp: Timestamp) -> String {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let secs = now.saturating_sub(timestamp.as_secs());
|
||||
@@ -20,7 +20,7 @@ pub fn relative_time(timestamp: Timestamp) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a unix timestamp in seconds as a short relative time (e.g. "3h ago").
|
||||
/// Format a unix timestamp in seconds as a short relative time, e.g. `3h ago`.
|
||||
pub fn relative_time_secs(secs: i64) -> String {
|
||||
relative_time(Timestamp::from_secs(secs.max(0) as u64))
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ publish.workspace = true
|
||||
assets = { path = "../assets" }
|
||||
dock = { workspace = true }
|
||||
paths = { path = "../paths" }
|
||||
settings = { path = "../settings" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
signed_git = { path = "../signed_git" }
|
||||
signed_state = { path = "../signed_state" }
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::mem::take;
|
||||
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
|
||||
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
|
||||
};
|
||||
|
||||
/// Default number of images each view's cache retains. Loading a new image
|
||||
/// evicts the least recently used entry once this is reached.
|
||||
pub const MAX_IMAGES: usize = 128;
|
||||
|
||||
pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider {
|
||||
AppImageCacheProvider {
|
||||
id: id.into(),
|
||||
max_items,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppImageCacheProvider {
|
||||
id: ElementId,
|
||||
max_items: usize,
|
||||
}
|
||||
|
||||
impl ImageCacheProvider for AppImageCacheProvider {
|
||||
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
|
||||
window
|
||||
.with_global_id(self.id.clone(), |id, window| {
|
||||
window.with_element_state(id, |cache, _| {
|
||||
let cache = cache.unwrap_or_else(|| AppImageCache::new(self.max_items, cx));
|
||||
(cache.clone(), cache)
|
||||
})
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppImageCache {
|
||||
max_items: usize,
|
||||
usage_list: VecDeque<u64>,
|
||||
cache: HashMap<u64, (ImageCacheItem, Resource)>,
|
||||
}
|
||||
|
||||
impl AppImageCache {
|
||||
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|cx| {
|
||||
log::info!("Creating AppImageCacheProvider");
|
||||
cx.on_release(|this: &mut Self, cx| {
|
||||
for (ix, (mut image, resource)) in take(&mut this.cache) {
|
||||
if let Some(Ok(image)) = image.get() {
|
||||
log::info!("Dropping image {ix}");
|
||||
cx.drop_image(image, None);
|
||||
}
|
||||
ImageSource::Resource(resource).remove_asset(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
AppImageCache {
|
||||
max_items,
|
||||
usage_list: VecDeque::with_capacity(max_items),
|
||||
cache: HashMap::with_capacity(max_items),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageCache for AppImageCache {
|
||||
fn load(
|
||||
&mut self,
|
||||
resource: &Resource,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::App,
|
||||
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
|
||||
let hash = hash(resource);
|
||||
|
||||
if let Some(item) = self.cache.get_mut(&hash) {
|
||||
let current_idx = self
|
||||
.usage_list
|
||||
.iter()
|
||||
.position(|item| *item == hash)
|
||||
.expect("cache has an item usage_list doesn't");
|
||||
|
||||
self.usage_list.remove(current_idx);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
return item.0.get();
|
||||
}
|
||||
|
||||
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
|
||||
let task = cx.background_executor().spawn(load_future).shared();
|
||||
|
||||
if self.usage_list.len() >= self.max_items {
|
||||
log::info!("Image cache is full, evicting oldest item");
|
||||
|
||||
if let Some(oldest) = self.usage_list.pop_back() {
|
||||
let mut image = self
|
||||
.cache
|
||||
.remove(&oldest)
|
||||
.expect("usage_list has an item cache doesn't");
|
||||
|
||||
if let Some(Ok(image)) = image.0.get() {
|
||||
log::info!("requesting image to be dropped");
|
||||
cx.drop_image(image, Some(window));
|
||||
}
|
||||
|
||||
ImageSource::Resource(image.1).remove_asset(cx);
|
||||
}
|
||||
}
|
||||
|
||||
self.cache.insert(
|
||||
hash,
|
||||
(
|
||||
gpui::ImageCacheItem::Loading(task.clone()),
|
||||
resource.clone(),
|
||||
),
|
||||
);
|
||||
self.usage_list.push_front(hash);
|
||||
|
||||
let entity = window.current_view();
|
||||
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
log::error!("error loading image into cache: {:?}", err);
|
||||
}
|
||||
|
||||
cx.on_next_frame(move |_, cx| {
|
||||
cx.notify(entity);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
mod pixel_avatar;
|
||||
mod views;
|
||||
mod workspace;
|
||||
|
||||
pub mod image_cache;
|
||||
|
||||
use gpui::{App, AppContext, Entity, Window};
|
||||
use gpui_component::Root;
|
||||
pub use views::{RepoListView, SidebarPanel};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
/// Build the root view tree. Requires `signed_state::init` and
|
||||
/// `gpui_component::init` to have been called first.
|
||||
/// Build the root view tree.
|
||||
pub fn root(window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let view = cx.new(|cx| Workspace::new(window, cx));
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
|
||||