Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f12feeb196 | ||
|
|
a56b0b8881 | ||
|
|
6706e328ec | ||
|
|
49d351fe06 | ||
|
|
4656f1340b | ||
|
|
023267001d | ||
|
|
d00ee33eab | ||
|
|
24070cc2cd | ||
|
|
855f7f5659 |
@@ -2,11 +2,7 @@
|
||||
|
||||
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
|
||||
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
|
||||
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
|
||||
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
|
||||
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
|
||||
* Do not wrap clients or SDK dependencies in project structs or pass-through helper functions, call their methods directly where needed. Extract a helper only when it holds real logic that is used in more than one place.
|
||||
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `CheckoutsStore::global(cx)` inline.
|
||||
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
|
||||
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
|
||||
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
|
||||
@@ -129,12 +125,6 @@ Action handlers can be registered on an element via the event handler `.on_actio
|
||||
|
||||
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
|
||||
|
||||
GPUI also tracks the entities a window reads while rendering. When an entity notifies, every view that read that entity during the previous frame is rerendered automatically.
|
||||
|
||||
* A view that reads an entity in render (e.g. `state.read(cx).profile(...)`) does not need `cx.observe(&entity, ...)` to pick up its changes - the read itself is the dependency. Reserve `cx.observe` and `cx.subscribe` for when the view must run logic in response to a change (rebuild derived state, re-query, dedupe), not to trigger a re-render.
|
||||
* Notify the narrowest entity holding the change. Notifying a global store rerenders every view that ever read it; notifying a per-panel entity rerenders only the panels reading it.
|
||||
* This works with cached panels (`panel.cached(...)`): a cached subtree repaints when the view it belongs to is invalidated.
|
||||
|
||||
## Entity events
|
||||
|
||||
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
|
||||
|
||||
+5
-29
@@ -1,49 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## v0.2.0 - 2026/10/21
|
||||
## Unreleased
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Added
|
||||
|
||||
- Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME), applying the OS colors, fonts, radii and accessibility preferences.
|
||||
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
|
||||
- Add a dropdown menu on the signed-in user button in the sidebar
|
||||
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
|
||||
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
|
||||
- Allow choosing the default branch when publishing a repository
|
||||
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
|
||||
- Add a step-by-step push checklist while publishing and syncing a repository
|
||||
- Add a `Reset remote to local` action that republishes the mirror and deletes grasp refs the mirror does not have
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the built-in light/dark syntax highlighting palette instead of the Signed palette bundled in `signed.json` when the native theme is active
|
||||
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
|
||||
- Surface backend errors as error notifications in the workspace instead of silently dropping them
|
||||
- Break ties in repository activity lists by event id, so same-second events order deterministically
|
||||
- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils`
|
||||
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
|
||||
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
|
||||
- Refresh the checkouts store only when the checkouts settings change
|
||||
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
|
||||
- Publish nostr events directly through the client with relay acceptance required, removing the grasp push helper and its per-server retry loop so failures surface immediately
|
||||
- Poll the repository sync drift check every 180 seconds instead of 60
|
||||
- Track sync status per view instead of globally
|
||||
- Merge the `PixelAvatar` into the `Avatar` component. Seed user pixel avatars from the public key instead of the display name, so avatars stay stable when a user renames
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix the repository view refreshing its mirror from the network while a push is in progress, so the refresh no longer races the push
|
||||
- Fix opening a repository failing with a `destination already exists` error when two loads raced to clone its mirror, clones are now serialized and later loads reuse the finished clone
|
||||
- Fix a repository never opening again after an interrupted clone left an unusable mirror directory behind, which is now removed and re-cloned
|
||||
- Fix signing in with a `bunker://` URI silently generating a new identity instead of erroring when its master key cannot be parsed
|
||||
- Fix repository sync failing with `state event in purgatory ... doesn't match push` when grasp servers hold branches or tags the local repository lacks
|
||||
- Fix sync failing with `could not fetch refs/tags/...^{}` on repositories with annotated tags, where the peel entries `git ls-remote` reports for annotated tags were mistaken for missing refs
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme
|
||||
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
|
||||
|
||||
### Deprecated
|
||||
|
||||
## v0.2.0-alpha - 2026/09/27
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+3428
-1428
File diff suppressed because it is too large
Load Diff
+13
-7
@@ -4,19 +4,21 @@ members = ["crates/*", "desktop"]
|
||||
default-members = ["desktop"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] }
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" }
|
||||
gpui = { package = "gpui-pre", version = "0.3.6" }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "0.3.6", features = ["font-kit", "x11", "wayland"] }
|
||||
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.6" }
|
||||
|
||||
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "c3e3c37731e389d858712369e47eec9c205cdfbe", features = ["layout", "editor"] }
|
||||
# GPUI Kit
|
||||
gpui-component = { version = "0.6.6", features = ["tree-sitter-languages"] }
|
||||
gpui-base = "0.6.6"
|
||||
|
||||
dock = { path = "crates/dock" }
|
||||
settings = { path = "crates/settings" }
|
||||
signed_app = { path = "crates/signed_app" }
|
||||
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
|
||||
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" }
|
||||
@@ -25,7 +27,7 @@ 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.88", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
|
||||
gix = { version = "0.87.1", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
|
||||
|
||||
smol = "2"
|
||||
futures = "0.3"
|
||||
@@ -42,6 +44,10 @@ tracing = "0.1"
|
||||
errno = { version = "0.3.14", default-features = false }
|
||||
instant = "0.1"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use stacker's psm version which may have better WASM support
|
||||
psm = { git = "https://github.com/rust-lang/stacker", branch = "master" }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "z"
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -6,7 +6,8 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-native-kit.workspace = true
|
||||
gpui-component.workspace = true
|
||||
anyhow.workspace = true
|
||||
rust-embed.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>
|
||||
|
Before Width: | Height: | Size: 258 B |
@@ -0,0 +1,433 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Signed",
|
||||
"author": "Signed",
|
||||
"themes": [
|
||||
{
|
||||
"is_default": true,
|
||||
"name": "Signed Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#F0F0F0",
|
||||
"accent.foreground": "#202020",
|
||||
"accordion.background": "#FFFFFF",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E4E4E7",
|
||||
"caret": "#202020",
|
||||
"chart.1": "#93C5FD",
|
||||
"chart.2": "#3B82F6",
|
||||
"chart.3": "#2563EB",
|
||||
"chart.4": "#1D4ED8",
|
||||
"chart.5": "#1E40AF",
|
||||
"chart_bullish": "green-600",
|
||||
"chart_bearish": "red-600",
|
||||
"danger.background": "red-500",
|
||||
"danger.foreground": "neutral-50",
|
||||
"description_list.label.foreground": "#202020",
|
||||
"drag.border": "#65A30D",
|
||||
"drop_target.background": "#C6FF4D40",
|
||||
"foreground": "#18181B",
|
||||
"group_box.background": "#F0F0F0",
|
||||
"group_box.foreground": "#202020",
|
||||
"info.background": "cyan-500",
|
||||
"info.foreground": "neutral-50",
|
||||
"input.border": "#E4E4E7",
|
||||
"link": "#3F6212",
|
||||
"link.active": "#3F6212",
|
||||
"link.hover": "#65A30D",
|
||||
"list.background": "#FFFFFF",
|
||||
"list.active.background": "#C6FF4D33",
|
||||
"list.active.border": "#65A30D",
|
||||
"list.even.background": "#F9F9F9",
|
||||
"list.head.background": "#F9F9F9",
|
||||
"list.hover.background": "#F0F0F0",
|
||||
"muted.background": "#F9F9F9",
|
||||
"muted.foreground": "#646464",
|
||||
"overlay": "#0000000D",
|
||||
"popover.background": "#FFFFFF",
|
||||
"popover.foreground": "#18181B",
|
||||
"primary.background": "#C6FF4D",
|
||||
"primary.active.background": "#65A30D",
|
||||
"primary.active.foreground": "#F7FEE7",
|
||||
"primary.foreground": "#1A2E05",
|
||||
"primary.hover.background": "#B7F03E",
|
||||
"progress.bar.background": "#18181B",
|
||||
"ring": "#BBBBBB",
|
||||
"scrollbar.background": "#F9F9F900",
|
||||
"scrollbar.thumb.background": "#BBBBBBE6",
|
||||
"scrollbar.thumb.hover.background": "#BBBBBB",
|
||||
"secondary.background": "#F0F0F0",
|
||||
"secondary.active.background": "#E0E0E0",
|
||||
"secondary.foreground": "#202020",
|
||||
"secondary.hover.background": "#E8E8E8",
|
||||
"selection.background": "#55A0FC",
|
||||
"sidebar.background": "#F9F9F9",
|
||||
"sidebar.accent.background": "#F0F0F0",
|
||||
"sidebar.accent.foreground": "#202020",
|
||||
"sidebar.border": "#E8E8E8",
|
||||
"sidebar.foreground": "#202020",
|
||||
"sidebar.primary.background": "#C6FF4D",
|
||||
"sidebar.primary.foreground": "#1A2E05",
|
||||
"skeleton.background": "#F0F0F0",
|
||||
"slider.background": "#18181B",
|
||||
"slider.thumb.background": "#FFFFFF",
|
||||
"status_bar.background": "#F9F9F9",
|
||||
"status_bar.border": "#E8E8E8",
|
||||
"success.background": "#2FBF71",
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#CECECE",
|
||||
"switch.thumb.background": "#FFFFFF",
|
||||
"tab.background": "#E8E8E8",
|
||||
"tab.active.background": "#F5F5F5",
|
||||
"tab.active.foreground": "#18181B",
|
||||
"tab.foreground": "#646464",
|
||||
"tab_bar.background": "#F0F0F0",
|
||||
"tab_bar.segmented.background": "#F0F0F0",
|
||||
"table.background": "#FFFFFF",
|
||||
"table.active.background": "#C6FF4D33",
|
||||
"table.active.border": "#65A30D",
|
||||
"table.even.background": "#F9F9F9",
|
||||
"table.head.background": "#F9F9F9",
|
||||
"table.head.foreground": "#838383",
|
||||
"table.hover.background": "#F0F0F0",
|
||||
"table.row.border": "#E8E8E8B3",
|
||||
"tiles.background": "#F9F9F9",
|
||||
"title_bar.background": "#F9F9F9",
|
||||
"title_bar.border": "#E8E8E8",
|
||||
"warning.background": "yellow-500",
|
||||
"warning.foreground": "neutral-50",
|
||||
"window.border": "#E8E8E8",
|
||||
"base.red": "red-600",
|
||||
"base.red.light": "red-400",
|
||||
"base.green": "#16A34A",
|
||||
"base.green.light": "green-400",
|
||||
"base.blue": "blue-600",
|
||||
"base.blue.light": "blue-400",
|
||||
"base.yellow": "yellow-600",
|
||||
"base.yellow.light": "yellow-400",
|
||||
"base.magenta": "purple-600",
|
||||
"base.magenta.light": "purple-400",
|
||||
"base.cyan": "cyan-600",
|
||||
"base.cyan.light": "cyan-400"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#000000",
|
||||
"editor.background": "#ffffff",
|
||||
"editor.active_line.background": "#F5F5F5",
|
||||
"editor.line_number": "#929292",
|
||||
"editor.active_line_number": "#000000",
|
||||
"editor.invisible": "#73737366",
|
||||
"conflict": "#C5060B",
|
||||
"created": "#1642FF",
|
||||
"hidden": "#6D6D6D",
|
||||
"hint": "#9e5dff",
|
||||
"modified": "#9e7008",
|
||||
"predictive": "#A4ABB6",
|
||||
"warning": "#C99401",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#957931"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007fff"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#C5060B"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"emphasis": {
|
||||
"font_style": "italic"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"font_weight": 700
|
||||
},
|
||||
"function": {
|
||||
"color": "#0000A2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#0000A2",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6A7293",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"string": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#036A07"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d21f07"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#0433ff"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#6F42C1"
|
||||
},
|
||||
"title": {
|
||||
"color": "#0433FF"
|
||||
},
|
||||
"type": {
|
||||
"color": "#6f42c1"
|
||||
},
|
||||
"property": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable": {
|
||||
"color": "#333333"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#C5060B"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"is_default": true,
|
||||
"name": "Signed Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#222222",
|
||||
"accent.foreground": "#EEEEEE",
|
||||
"accordion.background": "#0A0A0A",
|
||||
"background": "#0A0A0A",
|
||||
"border": "#27272A",
|
||||
"caret": "#EEEEEE",
|
||||
"chart.1": "#93C5FD",
|
||||
"chart.2": "#3B82F6",
|
||||
"chart.3": "#2563EB",
|
||||
"chart.4": "#1D4ED8",
|
||||
"chart.5": "#1E40AF",
|
||||
"chart_bullish": "green-600",
|
||||
"chart_bearish": "red-600",
|
||||
"danger.background": "red-400",
|
||||
"danger.foreground": "red-600",
|
||||
"description_list.label.background": "#191919",
|
||||
"description_list.label.foreground": "#EEEEEE",
|
||||
"drag.border": "#C6FF4D",
|
||||
"drop_target.background": "#C6FF4D2E",
|
||||
"foreground": "#FAFAFA",
|
||||
"group_box.background": "#191919",
|
||||
"group_box.foreground": "#EEEEEE",
|
||||
"info.background": "cyan-400",
|
||||
"info.foreground": "cyan-600",
|
||||
"input.border": "#2F2F2F",
|
||||
"link": "#A3E635",
|
||||
"link.active": "#A3E635",
|
||||
"link.hover": "#C6FF4D",
|
||||
"list.background": "#0A0A0A",
|
||||
"list.active.background": "#C6FF4D33",
|
||||
"list.active.border": "#C6FF4D",
|
||||
"list.even.background": "#191919",
|
||||
"list.head.background": "#191919",
|
||||
"list.hover.background": "#222222",
|
||||
"muted.background": "#191919",
|
||||
"muted.foreground": "#B4B4B4",
|
||||
"overlay": "#00000033",
|
||||
"popover.background": "#0A0A0A",
|
||||
"popover.foreground": "#FAFAFA",
|
||||
"primary.background": "#C6FF4D",
|
||||
"primary.active.background": "#65A30D",
|
||||
"primary.active.foreground": "#F7FEE7",
|
||||
"primary.foreground": "#1A2E05",
|
||||
"primary.hover.background": "#D6FF7A",
|
||||
"progress.bar.background": "#FAFAFA",
|
||||
"ring": "#606060",
|
||||
"scrollbar.background": "#19191900",
|
||||
"scrollbar.thumb.background": "#606060E6",
|
||||
"scrollbar.thumb.hover.background": "#606060",
|
||||
"secondary.background": "#222222",
|
||||
"secondary.active.background": "#313131",
|
||||
"secondary.foreground": "#EEEEEE",
|
||||
"secondary.hover.background": "#2A2A2A",
|
||||
"selection.background": "#1D4ED8",
|
||||
"sidebar.background": "#111111",
|
||||
"sidebar.accent.background": "#222222",
|
||||
"sidebar.accent.foreground": "#EEEEEE",
|
||||
"sidebar.border": "#2A2A2A",
|
||||
"sidebar.foreground": "#EEEEEE",
|
||||
"sidebar.primary.background": "#C6FF4D",
|
||||
"sidebar.primary.foreground": "#1A2E05",
|
||||
"skeleton.background": "#222222",
|
||||
"slider.background": "#FAFAFA",
|
||||
"slider.thumb.background": "#0A0A0A",
|
||||
"status_bar.background": "#191919",
|
||||
"status_bar.border": "#2A2A2A",
|
||||
"success.background": "#2FBF71",
|
||||
"success.foreground": "#052E16",
|
||||
"switch.background": "#484848",
|
||||
"switch.thumb.background": "#0A0A0A",
|
||||
"tab.background": "#2E2E2E",
|
||||
"tab.active.background": "#2A2A2A",
|
||||
"tab.active.foreground": "#FAFAFA",
|
||||
"tab.foreground": "#B4B4B4",
|
||||
"tab_bar.background": "#191919",
|
||||
"tab_bar.segmented.background": "#191919",
|
||||
"table.background": "#0A0A0A",
|
||||
"table.active.background": "#C6FF4D33",
|
||||
"table.active.border": "#C6FF4D",
|
||||
"table.even.background": "#191919",
|
||||
"table.head.background": "#191919",
|
||||
"table.head.foreground": "#7B7B7B",
|
||||
"table.hover.background": "#222222",
|
||||
"table.row.border": "#2A2A2AB3",
|
||||
"tiles.background": "#191919",
|
||||
"title_bar.background": "#191919",
|
||||
"title_bar.border": "#2A2A2A",
|
||||
"warning.background": "yellow-400",
|
||||
"warning.foreground": "yellow-600",
|
||||
"window.border": "#2A2A2A",
|
||||
"base.red": "red-400",
|
||||
"base.red.light": "red-300",
|
||||
"base.green": "#22C55E",
|
||||
"base.green.light": "green-300",
|
||||
"base.blue": "blue-400",
|
||||
"base.blue.light": "blue-300",
|
||||
"base.yellow": "yellow-400",
|
||||
"base.yellow.light": "yellow-300",
|
||||
"base.magenta": "purple-400",
|
||||
"base.magenta.light": "purple-300",
|
||||
"base.cyan": "cyan-400",
|
||||
"base.cyan.light": "cyan-300"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#CACCCA",
|
||||
"editor.background": "#0a0a0a",
|
||||
"editor.active_line.background": "#171717",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#73737366",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#E44A4F",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0059D1",
|
||||
"info.border": "#0059D1",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#7FAEF9"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9D9D9D"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"emphasis": {
|
||||
"font_style": "italic"
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"font_weight": 700
|
||||
},
|
||||
"function": {
|
||||
"color": "#B3C5F3"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#87B1F6"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#419CFF",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7faef9",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#CC9E00"
|
||||
},
|
||||
"string": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#68DC7C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#419CFF"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"text.code.span": {
|
||||
"color": "#A3E09F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#CC9E00",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#CBA6F7"
|
||||
},
|
||||
"property": {
|
||||
"color": "#BCC4E0"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#419CFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+29
-21
@@ -1,10 +1,12 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use gpui_native_kit::icons::IconNamed;
|
||||
use gpui_component::IconNamed;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "assets"]
|
||||
#[include = "icons/**/*.svg"]
|
||||
#[include = "themes/**/*.json"]
|
||||
#[include = "backgrounds/**/*.jpg"]
|
||||
#[include = "backgrounds/**/*.png"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
@@ -12,19 +14,35 @@ pub struct Assets;
|
||||
|
||||
impl AssetSource for Assets {
|
||||
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
|
||||
if let Some(file) = Self::get(path) {
|
||||
return Ok(Some(file.data));
|
||||
}
|
||||
gpui_native_kit::icons::IconAssets.load(path)
|
||||
Self::get(path)
|
||||
.map(|f| Some(f.data))
|
||||
.with_context(|| format!("loading asset at path {path:?}"))
|
||||
}
|
||||
|
||||
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
|
||||
let mut paths: Vec<SharedString> = Self::iter()
|
||||
.filter(|p| p.starts_with(path))
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
paths.extend(gpui_native_kit::icons::IconAssets.list(path)?);
|
||||
Ok(paths)
|
||||
Ok(Self::iter()
|
||||
.filter_map(|p| {
|
||||
if p.starts_with(path) {
|
||||
Some(p.into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
pub fn themes(&self) -> Vec<(String, String)> {
|
||||
Self::iter()
|
||||
.filter(|path| path.starts_with("themes/"))
|
||||
.filter_map(|path| {
|
||||
let data = Self::get(path.as_ref())?;
|
||||
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
|
||||
let content = std::str::from_utf8(data.data.as_ref()).ok()?.to_owned();
|
||||
Some((name.to_owned(), content))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +72,6 @@ pub enum CustomIconName {
|
||||
Refresh,
|
||||
Grid,
|
||||
Init,
|
||||
EllipsisVertical,
|
||||
ArrowUp,
|
||||
Trash,
|
||||
Globe,
|
||||
Inbox,
|
||||
}
|
||||
|
||||
impl IconNamed for CustomIconName {
|
||||
@@ -89,11 +102,6 @@ impl IconNamed for CustomIconName {
|
||||
CustomIconName::Recent => "icons/recent.svg",
|
||||
CustomIconName::Grid => "icons/grid.svg",
|
||||
CustomIconName::Init => "icons/init.svg",
|
||||
CustomIconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
|
||||
CustomIconName::ArrowUp => "icons/arrow-up.svg",
|
||||
CustomIconName::Trash => "icons/trash.svg",
|
||||
CustomIconName::Globe => "icons/globe.svg",
|
||||
CustomIconName::Inbox => "icons/inbox.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "dock"
|
||||
description = "The Signed dock skin over gpui-component's upstream dock (gpui_base::dock engine + renderer traits)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
settings = { path = "../settings" }
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::cell::Cell;
|
||||
use std::ops::Deref as _;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _,
|
||||
IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style,
|
||||
Styled as _, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
DockArea, DockAreaRenderer, DockContext, DockEvent, DockPlacement, NodeId, PanelState,
|
||||
PanelView, TabGroupRenderer,
|
||||
};
|
||||
use gpui_base::resize_handle;
|
||||
use gpui_component::{ActiveTheme as _, Side};
|
||||
|
||||
use crate::invalid_panel::InvalidPanel;
|
||||
use crate::panel_handle;
|
||||
use crate::tab_panel::SignedTabGroupSkin;
|
||||
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||
resizing_dock: Cell<Option<DockPlacement>>,
|
||||
}
|
||||
|
||||
impl SkinShared {
|
||||
pub(crate) fn area(&self) -> &WeakEntity<DockArea> {
|
||||
&self.area
|
||||
}
|
||||
|
||||
pub(crate) fn is_toggle_button_visible(&self) -> bool {
|
||||
self.toggle_button_visible.get()
|
||||
}
|
||||
|
||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||
&self.resizing_dock
|
||||
}
|
||||
|
||||
/// Redraw the area after a setting changed. The skin is not an entity, so nothing else would.
|
||||
pub(crate) fn notify(&self, cx: &mut App) {
|
||||
_ = self.area.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
}
|
||||
|
||||
/// The Signed appearance for a [`DockArea`].
|
||||
/// Install it in the constructor, the only place the area's weak handle is available.
|
||||
///
|
||||
/// ```ignore
|
||||
/// let dock = cx.new(|cx| {
|
||||
/// let skin = SignedDockSkin::new(cx);
|
||||
/// DockArea::new("dock", Some(1), window, cx).with_renderer(skin)
|
||||
/// });
|
||||
/// ```
|
||||
pub struct SignedDockSkin {
|
||||
shared: Rc<SkinShared>,
|
||||
}
|
||||
|
||||
impl SignedDockSkin {
|
||||
pub fn new(cx: &mut Context<DockArea>) -> Rc<Self> {
|
||||
Rc::new(Self {
|
||||
shared: Rc::new(SkinShared {
|
||||
area: cx.weak_entity(),
|
||||
toggle_button_visible: Cell::new(true),
|
||||
resizing_dock: Cell::new(None),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn shared(&self) -> &Rc<SkinShared> {
|
||||
&self.shared
|
||||
}
|
||||
|
||||
/// Whether tab bars offer the affordance that collapses a neighbouring dock.
|
||||
pub fn is_toggle_button_visible(&self) -> bool {
|
||||
self.shared.is_toggle_button_visible()
|
||||
}
|
||||
|
||||
pub fn set_toggle_button_visible(&self, visible: bool, cx: &mut App) {
|
||||
self.shared.toggle_button_visible.set(visible);
|
||||
self.shared.notify(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload a dock's resize handle drags.
|
||||
///
|
||||
/// It draws nothing, the handle element is the visible affordance.
|
||||
#[derive(Clone)]
|
||||
struct ResizePanel;
|
||||
|
||||
impl Render for ResizePanel {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
impl DockAreaRenderer for SignedDockSkin {
|
||||
fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("dock-area")
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.flex()
|
||||
.flex_row()
|
||||
}
|
||||
|
||||
fn center_frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("dock-area-center")
|
||||
.flex()
|
||||
.flex_1()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
}
|
||||
|
||||
fn split_frame(&self, node: NodeId, _: Axis, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
// `size_full` and `flex_1` stop the frame collapsing in an unsizing parent.
|
||||
div()
|
||||
.id(("dock-split-frame", node.as_u64()))
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.min_h(px(0.))
|
||||
.overflow_hidden()
|
||||
.bg(cx.theme().tokens.tab_bar)
|
||||
}
|
||||
|
||||
fn render_dock(
|
||||
&self,
|
||||
dock: &DockContext,
|
||||
content: AnyElement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(content)
|
||||
.child(self.render_resize_handle(dock, window, cx))
|
||||
.child(DockResizeTracker {
|
||||
dock: dock.clone(),
|
||||
shared: self.shared().clone(),
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Arc<dyn PanelView>> {
|
||||
let state = state.clone();
|
||||
Some(panel_handle(cx.new(|cx| {
|
||||
InvalidPanel::new(state.panel_name.clone(), state, cx)
|
||||
})))
|
||||
}
|
||||
|
||||
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
|
||||
Rc::new(SignedTabGroupSkin::new(self.shared().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl SignedDockSkin {
|
||||
fn render_resize_handle(
|
||||
&self,
|
||||
dock: &DockContext,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let placement = dock.placement();
|
||||
let shared = self.shared().clone();
|
||||
|
||||
resize_handle("resize-handle", placement.axis())
|
||||
.when(placement.is_left(), |this| this.placement(Side::Left))
|
||||
.on_drag(ResizePanel, move |info, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
shared.resizing_dock().set(Some(placement));
|
||||
cx.new(|_| info.deref().clone())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the window's mouse stream into dock resizing.
|
||||
/// It draws nothing, the `paint` hook is the only window listener registration point.
|
||||
struct DockResizeTracker {
|
||||
dock: DockContext,
|
||||
shared: Rc<SkinShared>,
|
||||
}
|
||||
|
||||
impl IntoElement for DockResizeTracker {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DockResizeTracker {
|
||||
type PrepaintState = ();
|
||||
type RequestLayoutState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
(window.request_layout(Style::default(), None, cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
_: &mut App,
|
||||
) {
|
||||
let placement = self.dock.placement();
|
||||
|
||||
window.on_mouse_event({
|
||||
let dock = self.dock.clone();
|
||||
let shared = self.shared.clone();
|
||||
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
||||
return;
|
||||
}
|
||||
// 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()
|
||||
.is_some_and(|area| area.read(cx).is_dock_open(placement));
|
||||
if !open {
|
||||
dock.toggle(window, cx);
|
||||
}
|
||||
dock.resize_to(event.position, window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let shared = self.shared.clone();
|
||||
move |_: &MouseUpEvent, phase, _, cx| {
|
||||
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
|
||||
return;
|
||||
}
|
||||
shared.resizing_dock().set(None);
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use gpui::{
|
||||
App, Context, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div,
|
||||
};
|
||||
use gpui_base::dock::{PanelEvent, PanelState};
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Panel;
|
||||
|
||||
/// 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,
|
||||
old_state: PanelState,
|
||||
}
|
||||
|
||||
impl InvalidPanel {
|
||||
pub(crate) fn new(
|
||||
name: impl Into<SharedString>,
|
||||
state: PanelState,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
name: name.into(),
|
||||
old_state: state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui_base::dock::Panel for InvalidPanel {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"InvalidPanel"
|
||||
}
|
||||
|
||||
fn dump(&self, _: &App) -> PanelState {
|
||||
self.old_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for InvalidPanel {}
|
||||
|
||||
impl EventEmitter<PanelEvent> for InvalidPanel {}
|
||||
|
||||
impl Focusable for InvalidPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InvalidPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.my_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(format!(
|
||||
"The `{}` panel type is not registered in PanelRegistry.",
|
||||
self.name.clone()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{Context, Pixels, Window, px};
|
||||
use gpui_base::dock::PanelView;
|
||||
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
mod tab_panel;
|
||||
mod window_controls;
|
||||
|
||||
pub use dock_area::SignedDockSkin;
|
||||
pub use gpui_component::dock::{
|
||||
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.);
|
||||
|
||||
/// 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.Close" => "Close",
|
||||
"Dock.Zoom In" => "Zoom In",
|
||||
"Dock.Zoom Out" => "Zoom Out",
|
||||
"Dock.Collapse" => "Collapse",
|
||||
"Dock.Expand" => "Expand",
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div,
|
||||
Empty, InteractiveElement as _, IntoElement, ParentElement as _, Pixels, Point, Render,
|
||||
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled as _, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::dock::{
|
||||
AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef,
|
||||
PanelView as BasePanelView, TabGroupContext, TabGroupRenderer,
|
||||
};
|
||||
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::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex};
|
||||
use settings::{SettingsStore, TabBarSettings};
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::{TAB_BAR_HEIGHT, t, window_controls};
|
||||
|
||||
const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = size(px(96.), px(30.));
|
||||
const COLLAPSED_TAB_BAR_SELECTOR: &str = "signed-dock-collapsed-tab-bar";
|
||||
const ACTIVE_TAB_SELECTOR: &str = "signed-dock-active-tab";
|
||||
const ACTIVE_TAB_CLOSE_SELECTOR: &str = "signed-dock-active-tab-close";
|
||||
const TAB_HOVER_GROUP: &str = "signed-dock-tab";
|
||||
const NAVIGATION_BUTTONS_TAB_THRESHOLD: usize = 10;
|
||||
|
||||
/// Whether a group's prev/next buttons should be drawn.
|
||||
fn navigation_buttons_visible(hide_navigation_buttons: bool, panels_len: usize) -> bool {
|
||||
!hide_navigation_buttons && panels_len > NAVIGATION_BUTTONS_TAB_THRESHOLD
|
||||
}
|
||||
|
||||
/// A panel's title, or its registered name when the panel has no handle.
|
||||
pub(crate) fn panel_title(
|
||||
panel: &Arc<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
match PanelHandle::of(panel) {
|
||||
Some(handle) => handle.title(window, cx),
|
||||
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The preview that follows the cursor while a panel is dragged.
|
||||
struct DragPanelPreview {
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
}
|
||||
|
||||
impl Render for DragPanelPreview {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id("drag-panel")
|
||||
.cursor_grab()
|
||||
.p_1()
|
||||
.whitespace_nowrap()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tokens.tab_active)
|
||||
.opacity(0.75)
|
||||
.child(panel_title(&self.panel, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom affordance for the group's displayed panel, if it offers one.
|
||||
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
|
||||
panel_zoom_control(group.active_panel()?, cx)
|
||||
}
|
||||
|
||||
/// The zoom affordance `panel` offers, if any.
|
||||
///
|
||||
/// The panel must offer a control and be zoomable, base refuses a zoom otherwise.
|
||||
fn panel_zoom_control(panel: &Arc<dyn BasePanelView>, cx: &App) -> Option<PanelControl> {
|
||||
panel
|
||||
.zoomable(cx)
|
||||
.then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// The left-most, top-most tab group in a container.
|
||||
///
|
||||
/// A left dock's collapse button lives in this group.
|
||||
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
match node.kind() {
|
||||
PaneRef::Tabs { .. } => Some(node.id()),
|
||||
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
PaneRef::Split { axis, children, .. } => match axis {
|
||||
gpui::Axis::Vertical => children.first(),
|
||||
gpui::Axis::Horizontal => children.last(),
|
||||
}
|
||||
.and_then(right_top_group),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bottom or right dock whose root tab group is this one, if any.
|
||||
///
|
||||
/// Base keeps a dock's last group, so the skin removes these docks as a whole.
|
||||
fn dock_root_group(
|
||||
shared: &SkinShared,
|
||||
group: &TabGroupContext,
|
||||
cx: &App,
|
||||
) -> Option<DockPlacement> {
|
||||
let area = shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
|
||||
[DockPlacement::Bottom, DockPlacement::Right]
|
||||
.into_iter()
|
||||
.find(|placement| {
|
||||
area.layout(*placement)
|
||||
.is_some_and(|tree| tree.root().id() == group.node())
|
||||
})
|
||||
}
|
||||
|
||||
/// Close `panel`, removing its dock instead when base would refuse an empty one.
|
||||
///
|
||||
/// A bottom or right dock's only panel is that case, so the whole dock goes.
|
||||
fn close_panel(
|
||||
shared: &SkinShared,
|
||||
group: &TabGroupContext,
|
||||
panel: &Arc<dyn BasePanelView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if !panel.closable(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dock = (group.panels().len() <= 1)
|
||||
.then(|| dock_root_group(shared, group, cx))
|
||||
.flatten();
|
||||
|
||||
match dock {
|
||||
Some(placement) => {
|
||||
_ = shared.area().update(cx, |area, cx| {
|
||||
area.remove_dock(placement, window, cx);
|
||||
});
|
||||
}
|
||||
None => group.close(panel.panel_id(cx), window, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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, measured to place the title-bar drag overlay.
|
||||
title_bar_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
/// 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 suffix area, where the drag region ends.
|
||||
title_bar_suffix_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
|
||||
}
|
||||
|
||||
impl SignedTabGroupSkin {
|
||||
pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
|
||||
Self {
|
||||
shared,
|
||||
scroll_handle: ScrollHandle::default(),
|
||||
last_active_ix: Cell::new(None),
|
||||
title_bar_bounds: Rc::new(Cell::new(None)),
|
||||
title_bar_strip_bounds: Rc::new(Cell::new(None)),
|
||||
title_bar_suffix_bounds: Rc::new(Cell::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
};
|
||||
let area = area.read(cx);
|
||||
let Some(left) = area
|
||||
.layout(DockPlacement::Left)
|
||||
.map(|tree| tree.root().id())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
left == group.node() && group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// The tab's drag payload, or `None` when the group must not be rearranged.
|
||||
///
|
||||
/// A locked group never is, a bottom or right dock root always is.
|
||||
fn tab_drag(&self, group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
|
||||
if group.is_locked() {
|
||||
return None;
|
||||
}
|
||||
if !group.is_draggable() && dock_root_group(&self.shared, group, cx).is_none() {
|
||||
return None;
|
||||
}
|
||||
group.drag_panel(ix, cx)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
group: &TabGroupContext,
|
||||
cx: &mut App,
|
||||
) -> Option<Button> {
|
||||
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let area = self.shared.area().upgrade()?;
|
||||
let area = area.read(cx);
|
||||
// A missing dock is not collapsible, this also covers the old `left_dock.is_some()` test.
|
||||
if !area.is_dock_collapsible(placement) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let designated = match placement {
|
||||
DockPlacement::Left => area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Right => area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| right_top_group(tree.root())),
|
||||
DockPlacement::Bottom => area
|
||||
.layout(DockPlacement::Bottom)
|
||||
.and_then(|tree| left_top_group(tree.root())),
|
||||
DockPlacement::Center => return None,
|
||||
};
|
||||
if designated != Some(group.node()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let is_open = area.is_dock_open(placement);
|
||||
let icon = match (placement, is_open) {
|
||||
(DockPlacement::Left, true) => IconName::PanelLeft,
|
||||
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
|
||||
(DockPlacement::Right, true) => IconName::PanelRight,
|
||||
(DockPlacement::Right, false) => IconName::PanelRightOpen,
|
||||
(DockPlacement::Bottom, true) => IconName::PanelBottom,
|
||||
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
|
||||
(DockPlacement::Center, _) => return None,
|
||||
};
|
||||
|
||||
let area = self.shared.area().clone();
|
||||
Some(
|
||||
Button::new(SharedString::from(format!("toggle-dock:{placement:?}")))
|
||||
.icon(icon)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip(match is_open {
|
||||
true => t("Dock.Collapse"),
|
||||
false => t("Dock.Expand"),
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
_ = area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The previous and next tab buttons in the tab bar's leading prefix.
|
||||
///
|
||||
/// Hidden by default, and only offered for crowded strips where the active
|
||||
/// tab may have scrolled out of view. Disabled at the strip ends or collapsed.
|
||||
fn render_prev_next_tab_buttons(&self, group: &TabGroupContext, cx: &mut App) -> AnyElement {
|
||||
let collapsed = group.is_collapsed();
|
||||
let active_ix = group.active_ix();
|
||||
let panels_len = group.panels().len();
|
||||
|
||||
let hidden = SettingsStore::try_global(cx)
|
||||
.map(|store| store.read(cx).settings().tab_bar.hide_navigation_buttons)
|
||||
.unwrap_or_else(|| TabBarSettings::default().hide_navigation_buttons);
|
||||
|
||||
if !navigation_buttons_visible(hidden, panels_len) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let prev_enabled = !collapsed && active_ix > 0;
|
||||
let next_enabled = !collapsed && active_ix + 1 < panels_len;
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("tab:prev")
|
||||
.icon(IconName::ArrowLeft)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip("Previous tab")
|
||||
.disabled(!prev_enabled)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
move |_, window, cx| group.select_tab(active_ix - 1, window, cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("tab:next")
|
||||
.icon(IconName::ArrowRight)
|
||||
.small()
|
||||
.ghost()
|
||||
.tab_stop(false)
|
||||
.tooltip("Next tab")
|
||||
.disabled(!next_enabled)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
move |_, window, cx| group.select_tab(active_ix + 1, window, cx)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// One tab of the pill strip.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_tab(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
ix: usize,
|
||||
panel: Arc<dyn BasePanelView>,
|
||||
active: bool,
|
||||
is_bottom_dock: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Tab {
|
||||
let collapsed = group.is_collapsed();
|
||||
let droppable = group.is_droppable();
|
||||
let drag = self.tab_drag(group, ix, cx);
|
||||
let handle = PanelHandle::of(&panel);
|
||||
let id = SharedString::from(format!("close-tab:{}:{ix}", group.node().as_u64()));
|
||||
|
||||
let closable = !collapsed
|
||||
&& (group.is_closable()
|
||||
|| (dock_root_group(&self.shared, group, cx).is_some() && panel.closable(cx)));
|
||||
|
||||
// Whether a double-click zooms this tab in.
|
||||
let can_zoom_in = panel_zoom_control(&panel, cx).is_some();
|
||||
|
||||
Tab::new(ix)
|
||||
.group(TAB_HOVER_GROUP)
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_7()
|
||||
.min_w_40()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.text_sm()
|
||||
.overflow_hidden()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.h_6()
|
||||
.items_center()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
|
||||
Some(tab_name) => this.child(tab_name),
|
||||
None => this.child(panel_title(&panel, window, cx)),
|
||||
}),
|
||||
)
|
||||
.when(closable, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.when(active, |this| {
|
||||
this.debug_selector(|| ACTIVE_TAB_CLOSE_SELECTOR.to_string())
|
||||
})
|
||||
.when(!active, |this| {
|
||||
this.invisible()
|
||||
.group_hover(TAB_HOVER_GROUP, |this| this.visible())
|
||||
})
|
||||
.child(
|
||||
Button::new(id)
|
||||
.icon(IconName::Close)
|
||||
.tooltip(t("Dock.Close"))
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tab_stop(false)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let shared = self.shared.clone();
|
||||
let panel = panel.clone();
|
||||
move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
close_panel(&shared, &group, &panel, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
.styles(|styles| {
|
||||
styles.selected(|style| {
|
||||
style
|
||||
.text_color(cx.theme().tab_active_foreground)
|
||||
.bg(cx.theme().tab_active)
|
||||
})
|
||||
})
|
||||
.hover(|this| {
|
||||
if active {
|
||||
this
|
||||
} else {
|
||||
this.text_color(cx.theme().tab_foreground)
|
||||
.bg(cx.theme().tab)
|
||||
}
|
||||
})
|
||||
.selected(active)
|
||||
.on_click({
|
||||
let group = group.clone();
|
||||
let area = self.shared.area().clone();
|
||||
move |event, window, cx| {
|
||||
group.select_tab(ix, window, cx);
|
||||
|
||||
// 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);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A double-click zooms the tab's panel
|
||||
if event.click_count() < 2 || (!group.is_zoomed() && !can_zoom_in) {
|
||||
return;
|
||||
}
|
||||
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.when(!collapsed, |this| {
|
||||
this.when_some(drag, |this, drag| {
|
||||
this.on_drag(drag, {
|
||||
let panel = panel.clone();
|
||||
move |drag, offset, _, cx| {
|
||||
cx.stop_propagation();
|
||||
drag.set_drag_offset(offset);
|
||||
drag.set_preview_size(DRAG_PREVIEW_SIZE);
|
||||
cx.new(|_| DragPanelPreview {
|
||||
panel: panel.clone(),
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(droppable, |this| {
|
||||
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().drag_border)
|
||||
})
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |drag: &DragPanel, window, cx| {
|
||||
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
|
||||
}
|
||||
})
|
||||
.drag_over::<AnyDrag>(|this, _, _, cx| {
|
||||
this.rounded_l_none()
|
||||
.border_l_2()
|
||||
.border_r_0()
|
||||
.border_color(cx.theme().drag_border)
|
||||
})
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |item: &AnyDrag, window, cx| {
|
||||
group.drop_item(item.clone(), None, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
tabs_count: usize,
|
||||
_cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let strip_bounds = self.title_bar_strip_bounds.clone();
|
||||
let shared = self.shared.clone();
|
||||
let droppable = group.is_droppable();
|
||||
|
||||
let mut empty = div()
|
||||
.id("tab-bar-empty-space")
|
||||
.h_full()
|
||||
.flex_grow_1()
|
||||
.min_w_16()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if strip_bounds.get() != Some(bounds) {
|
||||
strip_bounds.set(Some(bounds));
|
||||
_ = shared.area().update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
});
|
||||
|
||||
if droppable {
|
||||
empty = empty
|
||||
.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
|
||||
.on_drop({
|
||||
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.
|
||||
// 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);
|
||||
}
|
||||
})
|
||||
.drag_over::<AnyDrag>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
|
||||
.on_drop({
|
||||
let group = group.clone();
|
||||
move |item: &AnyDrag, window, cx| {
|
||||
group.drop_item(item.clone(), None, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
empty.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
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 draws nothing, so no bare tab bar is left behind.
|
||||
if group.panels().is_empty() {
|
||||
return div().id("tab-panel");
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
|
||||
// `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)
|
||||
.when(!group.is_collapsed(), |this| {
|
||||
this.on_action({
|
||||
let group = group.clone();
|
||||
move |_: &ToggleZoom, window, cx| {
|
||||
if !group.is_zoomed() && control.is_none() {
|
||||
return;
|
||||
}
|
||||
group.toggle_zoom(window, cx);
|
||||
}
|
||||
})
|
||||
.on_action({
|
||||
let group = group.clone();
|
||||
let shared = shared.clone();
|
||||
move |_: &ClosePanel, window, cx| {
|
||||
let Some(panel) = group.active_panel() else {
|
||||
return;
|
||||
};
|
||||
close_panel(&shared, &group, panel, window, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn content_frame(&self, group: &TabGroupContext, _: &mut Window, _: &mut App) -> Stateful<Div> {
|
||||
v_flex()
|
||||
.id("active-panel")
|
||||
// A collapsed group draws its tab strip only, so the content claims no space.
|
||||
.when(!group.is_collapsed(), |this| this.flex_1())
|
||||
}
|
||||
|
||||
fn render_tab_bar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
// 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, like the vendored `DockItem::Panel`.
|
||||
if self.is_plain_sidebar_group(group, cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let collapsed = group.is_collapsed();
|
||||
let active_ix = group.active_ix();
|
||||
let tabs_count = group.panels().len();
|
||||
|
||||
let left_dock_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
|
||||
let bottom_dock_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
|
||||
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let is_bottom_dock = bottom_dock_button.is_some();
|
||||
|
||||
// On macOS the traffic lights overlay the window's top-left corner, so the
|
||||
// tab bar under them reserves the space. That is the center's top-left group
|
||||
// when the left dock is closed or absent, and the zoomed group otherwise:
|
||||
// a zoom fills the whole area, hiding the docks the frame would draw.
|
||||
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||
&& self.shared.area().upgrade().is_some_and(|area| {
|
||||
let area = area.read(cx);
|
||||
match area.zoomed_group() {
|
||||
Some(zoomed) => zoomed == group.node(),
|
||||
None => {
|
||||
!area.is_dock_open(DockPlacement::Left)
|
||||
&& area
|
||||
.layout(DockPlacement::Center)
|
||||
.and_then(|tree| left_top_group(tree.root()))
|
||||
== Some(group.node())
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bring a newly displayed tab into view.
|
||||
// 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()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, panel)| panel.visible(cx))
|
||||
.map(|(ix, _)| ix)
|
||||
.collect();
|
||||
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
|
||||
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
|
||||
{
|
||||
self.scroll_handle.scroll_to_item(visible_ix);
|
||||
}
|
||||
|
||||
// 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(),
|
||||
self.title_bar_suffix_bounds.get(),
|
||||
) {
|
||||
(Some(title), Some(strip), Some(suffix)) => {
|
||||
let left = strip.left() - title.left();
|
||||
let right = title.right() - suffix.left();
|
||||
(left + right < title.size.width).then(|| {
|
||||
title_bar_drag_handlers(
|
||||
div()
|
||||
.id(("title-bar-drag", group.node().as_u64()))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.bottom_0()
|
||||
.left(left)
|
||||
.right(right),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let tabs: Vec<_> = group
|
||||
.panels()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, panel)| {
|
||||
let mut active = displayed == Some(panel.panel_id(cx));
|
||||
if !panel.visible(cx) {
|
||||
return None;
|
||||
}
|
||||
// Collapsed tabs never show as active, the strip only reopens the dock.
|
||||
if collapsed {
|
||||
active = false;
|
||||
}
|
||||
Some(self.render_tab(group, ix, panel.clone(), active, is_bottom_dock, window, cx))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let empty_space = self.render_empty_space(group, tabs_count, cx);
|
||||
let title_bar_bounds = self.title_bar_bounds.clone();
|
||||
let title_bar_suffix_bounds = self.title_bar_suffix_bounds.clone();
|
||||
let shared = self.shared.clone();
|
||||
let suffix_shared = self.shared.clone();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if title_bar_bounds.get() != Some(bounds) {
|
||||
title_bar_bounds.set(Some(bounds));
|
||||
_ = shared.area().update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
})
|
||||
.child(
|
||||
Tabs::new("tab-bar")
|
||||
.px(px(-1.))
|
||||
// A collapsed group stands in for a closed bottom dock,
|
||||
// which base gives only its reserved strip. The bar keeps
|
||||
// its own height there rather than the title-bar height,
|
||||
// so it fits instead of being clipped away.
|
||||
.when(!collapsed, |this| this.h(TAB_BAR_HEIGHT))
|
||||
// Only a collapsed (bottom) dock tags its bar, so the
|
||||
// selector is unique when a test measures it.
|
||||
.when(collapsed, |this| {
|
||||
this.debug_selector(|| COLLAPSED_TAB_BAR_SELECTOR.to_string())
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.text_color(cx.theme().tab_foreground)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
// -1 px so the border does not overlap the first tab.
|
||||
.right(-px(1.))
|
||||
.h_full()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
|
||||
.children(left_dock_button)
|
||||
.children(bottom_dock_button)
|
||||
.child(self.render_prev_next_tab_buttons(group, cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex().id("tabs").flex_1().overflow_x_hidden().child(
|
||||
h_flex()
|
||||
.id("tabs-inner")
|
||||
.relative()
|
||||
.gap(px(4.))
|
||||
.overflow_x_scroll()
|
||||
.lock_scroll_axis()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.children(tabs)
|
||||
.when(!collapsed, |this| this.child(empty_space)),
|
||||
),
|
||||
)
|
||||
.when(!collapsed, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.h_full()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.on_prepaint(move |bounds, _, cx| {
|
||||
if title_bar_suffix_bounds.get() != Some(bounds) {
|
||||
title_bar_suffix_bounds.set(Some(bounds));
|
||||
_ = suffix_shared
|
||||
.area()
|
||||
.update(cx, |_, cx| cx.notify());
|
||||
}
|
||||
})
|
||||
.children(
|
||||
group
|
||||
.active_panel()
|
||||
.and_then(PanelHandle::of)
|
||||
.and_then(|handle| handle.title_suffix(window, cx)),
|
||||
)
|
||||
.children(right_dock_button),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
|
||||
)
|
||||
.child(window_controls::window_controls(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_active_panel(
|
||||
&self,
|
||||
panel: AnyView,
|
||||
group: &TabGroupContext,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> AnyElement {
|
||||
if group.is_collapsed() {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.id("tab-content")
|
||||
.overflow_y_scroll()
|
||||
.overflow_x_hidden()
|
||||
.flex_1()
|
||||
.child(panel.cached(StyleRefinement::default().absolute().size_full()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_drop_indicator(
|
||||
&self,
|
||||
indicator: DropIndicator,
|
||||
_: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
let (from, to) = (indicator.from(), indicator.to());
|
||||
// The element sits at the drop target, the animation walks back from the source.
|
||||
let offset = from.origin() - to.origin();
|
||||
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left(to.origin().x)
|
||||
.top(to.origin().y)
|
||||
.w(to.size().width)
|
||||
.h(to.size().height)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.bg(cx.theme().tokens.drop_target)
|
||||
.with_animation(
|
||||
gpui::ElementId::NamedInteger(
|
||||
"drop-placeholder".into(),
|
||||
indicator.epoch(),
|
||||
),
|
||||
Animation::new(Duration::from_millis(150)).with_easing(ease_out_cubic),
|
||||
move |this, delta| {
|
||||
let origin = offset.lerp(&Point::default(), delta);
|
||||
let width = from.size().width.lerp(&to.size().width, delta);
|
||||
let height = from.size().height.lerp(&to.size().height, delta);
|
||||
this.left(origin.x).top(origin.y).w(width).h(height)
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
Render, TestAppContext, Window, px,
|
||||
};
|
||||
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
|
||||
|
||||
use super::{COLLAPSED_TAB_BAR_SELECTOR, navigation_buttons_visible};
|
||||
use crate::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||
|
||||
struct Probe {
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for Probe {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Probe {
|
||||
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Probe {}
|
||||
|
||||
impl Focusable for Probe {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Probe {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// Base reserves only `gpui_base::dock::CLOSED_BOTTOM_STRIP` for a closed
|
||||
/// bottom dock, so the tab bar drawn in place of one has to shrink to that
|
||||
/// height; at the full title-bar height its bottom was clipped away.
|
||||
#[gpui::test]
|
||||
fn a_collapsed_bottom_dock_bar_fits_the_reserved_strip(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
});
|
||||
let (area, cx) = cx.add_window_view(|window, cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
DockArea::new("test", None, window, cx).with_renderer(skin)
|
||||
});
|
||||
|
||||
cx.update(|window, cx| {
|
||||
let center = cx.new(Probe::new);
|
||||
let bottom = cx.new(Probe::new);
|
||||
area.update(cx, |area, cx| {
|
||||
area.set_center(
|
||||
DockLayout::tabs().panel_view(panel_handle(center), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
area.set_dock(
|
||||
DockPlacement::Bottom,
|
||||
DockLayout::tabs().panel_view(panel_handle(bottom), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
// Open, nothing is collapsed, so no bar carries the selector.
|
||||
assert!(cx.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR).is_none());
|
||||
|
||||
cx.update(|window, cx| {
|
||||
area.update(cx, |area, cx| {
|
||||
area.toggle_dock(DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
|
||||
let bounds = cx
|
||||
.debug_bounds(COLLAPSED_TAB_BAR_SELECTOR)
|
||||
.expect("a collapsed bottom dock draws its stand-in tab bar");
|
||||
// `gpui_base::dock::CLOSED_BOTTOM_STRIP`, which the engine does not
|
||||
// re-export. The bar used to be `TAB_BAR_HEIGHT` here and its bottom was
|
||||
// clipped away; it now has to fit inside what base reserves.
|
||||
let reserved_strip = px(29.);
|
||||
assert!(
|
||||
bounds.size.height <= reserved_strip,
|
||||
"the collapsed bar is {}px tall, more than the {}px strip base reserves",
|
||||
bounds.size.height,
|
||||
reserved_strip,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_buttons_follow_the_setting_and_tab_count() {
|
||||
// Hidden by default, no matter how many tabs there are.
|
||||
assert!(!navigation_buttons_visible(true, 1));
|
||||
assert!(!navigation_buttons_visible(true, 20));
|
||||
|
||||
// When not hidden they still only appear for a crowded strip.
|
||||
assert!(!navigation_buttons_visible(false, 10));
|
||||
assert!(navigation_buttons_visible(false, 11));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||
StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
|
||||
|
||||
use crate::TAB_BAR_HEIGHT;
|
||||
|
||||
const CONTROL_WIDTH: f32 = 34.;
|
||||
|
||||
#[derive(IntoElement, Clone)]
|
||||
enum ControlIcon {
|
||||
Minimize,
|
||||
Restore,
|
||||
Maximize,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl ControlIcon {
|
||||
fn minimize() -> Self {
|
||||
Self::Minimize
|
||||
}
|
||||
|
||||
fn restore() -> Self {
|
||||
Self::Restore
|
||||
}
|
||||
|
||||
fn maximize() -> Self {
|
||||
Self::Maximize
|
||||
}
|
||||
|
||||
fn close() -> Self {
|
||||
Self::Close
|
||||
}
|
||||
|
||||
fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Minimize => "minimize",
|
||||
Self::Restore => "restore",
|
||||
Self::Maximize => "maximize",
|
||||
Self::Close => "close",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(&self) -> IconName {
|
||||
match self {
|
||||
Self::Minimize => IconName::WindowMinimize,
|
||||
Self::Restore => IconName::WindowRestore,
|
||||
Self::Maximize => IconName::WindowMaximize,
|
||||
Self::Close => IconName::WindowClose,
|
||||
}
|
||||
}
|
||||
|
||||
fn window_control_area(&self) -> gpui::WindowControlArea {
|
||||
match self {
|
||||
Self::Minimize => gpui::WindowControlArea::Min,
|
||||
Self::Restore | Self::Maximize => gpui::WindowControlArea::Max,
|
||||
Self::Close => gpui::WindowControlArea::Close,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_close(&self) -> bool {
|
||||
matches!(self, Self::Close)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hover_fg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_foreground
|
||||
} else {
|
||||
cx.theme().secondary_foreground
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hover_bg(&self, cx: &App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger
|
||||
} else {
|
||||
cx.theme().secondary_hover
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn active_bg(&self, cx: &mut App) -> Hsla {
|
||||
if self.is_close() {
|
||||
cx.theme().danger_active
|
||||
} else {
|
||||
cx.theme().secondary_active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ControlIcon {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let is_linux = cfg!(target_os = "linux");
|
||||
let is_windows = cfg!(target_os = "windows");
|
||||
let hover_fg = self.hover_fg(cx);
|
||||
let hover_bg = self.hover_bg(cx);
|
||||
let active_bg = self.active_bg(cx);
|
||||
let icon = self.clone();
|
||||
|
||||
div()
|
||||
.id(self.id())
|
||||
.flex()
|
||||
.w(px(CONTROL_WIDTH))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.content_center()
|
||||
.items_center()
|
||||
.text_color(cx.theme().foreground)
|
||||
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
|
||||
.active(|style| style.bg(active_bg).text_color(hover_fg))
|
||||
.when(is_windows, |this| {
|
||||
this.window_control_area(self.window_control_area())
|
||||
})
|
||||
.when(is_linux, |this| {
|
||||
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
match icon {
|
||||
Self::Minimize => window.minimize_window(),
|
||||
Self::Restore | Self::Maximize => window.zoom_window(),
|
||||
Self::Close => window.remove_window(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.child(Icon::new(self.icon()).small())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
|
||||
return div().id("window-controls");
|
||||
}
|
||||
|
||||
let supported = window.window_controls();
|
||||
|
||||
h_flex()
|
||||
.id("window-controls")
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
// 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)
|
||||
})
|
||||
.border_l_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().tokens.tab_bar)
|
||||
.when(supported.minimize, |this| {
|
||||
this.child(ControlIcon::minimize())
|
||||
})
|
||||
.when(supported.maximize, |this| {
|
||||
this.child(if window.is_maximized() {
|
||||
ControlIcon::restore()
|
||||
} else {
|
||||
ControlIcon::maximize()
|
||||
})
|
||||
})
|
||||
.child(ControlIcon::close())
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use dock::{BasePanel, Panel, SignedDockSkin, panel_handle};
|
||||
use gpui::{
|
||||
App, AppContext, Context, Empty, EventEmitter, FocusHandle, Focusable, IntoElement, Render,
|
||||
TestAppContext, Window,
|
||||
};
|
||||
use gpui_base::dock::{DockArea, DockLayout, DockPlacement, PanelEvent};
|
||||
|
||||
struct Probe {
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BasePanel for Probe {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Probe {
|
||||
fn title(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
"Probe"
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for Probe {}
|
||||
|
||||
impl Focusable for Probe {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Probe {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_first_frame_renders_the_area_and_its_docks(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
gpui_component::init(cx);
|
||||
});
|
||||
let (area, cx) = cx.add_window_view(|window, cx| {
|
||||
let skin = SignedDockSkin::new(cx);
|
||||
DockArea::new("test", None, window, cx).with_renderer(skin)
|
||||
});
|
||||
|
||||
let bottom = cx.update(|_, cx| cx.new(Probe::new));
|
||||
cx.update(|window, cx| {
|
||||
let left = cx.new(Probe::new);
|
||||
let center = cx.new(Probe::new);
|
||||
|
||||
area.update(cx, |area, cx| {
|
||||
area.set_dock(
|
||||
DockPlacement::Left,
|
||||
DockLayout::tabs().panel_view(panel_handle(left), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
area.set_center(
|
||||
DockLayout::tabs().panel_view(panel_handle(center), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
area.set_dock(
|
||||
DockPlacement::Bottom,
|
||||
DockLayout::tabs().panel_view(panel_handle(bottom.clone()), cx),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// 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, its render must also be safe.
|
||||
cx.update(|window, cx| {
|
||||
area.update(cx, |area, cx| {
|
||||
area.remove_panel(bottom, window, cx);
|
||||
});
|
||||
});
|
||||
cx.update(|window, cx| window.draw(cx).clear(cx));
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "metadata"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
signed_state = { path = "../signed_state" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
nostr-sdk.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
log.workspace = true
|
||||
@@ -1,9 +0,0 @@
|
||||
mod profile;
|
||||
|
||||
use gpui::{App, AppContext};
|
||||
pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore};
|
||||
|
||||
/// Initialize the global profile store.
|
||||
pub fn init(cx: &mut App) {
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use flume::Sender;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_state::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
use utils::shorten_pubkey;
|
||||
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const REQUEST_CHUNK: usize = 100;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
metadata: Metadata,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.public_key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> &Metadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
// Falls back to `name`, then a shortened npub.
|
||||
pub fn name(&self) -> SharedString {
|
||||
if let Some(display_name) = self.metadata.display_name.as_ref()
|
||||
&& !display_name.is_empty()
|
||||
{
|
||||
return SharedString::from(display_name.trim().to_owned());
|
||||
}
|
||||
|
||||
if let Some(name) = self.metadata.name.as_ref()
|
||||
&& !name.is_empty()
|
||||
{
|
||||
return SharedString::from(name.trim().to_owned());
|
||||
}
|
||||
|
||||
SharedString::from(shorten_pubkey(self.public_key))
|
||||
}
|
||||
|
||||
pub fn picture(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| SharedString::from(p.clone()))
|
||||
}
|
||||
|
||||
/// The newest profile per author from metadata events.
|
||||
fn latest_profiles(events: impl IntoIterator<Item = Event>) -> Vec<Self> {
|
||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
match latest.get(&event.pubkey) {
|
||||
Some((created_at, _)) if *created_at >= event.created_at => {}
|
||||
_ => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
latest.insert(event.pubkey, (event.created_at, metadata));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest
|
||||
.into_iter()
|
||||
.map(|(public_key, (_, metadata))| Profile::new(public_key, metadata))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for these authors may have changed in the local database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProfileEvent {
|
||||
Updated(Vec<PublicKey>),
|
||||
}
|
||||
|
||||
/// Batches metadata requests into relay syncs feeding the local database.
|
||||
pub struct ProfileStore {
|
||||
in_flight: RefCell<HashSet<PublicKey>>,
|
||||
sender: Sender<PublicKey>,
|
||||
_task: Task<Result<(), anyhow::Error>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
struct GlobalProfileStore(Entity<ProfileStore>);
|
||||
|
||||
impl Global for GlobalProfileStore {}
|
||||
|
||||
impl EventEmitter<ProfileEvent> for ProfileStore {}
|
||||
|
||||
impl ProfileStore {
|
||||
pub(crate) fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalProfileStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalProfileStore(entity));
|
||||
}
|
||||
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||
this.emit_updated(authors.clone(), cx);
|
||||
}
|
||||
});
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||
|
||||
loop {
|
||||
match receiver.recv_async().await {
|
||||
Ok(public_key) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
Err(_) => return Ok(()),
|
||||
}
|
||||
|
||||
// The channel has no async timeout, race the receive against a timer.
|
||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
let timer = cx.background_executor().timer(deadline - now);
|
||||
futures::pin_mut!(timer);
|
||||
|
||||
let recv = receiver.recv_async();
|
||||
futures::pin_mut!(recv);
|
||||
|
||||
match futures::future::select(recv, timer).await {
|
||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||
batch.insert(public_key);
|
||||
}
|
||||
futures::future::Either::Left((Err(_), _)) => return Ok(()),
|
||||
futures::future::Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let authors: Vec<PublicKey> = batch.drain().collect();
|
||||
|
||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
||||
let opts = SyncOptions::default();
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.authors(chunk.iter().copied());
|
||||
|
||||
if let Err(e) = sync_bootstrap_only(&client, filter, opts).await {
|
||||
log::warn!("profile fetch failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit even when the sync failed, the database may hold newer data.
|
||||
this.update(cx, |this, cx| {
|
||||
this.emit_updated(authors, cx);
|
||||
})?;
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
in_flight: RefCell::new(HashSet::new()),
|
||||
sender,
|
||||
_task: task,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a batched relay sync for keys without one in flight.
|
||||
pub fn fetch(&self, keys: impl IntoIterator<Item = PublicKey>) {
|
||||
let mut queue = Vec::new();
|
||||
{
|
||||
let mut in_flight = self.in_flight.borrow_mut();
|
||||
|
||||
for key in keys {
|
||||
if in_flight.insert(key) {
|
||||
queue.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
for public_key in queue {
|
||||
if let Err(e) = self.sender.send(public_key) {
|
||||
log::warn!("failed to queue profile fetch: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Releases the in-flight keys so future fetches can be queued again.
|
||||
fn emit_updated(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
||||
if authors.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut in_flight = self.in_flight.borrow_mut();
|
||||
for author in &authors {
|
||||
in_flight.remove(author);
|
||||
}
|
||||
}
|
||||
|
||||
cx.emit(ProfileEvent::Updated(authors));
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile cache bound to one panel or screen.
|
||||
pub struct ProfileDirectory {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
pending: RefCell<HashSet<PublicKey>>,
|
||||
tasks: Vec<Task<()>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl ProfileDirectory {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let store = ProfileStore::global(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let subscription =
|
||||
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
||||
ProfileEvent::Updated(authors) => this.refresh(authors, cx),
|
||||
});
|
||||
|
||||
Self {
|
||||
profiles: HashMap::new(),
|
||||
pending: RefCell::new(HashSet::new()),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: vec![subscription],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve keys from the local database, queue the rest for a relay sync.
|
||||
pub fn track<K>(&mut self, keys: K, cx: &mut Context<Self>)
|
||||
where
|
||||
K: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
let missing: Vec<PublicKey> = keys
|
||||
.into_iter()
|
||||
.filter(|key| {
|
||||
!self.profiles.contains_key(key)
|
||||
&& !self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.load(missing, true, cx);
|
||||
}
|
||||
|
||||
/// The profile for a key, or a placeholder that queues a relay sync.
|
||||
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
}
|
||||
|
||||
if self.with_pending(|pending| pending.insert(*public_key)) {
|
||||
let store = ProfileStore::global(cx);
|
||||
store.read(cx).fetch([*public_key]);
|
||||
}
|
||||
|
||||
Profile::new(*public_key, Metadata::default())
|
||||
}
|
||||
|
||||
/// Refreshes profiles for keys that are already tracked.
|
||||
fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context<Self>) {
|
||||
let tracked: Vec<PublicKey> = authors
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| {
|
||||
self.profiles.contains_key(key)
|
||||
|| self.with_pending(|pending| pending.contains(key))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tracked.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.load(tracked, false, cx);
|
||||
}
|
||||
|
||||
/// Loads profiles from the local database, queueing a relay sync for any missing keys.
|
||||
fn load(&mut self, keys: Vec<PublicKey>, queue_sync: bool, cx: &mut Context<Self>) {
|
||||
// Accept events that arrive while the database query is running.
|
||||
self.with_pending(|pending| {
|
||||
pending.extend(keys.iter().copied());
|
||||
});
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
let query_keys = keys.clone();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let authors = query_keys.iter().copied();
|
||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
Ok::<_, anyhow::Error>(Profile::latest_profiles(events))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let loaded = work.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let remaining = match loaded {
|
||||
Ok(profiles) => {
|
||||
let found: HashSet<PublicKey> =
|
||||
profiles.iter().map(Profile::public_key).collect();
|
||||
this.insert_loaded(profiles, cx);
|
||||
keys.into_iter()
|
||||
.filter(|key| !found.contains(key))
|
||||
.collect()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to load profiles from the database: {e}");
|
||||
keys
|
||||
}
|
||||
};
|
||||
|
||||
if queue_sync && !remaining.is_empty() {
|
||||
let store = ProfileStore::global(cx);
|
||||
store.read(cx).fetch(remaining);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
// Inserts database results, replacing anything loaded earlier.
|
||||
fn insert_loaded(&mut self, profiles: Vec<Profile>, cx: &mut Context<Self>) {
|
||||
let mut inserted = false;
|
||||
|
||||
for profile in profiles {
|
||||
let public_key = profile.public_key();
|
||||
self.profiles.insert(public_key, profile);
|
||||
self.with_pending(|pending| pending.remove(&public_key));
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
if inserted {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn with_pending<R, T>(&self, then: T) -> R
|
||||
where
|
||||
T: FnOnce(&mut HashSet<PublicKey>) -> R,
|
||||
{
|
||||
let mut pending = self.pending.borrow_mut();
|
||||
then(&mut pending)
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -28,6 +28,58 @@ pub enum EventFetchingStrategy {
|
||||
Uncensored,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab bar chrome.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TabBarSettings {
|
||||
/// Hide the previous and next tab buttons.
|
||||
pub hide_navigation_buttons: bool,
|
||||
}
|
||||
|
||||
impl Default for TabBarSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hide_navigation_buttons: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GraspServersSettings {
|
||||
@@ -50,17 +102,18 @@ impl Default for GraspServersSettings {
|
||||
#[serde(default)]
|
||||
pub struct LocalReposSettings {
|
||||
/// The directories scanned for local git repositories,
|
||||
/// Defaults:
|
||||
///
|
||||
/// - `~/Desktop`
|
||||
/// - `~/Documents`
|
||||
/// 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: vec![paths::desktop_dir(), paths::documents_dir()],
|
||||
scan_paths: default_scan_paths(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +152,8 @@ pub struct CreateRepositorySettings {
|
||||
pub struct Settings {
|
||||
pub appearance: AppearanceMode,
|
||||
pub event_fetching: EventFetchingStrategy,
|
||||
pub theme: ThemeSettings,
|
||||
pub tab_bar: TabBarSettings,
|
||||
pub grasp_servers: GraspServersSettings,
|
||||
pub local_repos: LocalReposSettings,
|
||||
pub checkouts: CheckoutsSettings,
|
||||
@@ -114,6 +169,10 @@ mod tests {
|
||||
let settings = Settings {
|
||||
appearance: AppearanceMode::Dark,
|
||||
event_fetching: EventFetchingStrategy::Curated,
|
||||
theme: ThemeSettings {
|
||||
radius: 8.0,
|
||||
..Default::default()
|
||||
},
|
||||
create_repository: CreateRepositorySettings {
|
||||
default_folder: Some(PathBuf::from("/tmp/repos")),
|
||||
},
|
||||
@@ -127,34 +186,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn partial_json_merges_with_defaults() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{"appearance": "dark", "grasp_servers": {"default_servers": []}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings: Settings =
|
||||
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
|
||||
assert_eq!(settings.appearance, AppearanceMode::Dark);
|
||||
assert_eq!(settings.grasp_servers.default_servers, Vec::<String>::new());
|
||||
assert_eq!(settings.theme.radius, 4.0);
|
||||
// Unset fields fall back to their defaults, including the fetching strategy.
|
||||
assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored);
|
||||
assert_eq!(settings.local_repos, LocalReposSettings::default());
|
||||
// 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 legacy_theme_keys_are_ignored() {
|
||||
let settings: Settings = serde_json::from_str(
|
||||
r#"{"appearance": "light", "theme": {"radius": 8.0}, "tab_bar": {"hide_navigation_buttons": false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings.appearance, AppearanceMode::Light);
|
||||
assert_eq!(
|
||||
settings,
|
||||
Settings {
|
||||
appearance: AppearanceMode::Light,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_fetching_uses_snake_case() {
|
||||
let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap();
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
use gpui::App;
|
||||
use gpui_native_kit::theme::{ThemeVariant, follow_system, set_mode_override};
|
||||
use settings::{AppearanceMode, Settings};
|
||||
|
||||
/// Applies the persisted light/dark/system preference to the kit theme.
|
||||
pub fn apply_appearance(settings: &Settings, cx: &mut App) {
|
||||
apply_appearance_mode(settings.appearance, cx);
|
||||
}
|
||||
|
||||
/// Applies one appearance mode to the kit theme.
|
||||
pub fn apply_appearance_mode(mode: AppearanceMode, cx: &mut App) {
|
||||
match mode {
|
||||
AppearanceMode::System => follow_system(cx),
|
||||
AppearanceMode::Light => set_mode_override(Some(ThemeVariant::Light), cx),
|
||||
AppearanceMode::Dark => set_mode_override(Some(ThemeVariant::Dark), cx),
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
pub mod helpers;
|
||||
mod placeholder;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Render, Styled as _, Subscription, Window, div,
|
||||
};
|
||||
use gpui_native_kit::common::h_flex;
|
||||
use gpui_native_kit::icons::IconRole;
|
||||
use gpui_native_kit::layout::dock::{Dock, DockState};
|
||||
use gpui_native_kit::layout::shell::{LayoutState, Shell, SidebarToggles};
|
||||
use gpui_native_kit::layout::window_controls::{TRAFFIC_LIGHT_PAD, titlebar_strip};
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use gpui_native_kit::toast::Toast;
|
||||
use helpers::apply_appearance;
|
||||
use settings::SettingsStore;
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
|
||||
use crate::app::placeholder::PlaceholderView;
|
||||
use crate::identity;
|
||||
use crate::shared::dialogs::DialogHost;
|
||||
use crate::sidebar::SidebarView;
|
||||
|
||||
pub struct AppRoot {
|
||||
layout: Entity<LayoutState>,
|
||||
dock: Entity<DockState>,
|
||||
sidebar: Entity<SidebarView>,
|
||||
dialogs: Entity<DialogHost>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl AppRoot {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let dock = DockState::new(cx);
|
||||
let layout = LayoutState::new(cx);
|
||||
|
||||
layout.update(cx, |layout, cx| {
|
||||
layout.set_sidebar_toggles(SidebarToggles::Hidden, cx);
|
||||
});
|
||||
|
||||
dock.update(cx, |dock, cx| {
|
||||
dock.register_panel("welcome", |_, cx| {
|
||||
cx.new(|_| PlaceholderView::new("Welcome"))
|
||||
});
|
||||
dock.register_panel("profile", |_, cx| {
|
||||
cx.new(|_| PlaceholderView::new("Profile"))
|
||||
});
|
||||
dock.register_panel("relays", |_, cx| cx.new(|_| PlaceholderView::new("Relays")));
|
||||
dock.open_panel("welcome", window, cx);
|
||||
});
|
||||
|
||||
let sidebar = cx.new(|cx| SidebarView::new(dock.downgrade(), cx));
|
||||
let dialogs = cx.new(DialogHost::new);
|
||||
DialogHost::set_global(dialogs.clone(), cx);
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let settings = SettingsStore::global(cx);
|
||||
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||
BackendEvent::PassphraseRequired => identity::helpers::open_passphrase(cx),
|
||||
BackendEvent::Error(message) => {
|
||||
this.layout.update(cx, |layout, cx| {
|
||||
layout.push_toast(Toast::new(message.clone()).icon(IconRole::Warning), cx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.observe(&settings, |_this, store, cx| {
|
||||
let settings = store.read(cx).settings().clone();
|
||||
apply_appearance(&settings, cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&dock, |this, dock, cx| {
|
||||
let Some(panel) = dock.read(cx).active_panel() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !panel.starts_with("repo:") && !panel.starts_with("repo-local:") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.layout.update(cx, |layout, cx| {
|
||||
if !layout.is_right_sidebar_visible() {
|
||||
layout.set_right_sidebar_visible(true, cx);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
window.defer(cx, move |_window, cx| {
|
||||
if backend.read(cx).passphrase_required() {
|
||||
identity::helpers::open_passphrase(cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
layout,
|
||||
dock,
|
||||
sidebar,
|
||||
dialogs,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AppRoot {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = *cx.theme();
|
||||
let layout = self.layout.read(cx);
|
||||
let left_visible = layout.is_left_sidebar_visible();
|
||||
let sidebar_width = layout.left_sidebar_width();
|
||||
|
||||
let titlebar = if left_visible {
|
||||
self.sidebar.read(cx).render_titlebar(cx)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let titlebar = titlebar.map(|row| {
|
||||
let strip = h_flex()
|
||||
.id("signed-sidebar-titlebar")
|
||||
.size_full()
|
||||
.when(cfg!(target_os = "macos"), |strip| {
|
||||
strip.pl(TRAFFIC_LIGHT_PAD)
|
||||
})
|
||||
.when(cfg!(not(target_os = "macos")), |strip| strip.pl_2())
|
||||
.pr_2();
|
||||
|
||||
titlebar_strip(strip, "signed-sidebar-titlebar-drag", window, cx)
|
||||
.child(row)
|
||||
.into_any_element()
|
||||
});
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.font_family(".SystemUIFont")
|
||||
.text_color(theme.colors.foreground)
|
||||
.child(
|
||||
Shell::new("signed-shell", self.layout.clone())
|
||||
.left_sidebar(self.sidebar.read(cx).build(cx))
|
||||
.right_sidebar(div())
|
||||
.center(
|
||||
Dock::new("signed-dock", self.dock.clone())
|
||||
.window_controls(true)
|
||||
.traffic_light_pad(!left_visible)
|
||||
.sidebar_toggles(self.layout.clone()),
|
||||
),
|
||||
)
|
||||
.when_some(titlebar, |root, titlebar| {
|
||||
root.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.w(sidebar_width)
|
||||
.h(theme.titlebar_height)
|
||||
.child(titlebar),
|
||||
)
|
||||
})
|
||||
.child(self.dialogs.clone())
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use gpui::{
|
||||
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_kit::empty_state::EmptyState;
|
||||
use gpui_native_kit::layout::dock::Panel;
|
||||
|
||||
/// A tab that stands in for a screen that is not ported yet.
|
||||
pub struct PlaceholderView {
|
||||
title: SharedString,
|
||||
}
|
||||
|
||||
impl PlaceholderView {
|
||||
pub fn new(title: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for PlaceholderView {
|
||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||
Some(self.title.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PlaceholderView {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(EmptyState::new(self.title.clone()))
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, Entity, InteractiveElement as _, IntoElement, ParentElement as _, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, WeakEntity, div, px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::layout::dock::DockState;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{RepoListStore, Timestamp};
|
||||
use utils::relative_time;
|
||||
|
||||
use crate::repo::actions::open_repo_panel;
|
||||
|
||||
const RECENT_COUNT: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(super) enum RepoFilter {
|
||||
All,
|
||||
#[default]
|
||||
Popular,
|
||||
Recent,
|
||||
}
|
||||
|
||||
impl RepoFilter {
|
||||
/// Indices into the store's `announcements` this filter includes, in display order.
|
||||
pub(super) fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
|
||||
let announcements = &store.announcements;
|
||||
let mut indices: Vec<usize> = (0..announcements.len()).collect();
|
||||
let query = query.trim().to_lowercase();
|
||||
|
||||
if !query.is_empty() {
|
||||
indices.retain(|&ix| {
|
||||
let announcement = &announcements[ix];
|
||||
let name = announcement.name.as_deref().unwrap_or(&announcement.id);
|
||||
name.to_lowercase().contains(&query)
|
||||
});
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::All => {}
|
||||
Self::Recent => indices.truncate(RECENT_COUNT),
|
||||
Self::Popular => {
|
||||
let counts = &store.counts;
|
||||
let scores: Vec<u32> = announcements
|
||||
.iter()
|
||||
.map(|a| counts.get(&a.addr()).map_or(0, |c| c.score()))
|
||||
.collect();
|
||||
indices.sort_by(|&a, &b| scores[b].cmp(&scores[a]));
|
||||
}
|
||||
}
|
||||
|
||||
indices
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_card(
|
||||
ix: usize,
|
||||
announcement: &Announcement,
|
||||
last_activity: Option<Timestamp>,
|
||||
announcements: &[Announcement],
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
dock: &WeakEntity<DockState>,
|
||||
cx: &mut gpui::App,
|
||||
) -> AnyElement {
|
||||
let owner = directory.read(cx).profile(&announcement.owner, cx);
|
||||
let owner_name = owner.name();
|
||||
let dock = dock.clone();
|
||||
let announcement = announcement.clone();
|
||||
|
||||
let name = announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||
|
||||
let description = announcement
|
||||
.description
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from("No description"));
|
||||
|
||||
let activity = last_activity
|
||||
.map(relative_time)
|
||||
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||
.unwrap_or_default();
|
||||
|
||||
let fork_label: Option<SharedString> = announcement.upstream.as_ref().and_then(|upstream| {
|
||||
let addr = upstream.addr.as_ref()?;
|
||||
let name = announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == *addr)
|
||||
.map(|a| {
|
||||
a.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
|
||||
Some(SharedString::from(format!("forked from {name}")))
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.id(ix)
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.min_w_0()
|
||||
.px_3()
|
||||
.rounded(cx.theme().radius.control)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.hover(|this| this.bg(cx.theme().colors.muted_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Headline, cx)
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.when_some(fork_label, |this, label| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(
|
||||
Icon::named(CustomIconName::GitBranch)
|
||||
.size(px(12.))
|
||||
.color(cx.theme().colors.muted_foreground),
|
||||
)
|
||||
.child(label),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.h_16()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.line_clamp(2)
|
||||
.child(description),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_12()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner_name.clone())
|
||||
.picture(owner.picture())
|
||||
.small(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(owner_name),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.justify_end()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(activity),
|
||||
),
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx);
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
mod helpers;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Render, SharedString, Styled as _, Subscription, UniformListScrollHandle, WeakEntity, Window,
|
||||
div, px, uniform_list,
|
||||
};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::empty_state::EmptyState;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState, InputStateEvent};
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
use gpui_native_kit::scrollbar::Scrollbar;
|
||||
use gpui_native_kit::segmented::{Segmented, SegmentedOption};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use helpers::{RepoFilter, render_card};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_state::RepoListStore;
|
||||
|
||||
const COLUMNS: usize = 2;
|
||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||
|
||||
pub struct ExploreView {
|
||||
store: Entity<RepoListStore>,
|
||||
dock: WeakEntity<DockState>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
filter: RepoFilter,
|
||||
/// Indices matching [`Self::filter`] into the store's `announcements`.
|
||||
visible: Vec<usize>,
|
||||
search: Entity<InputState>,
|
||||
/// Profiles for the card owners.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_search_subscription: Subscription,
|
||||
_store_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl ExploreView {
|
||||
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> Self {
|
||||
let store = RepoListStore::global(cx);
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
let search = InputState::new(cx);
|
||||
|
||||
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
|
||||
if matches!(event, InputStateEvent::TextChanged) {
|
||||
this.rebuild_rows(cx);
|
||||
}
|
||||
});
|
||||
|
||||
let store_subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild_rows(cx);
|
||||
});
|
||||
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.rebuild_rows(cx)) {
|
||||
log::warn!("failed to load explore rows: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
dock,
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
filter: RepoFilter::default(),
|
||||
visible: Vec::new(),
|
||||
search,
|
||||
directory,
|
||||
_search_subscription: search_subscription,
|
||||
_store_subscription: store_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
let query = self.search.read(cx).value().to_string();
|
||||
let store = self.store.read(cx);
|
||||
|
||||
self.visible = filter.visible(store, &query);
|
||||
|
||||
let owners: Vec<PublicKey> = self
|
||||
.visible
|
||||
.iter()
|
||||
.filter_map(|ix| store.announcements.get(*ix))
|
||||
.map(|announcement| announcement.owner)
|
||||
.collect();
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(owners, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_header(&self, count: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let muted = cx.theme().colors.muted_foreground;
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().typeset(TextRole::Caption2, cx).child("Repositories"))
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(muted)
|
||||
.child(SharedString::from(format!("Total: {count}"))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(180.)).child(
|
||||
Input::new("explore-search", self.search.clone())
|
||||
.placeholder("Search...")
|
||||
.prefix(Icon::named(IconRole::Search).small().color(muted)),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Segmented::new("explore-filter", self.filter)
|
||||
.options(vec![
|
||||
SegmentedOption::new(RepoFilter::All, "All")
|
||||
.icon(Icon::named(IconRole::Grid).small().color(muted)),
|
||||
SegmentedOption::new(RepoFilter::Popular, "Popular")
|
||||
.icon(Icon::named(CustomIconName::Trending).small().color(muted)),
|
||||
SegmentedOption::new(RepoFilter::Recent, "Recent")
|
||||
.icon(Icon::named(IconRole::Clock).small().color(muted)),
|
||||
])
|
||||
.on_change(move |filter, _, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
}) {
|
||||
log::warn!("failed to change explore filter: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for ExploreView {
|
||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||
Some(SharedString::from("Explore"))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||
Some(
|
||||
Icon::named(CustomIconName::Globe)
|
||||
.small()
|
||||
.color(cx.theme().colors.muted_foreground)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ExploreView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
let announcements = store.announcements.clone();
|
||||
let last_activity = store.last_activity.clone();
|
||||
let count = self.visible.len();
|
||||
let visible = self.visible.clone();
|
||||
let directory = self.directory.clone();
|
||||
let dock = self.dock.clone();
|
||||
let has_repos = count > 0;
|
||||
let rows = visible.len().div_ceil(COLUMNS);
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(self.render_header(count, cx))
|
||||
.when(!has_repos, |this| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(EmptyState::new("No repositories match your search")),
|
||||
)
|
||||
})
|
||||
.when(has_repos, |this| {
|
||||
this.child(
|
||||
uniform_list("explore-repos", rows, move |range, _, cx| {
|
||||
range
|
||||
.map(|row| {
|
||||
let mut cards = Vec::with_capacity(COLUMNS);
|
||||
|
||||
for col in 0..COLUMNS {
|
||||
let Some(&ix) = visible.get(row * COLUMNS + col) else {
|
||||
break;
|
||||
};
|
||||
let Some(announcement) = announcements.get(ix) else {
|
||||
break;
|
||||
};
|
||||
let activity = last_activity.get(&announcement.addr()).copied();
|
||||
cards.push(render_card(
|
||||
ix,
|
||||
announcement,
|
||||
activity,
|
||||
&announcements,
|
||||
&directory,
|
||||
&dock,
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.id(row)
|
||||
.w_full()
|
||||
.h(px(CARD_HEIGHT))
|
||||
.gap_3()
|
||||
.px_4()
|
||||
.pt_4()
|
||||
.children(cards)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(
|
||||
"explore-scroll",
|
||||
self.scroll_handle.clone(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, IntoElement, ParentElement as _, Render, Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::shared::dialogs::close_global;
|
||||
|
||||
pub struct ExitDialog;
|
||||
|
||||
impl Render for ExitDialog {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
Dialog::new("exit-dialog")
|
||||
.open(true)
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Exit Signed?")
|
||||
.content(|_, cx| {
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(
|
||||
"You will be signed out on this device and will need to sign in again on the next session.",
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(|_, _| {
|
||||
let cancel: AnyElement = Button::new("cancel-exit")
|
||||
.label("Cancel")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.on_click(|_, _, cx| close_global(cx))
|
||||
.into_any_element();
|
||||
|
||||
let exit: AnyElement = Button::new("confirm-exit")
|
||||
.label("Exit")
|
||||
.variant(ButtonVariant::Destructive)
|
||||
.on_click(|_, _, cx| {
|
||||
let backend = Backend::global(cx);
|
||||
backend.update(cx, |backend, cx| backend.sign_out(cx));
|
||||
close_global(cx);
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![cancel, exit]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use gpui::{App, AppContext as _};
|
||||
|
||||
use super::exit::ExitDialog;
|
||||
use super::import::ImportDialog;
|
||||
use super::onboarding::OnboardingDialog;
|
||||
use super::passphrase::PassphraseDialog;
|
||||
use crate::shared::dialogs::open_global;
|
||||
|
||||
pub fn open_passphrase(cx: &mut App) {
|
||||
open_global(PassphraseDialog::new(cx), cx);
|
||||
}
|
||||
|
||||
pub fn open_onboarding(cx: &mut App) {
|
||||
open_global(OnboardingDialog::new(cx), cx);
|
||||
}
|
||||
|
||||
pub fn open_import(cx: &mut App) {
|
||||
open_global(ImportDialog::new(cx), cx);
|
||||
}
|
||||
|
||||
pub fn open_exit(cx: &mut App) {
|
||||
open_global(cx.new(|_| ExitDialog), cx);
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::input::{Input, InputState, InputStateEvent};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::shared::dialogs::close_global;
|
||||
use crate::shared::progress::{DialogProgress, error_row};
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Credential,
|
||||
/// An `nsec` was entered; ask for a passphrase to encrypt it with.
|
||||
Encrypt,
|
||||
/// An `ncryptsec` was entered; ask for its passphrase to decrypt it.
|
||||
Decrypt,
|
||||
}
|
||||
|
||||
/// The credential entered on the first step.
|
||||
enum Credential {
|
||||
Nsec(String),
|
||||
Ncryptsec(String),
|
||||
Bunker(String),
|
||||
}
|
||||
|
||||
impl Credential {
|
||||
fn parse(input: &str) -> Option<Self> {
|
||||
let input = input.trim();
|
||||
|
||||
if input.starts_with("nsec1") {
|
||||
Some(Self::Nsec(input.to_owned()))
|
||||
} else if input.starts_with("ncryptsec1") {
|
||||
Some(Self::Ncryptsec(input.to_owned()))
|
||||
} else if input.starts_with("bunker://") {
|
||||
Some(Self::Bunker(input.to_owned()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State of the import dialog, so the current step and async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct ImportState {
|
||||
stage: Stage,
|
||||
credential: String,
|
||||
progress: DialogProgress,
|
||||
}
|
||||
|
||||
pub struct ImportDialog {
|
||||
secret_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
repass_input: Entity<InputState>,
|
||||
state: Entity<ImportState>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl ImportDialog {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let state = cx.new(|_| ImportState::default());
|
||||
let secret_input = InputState::new(cx);
|
||||
let pass_input = InputState::builder().password().build(cx);
|
||||
let repass_input = InputState::builder().password().build(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
for input in [&secret_input, &pass_input, &repass_input] {
|
||||
let secret_input = secret_input.clone();
|
||||
let pass_input = pass_input.clone();
|
||||
let repass_input = repass_input.clone();
|
||||
let state = state.clone();
|
||||
|
||||
subscriptions.push(cx.subscribe(input, move |_this, _input, event, cx| {
|
||||
if matches!(event, InputStateEvent::Submitted) {
|
||||
submit(&secret_input, &pass_input, &repass_input, &state, cx);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Self {
|
||||
secret_input,
|
||||
pass_input,
|
||||
repass_input,
|
||||
state,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImportDialog {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let state = self.state.clone();
|
||||
|
||||
let secret_input = self.secret_input.clone();
|
||||
let pass_input = self.pass_input.clone();
|
||||
let repass_input = self.repass_input.clone();
|
||||
|
||||
let action_state = state.clone();
|
||||
let action_secret = secret_input.clone();
|
||||
|
||||
let action_pass = pass_input.clone();
|
||||
let action_repass = repass_input.clone();
|
||||
|
||||
Dialog::new("import-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Import identity")
|
||||
.content(move |_window, cx| {
|
||||
let state = state.read(cx);
|
||||
let stage = state.stage;
|
||||
|
||||
let description = match stage {
|
||||
Stage::Credential => {
|
||||
"Paste the secret key or bunker URI of an existing identity."
|
||||
}
|
||||
Stage::Encrypt => "Choose a passphrase to encrypt your key on this device.",
|
||||
Stage::Decrypt => "Enter the passphrase used to encrypt this key.",
|
||||
};
|
||||
|
||||
let form = match stage {
|
||||
Stage::Credential => v_flex().gap_3().child(
|
||||
Field::new("secret-field", "Secret key or bunker URI")
|
||||
.child(Input::new("secret-input", secret_input.clone())),
|
||||
),
|
||||
Stage::Encrypt => v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
Field::new("passphrase-field", "Passphrase")
|
||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("repeat-field", "Repeat passphrase")
|
||||
.child(Input::new("repeat-input", repass_input.clone())),
|
||||
),
|
||||
Stage::Decrypt => v_flex().gap_3().child(
|
||||
Field::new("passphrase-field", "Passphrase")
|
||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||
),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(description),
|
||||
)
|
||||
.child(form)
|
||||
.when_some(error_row(&state.progress.error, cx), |el, error| {
|
||||
el.child(error)
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, cx| {
|
||||
let state = action_state.read(cx);
|
||||
let busy = state.progress.busy;
|
||||
|
||||
let label = match state.stage {
|
||||
Stage::Credential => "Continue",
|
||||
Stage::Encrypt => "Import identity",
|
||||
Stage::Decrypt => "Unlock",
|
||||
};
|
||||
|
||||
let secret = action_secret.clone();
|
||||
let pass = action_pass.clone();
|
||||
let repass = action_repass.clone();
|
||||
let state = action_state.clone();
|
||||
|
||||
let import: AnyElement = Button::new("import-identity")
|
||||
.label(label)
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click(move |_, _, cx| {
|
||||
submit(&secret, &pass, &repass, &state, cx);
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![import]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(
|
||||
secret_input: &Entity<InputState>,
|
||||
pass_input: &Entity<InputState>,
|
||||
repass_input: &Entity<InputState>,
|
||||
state: &Entity<ImportState>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if state.read(cx).progress.busy {
|
||||
return;
|
||||
}
|
||||
|
||||
match state.read(cx).stage {
|
||||
Stage::Credential => start_import(secret_input, state, cx),
|
||||
Stage::Encrypt => import_nsec(pass_input, repass_input, state, cx),
|
||||
Stage::Decrypt => import_ncryptsec(pass_input, state, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the entered credential and either moves to the passphrase step or connects to a bunker.
|
||||
fn start_import(secret_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
|
||||
let credential = match Credential::parse(secret_input.read(cx).value()) {
|
||||
Some(credential) => credential,
|
||||
None => {
|
||||
state.update(cx, |state, cx| {
|
||||
state
|
||||
.progress
|
||||
.fail("Enter an nsec, ncryptsec or bunker:// URI");
|
||||
cx.notify();
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match credential {
|
||||
Credential::Nsec(secret) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.credential = secret;
|
||||
state.stage = Stage::Encrypt;
|
||||
state.progress.error = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Credential::Ncryptsec(secret) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.credential = secret;
|
||||
state.stage = Stage::Decrypt;
|
||||
state.progress.error = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Credential::Bunker(uri) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.progress.begin();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx));
|
||||
finish_import(task, state, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts the entered `nsec` with the passphrase and signs in with it.
|
||||
fn import_nsec(
|
||||
pass_input: &Entity<InputState>,
|
||||
repass_input: &Entity<InputState>,
|
||||
state: &Entity<ImportState>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
let nsec = state.read(cx).credential.clone();
|
||||
let pass = pass_input.read(cx).value().to_string();
|
||||
let repass = repass_input.read(cx).value().to_string();
|
||||
|
||||
if pass.is_empty() {
|
||||
state.update(cx, |state, cx| {
|
||||
state.progress.fail("Passphrase must not be empty");
|
||||
cx.notify();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if pass != repass {
|
||||
state.update(cx, |state, cx| {
|
||||
state.progress.fail("Passphrases do not match");
|
||||
cx.notify();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, cx| {
|
||||
state.progress.begin();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx));
|
||||
finish_import(task, state, cx);
|
||||
}
|
||||
|
||||
/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it.
|
||||
fn import_ncryptsec(pass_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
|
||||
let backend = Backend::global(cx);
|
||||
let ncryptsec = state.read(cx).credential.clone();
|
||||
let pass = pass_input.read(cx).value().to_string();
|
||||
|
||||
if pass.is_empty() {
|
||||
state.update(cx, |state, cx| {
|
||||
state.progress.fail("Passphrase must not be empty");
|
||||
cx.notify();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, cx| {
|
||||
state.progress.begin();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.import_ncryptsec(&ncryptsec, &pass, cx)
|
||||
});
|
||||
finish_import(task, state, cx);
|
||||
}
|
||||
|
||||
/// Runs the import task to completion, closing the dialog on success.
|
||||
fn finish_import<T: 'static>(
|
||||
task: Task<Result<T, anyhow::Error>>,
|
||||
state: &Entity<ImportState>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let state = state.clone();
|
||||
|
||||
cx.spawn(async move |cx| match task.await {
|
||||
Ok(_) => cx.update(close_global),
|
||||
Err(error) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.progress.fail(error.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod exit;
|
||||
pub mod helpers;
|
||||
pub mod import;
|
||||
pub mod onboarding;
|
||||
pub mod passphrase;
|
||||
@@ -1,140 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::shared::dialogs::close_global;
|
||||
use crate::shared::progress::{DialogProgress, error_row};
|
||||
|
||||
pub struct OnboardingDialog {
|
||||
name_input: Entity<InputState>,
|
||||
pass_input: Entity<InputState>,
|
||||
repass_input: Entity<InputState>,
|
||||
state: Entity<DialogProgress>,
|
||||
}
|
||||
|
||||
impl OnboardingDialog {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let state = cx.new(|_| DialogProgress::default());
|
||||
let name_input = InputState::new(cx);
|
||||
let pass_input = InputState::builder().password().build(cx);
|
||||
let repass_input = InputState::builder().password().build(cx);
|
||||
|
||||
cx.new(|_| Self {
|
||||
name_input,
|
||||
pass_input,
|
||||
repass_input,
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_identity(&mut self, cx: &mut Context<Self>) {
|
||||
if self.state.read(cx).busy {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let pass = self.pass_input.read(cx).value().to_string();
|
||||
let repass = self.repass_input.read(cx).value().to_string();
|
||||
|
||||
if pass != repass {
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.fail("Passphrases do not match");
|
||||
cx.notify();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.begin();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| backend.create_identity(&name, &pass, cx));
|
||||
let state = self.state.clone();
|
||||
|
||||
cx.spawn(async move |_this, cx| match task.await {
|
||||
Ok(_) => cx.update(close_global),
|
||||
Err(error) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.fail(error.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for OnboardingDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let name_input = self.name_input.clone();
|
||||
let pass_input = self.pass_input.clone();
|
||||
let repass_input = self.repass_input.clone();
|
||||
let state = self.state.clone();
|
||||
let action_state = state.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
Dialog::new("onboarding-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Create identity")
|
||||
.content(move |_window, cx| {
|
||||
let progress = state.read(cx);
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Set up your Signed identity to get started."),
|
||||
)
|
||||
.child(
|
||||
Field::new("name-field", "Name")
|
||||
.description("Max 255 characters")
|
||||
.child(Input::new("name-input", name_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("passphrase-field", "Passphrase")
|
||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("repeat-field", "Repeat passphrase")
|
||||
.child(Input::new("repeat-input", repass_input.clone())),
|
||||
)
|
||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, cx| {
|
||||
let busy = action_state.read(cx).busy;
|
||||
let this = this.clone();
|
||||
|
||||
let create: AnyElement = Button::new("create-identity")
|
||||
.label("Create new identity")
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click(move |_, _, cx| {
|
||||
crate::shared::log_dropped(
|
||||
this.update(cx, |this, cx| this.create_identity(cx)),
|
||||
);
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![create]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Subscription, Window, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState, InputStateEvent};
|
||||
use signed_state::Backend;
|
||||
|
||||
use crate::shared::dialogs::close_global;
|
||||
use crate::shared::progress::{DialogProgress, error_row, push_steps_row};
|
||||
|
||||
pub struct PassphraseDialog {
|
||||
pass_input: Entity<InputState>,
|
||||
state: Entity<DialogProgress>,
|
||||
_enter_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PassphraseDialog {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let state = cx.new(|_| DialogProgress::default());
|
||||
let pass_input = InputState::builder().password().build(cx);
|
||||
|
||||
cx.new(|cx| {
|
||||
let enter_subscription =
|
||||
cx.subscribe(&pass_input, |this: &mut Self, _input, event, cx| {
|
||||
if matches!(event, InputStateEvent::Submitted) {
|
||||
this.unlock(cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
pass_input,
|
||||
state,
|
||||
_enter_subscription: enter_subscription,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn unlock(&mut self, cx: &mut Context<Self>) {
|
||||
if self.state.read(cx).busy {
|
||||
return;
|
||||
}
|
||||
|
||||
let pass = self.pass_input.read(cx).value().to_string();
|
||||
|
||||
if pass.is_empty() {
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.fail("Passphrase must not be empty");
|
||||
cx.notify();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.begin();
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
||||
let state = self.state.clone();
|
||||
|
||||
cx.spawn(async move |_this, cx| match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(close_global);
|
||||
}
|
||||
Err(error) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.fail(error.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PassphraseDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let pass_input = self.pass_input.clone();
|
||||
let state = self.state.clone();
|
||||
let action_state = state.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
Dialog::new("passphrase-dialog")
|
||||
.open(true)
|
||||
.on_change(|_open, _, _| {})
|
||||
.min_width(px(420.))
|
||||
.title("Unlock your identity")
|
||||
.content(move |_window, cx| {
|
||||
let progress = state.read(cx);
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
Field::new(
|
||||
"passphrase-field",
|
||||
"Enter the passphrase used to encrypt this identity.",
|
||||
)
|
||||
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||
)
|
||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
||||
el.child(steps)
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, cx| {
|
||||
let busy = action_state.read(cx).busy;
|
||||
let this = this.clone();
|
||||
|
||||
let unlock: AnyElement = Button::new("unlock")
|
||||
.label("Unlock")
|
||||
.icon(Icon::named(IconRole::Unlock))
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click(move |_, _, cx| {
|
||||
crate::shared::log_dropped(this.update(cx, |this, cx| this.unlock(cx)));
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![unlock]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Div, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
SharedString, Stateful, Styled as _, div, px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::badge::Badge;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, Kind, PublicKey};
|
||||
use utils::relative_time;
|
||||
|
||||
pub fn repo_header(id: SharedString, name: SharedString, unread: usize, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.when(unread > 0, |this| {
|
||||
this.child(Badge::new(id.clone(), unread.to_string()))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub fn empty_section_row(cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.h_12()
|
||||
.w_full()
|
||||
.px_3()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.bg(cx.theme().colors.secondary)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(SharedString::from("No activity yet."))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn thread(
|
||||
prefix: &'static str,
|
||||
ix: usize,
|
||||
title: SharedString,
|
||||
unread: bool,
|
||||
first: bool,
|
||||
last: bool,
|
||||
events: Vec<Event>,
|
||||
me: Option<PublicKey>,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let mut timeline = v_flex().gap_2().w_full();
|
||||
for event in events {
|
||||
timeline = timeline.child(sub_activity(&event, me, directory, cx));
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.id((prefix, ix))
|
||||
.w_full()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors.secondary)
|
||||
.when(first, |this| this.rounded_t(cx.theme().radius.control))
|
||||
.when(last, |this| this.rounded_b(cx.theme().radius.control))
|
||||
.when(!last, |this| {
|
||||
this.border_b_1().border_color(cx.theme().colors.background)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().colors.muted_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child(
|
||||
h_flex()
|
||||
.size_6()
|
||||
.flex_shrink_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
Icon::named(CustomIconName::Inbox)
|
||||
.small()
|
||||
.color(cx.theme().colors.muted_foreground),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(title),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.when(unread, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size_2()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().colors.primary),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(timeline)
|
||||
}
|
||||
|
||||
fn sub_activity(
|
||||
event: &Event,
|
||||
me: Option<PublicKey>,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let profile = directory.read(cx).profile(&event.pubkey, cx);
|
||||
let name = if Some(event.pubkey) == me {
|
||||
SharedString::from("You")
|
||||
} else {
|
||||
profile.name()
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(div().w_6().flex_shrink_0())
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.child(
|
||||
Avatar::new(event.pubkey)
|
||||
.name(name.clone())
|
||||
.picture(profile.picture())
|
||||
.small(),
|
||||
)
|
||||
.child(name)
|
||||
.child(SharedString::from(activity_phrase(event.kind)))
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(SharedString::from(relative_time(event.created_at))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn activity_phrase(kind: Kind) -> &'static str {
|
||||
match kind {
|
||||
Kind::GitIssue => "opened an issue",
|
||||
Kind::GitPullRequest => "opened a PR",
|
||||
Kind::GitPullRequestUpdate => "updated a PR",
|
||||
Kind::GitPatch => "created a patch",
|
||||
Kind::Comment => "commented",
|
||||
Kind::GitStatusOpen => "opened a status",
|
||||
Kind::GitStatusApplied => "applied a status",
|
||||
Kind::GitStatusClosed => "closed a status",
|
||||
Kind::GitStatusDraft => "drafted a status",
|
||||
_ => "did something",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_state(message: &'static str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.py_8()
|
||||
.child(
|
||||
Icon::named(CustomIconName::Inbox)
|
||||
.size(px(24.))
|
||||
.color(cx.theme().colors.muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(SharedString::from(message)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render,
|
||||
ScrollHandle, SharedString, Styled as _, Subscription, Window, div,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::empty_state::EmptyState;
|
||||
use gpui_native_kit::input::InputState;
|
||||
use gpui_native_kit::layout::dock::Panel;
|
||||
use gpui_native_kit::scroll_area::ScrollArea;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, PublicKey};
|
||||
use signed_core::GitEvent;
|
||||
use signed_state::RepoStore;
|
||||
use utils::relative_time;
|
||||
|
||||
use crate::issues::helpers::{comment_form, comments_section, issue_roots, sidebar_section};
|
||||
use crate::shared::widgets::{repo_tab_avatar, status_badge};
|
||||
|
||||
pub struct IssueDetailView {
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
comment_input: Entity<InputState>,
|
||||
/// Profiles for the author, commenters and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
scroll_handle: ScrollHandle,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssueDetailView {
|
||||
pub fn new(store: Entity<RepoStore>, issue_id: EventId, cx: &mut Context<Self>) -> Self {
|
||||
let comment_input = InputState::builder().multiline().build(cx);
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.track_participants(cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| this.track_participants(cx)));
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
directory,
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
/// Track the issue author, everyone who commented and the repository owner.
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == self.issue_id)
|
||||
.map(|issue| issue.pubkey)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
keys.extend(
|
||||
store
|
||||
.comments_of(&self.issue_id)
|
||||
.map(|comment| comment.pubkey),
|
||||
);
|
||||
|
||||
if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) {
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for IssueDetailView {
|
||||
fn title(&self, cx: &gpui::App) -> Option<SharedString> {
|
||||
let short_id = self
|
||||
.store
|
||||
.read(cx)
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == self.issue_id)
|
||||
.map(|issue| {
|
||||
let hex = issue.id.to_hex();
|
||||
SharedString::from(&hex[..8])
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from("Issue"));
|
||||
|
||||
Some(short_id)
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &gpui::App) -> Option<AnyElement> {
|
||||
Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for IssueDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.store.read(cx);
|
||||
|
||||
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
|
||||
let message = if store.loaded {
|
||||
"Issue not found"
|
||||
} else {
|
||||
"Loading issue..."
|
||||
};
|
||||
return v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(EmptyState::new(message))
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let (title, author, status, age, issue_id, content, pubkey) = {
|
||||
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
|
||||
let content = if issue.content.is_empty() {
|
||||
SharedString::from("No description provided.")
|
||||
} else {
|
||||
SharedString::from(&issue.content)
|
||||
};
|
||||
|
||||
(
|
||||
issue.activity_subject(),
|
||||
profile.name(),
|
||||
store.status_of(issue),
|
||||
relative_time(issue.created_at),
|
||||
issue.id,
|
||||
content,
|
||||
issue.pubkey,
|
||||
)
|
||||
};
|
||||
let picture = self.directory.read(cx).profile(&pubkey, cx).picture();
|
||||
|
||||
h_flex()
|
||||
.id("issue-detail")
|
||||
.size_full()
|
||||
.child(
|
||||
ScrollArea::new("issue-detail-scroll")
|
||||
.handle(self.scroll_handle.clone())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_6()
|
||||
.child(
|
||||
h_flex().gap_2().child(status_badge(status)).child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Headline, cx)
|
||||
.child(title),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_8()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture)
|
||||
.small(),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme().colors.muted_foreground,
|
||||
)
|
||||
.child("opened"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme().colors.muted_foreground,
|
||||
)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().typeset(TextRole::Body, cx).child(content),
|
||||
),
|
||||
)
|
||||
.child(comments_section(
|
||||
&self.store,
|
||||
issue_id,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
&self.comment_input,
|
||||
"comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
issue_id,
|
||||
issue_roots,
|
||||
true,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, div, px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_core::RepoStatus;
|
||||
use signed_state::RepoStore;
|
||||
use utils::relative_time;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum IssueFilter {
|
||||
All,
|
||||
Open,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl IssueFilter {
|
||||
pub(crate) fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Open => status == RepoStatus::Open,
|
||||
Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Root events counted by their resolved status.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StatusCounts {
|
||||
pub(crate) total: usize,
|
||||
pub(crate) open: usize,
|
||||
pub(crate) closed: usize,
|
||||
pub(crate) draft: usize,
|
||||
pub(crate) applied: usize,
|
||||
}
|
||||
|
||||
impl StatusCounts {
|
||||
fn record(&mut self, status: RepoStatus) {
|
||||
self.total += 1;
|
||||
match status {
|
||||
RepoStatus::Open => self.open += 1,
|
||||
RepoStatus::Closed => self.closed += 1,
|
||||
RepoStatus::Draft => self.draft += 1,
|
||||
RepoStatus::Applied => self.applied += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices of `roots` whose status `keep` accepts, counting every root's status.
|
||||
pub(crate) fn filter_by_status<'a>(
|
||||
roots: impl IntoIterator<Item = &'a Event>,
|
||||
status_of: impl Fn(&Event) -> RepoStatus,
|
||||
keep: impl Fn(RepoStatus) -> bool,
|
||||
) -> (Vec<usize>, StatusCounts) {
|
||||
let mut counts = StatusCounts::default();
|
||||
|
||||
let visible = roots
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, root)| {
|
||||
let status = status_of(root);
|
||||
counts.record(status);
|
||||
keep(status).then_some(index)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(visible, counts)
|
||||
}
|
||||
|
||||
pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.issues
|
||||
}
|
||||
|
||||
pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] {
|
||||
&store.pull_requests
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar_section(
|
||||
store: &Entity<RepoStore>,
|
||||
id: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
top_gap: bool,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let mut participants: Vec<PublicKey> = vec![root.pubkey];
|
||||
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
|
||||
participants.sort_by_key(PublicKey::to_hex);
|
||||
participants.dedup();
|
||||
|
||||
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
|
||||
|
||||
v_flex()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.gap_4()
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.child(
|
||||
v_flex()
|
||||
.when(top_gap, |this| this.mt_4())
|
||||
.gap_2()
|
||||
.child(crate::shared::widgets::section_label("Participants", cx))
|
||||
.children(participants.iter().map(|pubkey| {
|
||||
let profile = directory.read(cx).profile(pubkey, cx);
|
||||
let name = profile.name();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(*pubkey)
|
||||
.name(name.clone())
|
||||
.picture(profile.picture())
|
||||
.small(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(crate::shared::widgets::section_label("Labels", cx))
|
||||
.map(|this| {
|
||||
if labels.is_empty() {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("None yet."),
|
||||
)
|
||||
} else {
|
||||
this.child(v_flex().gap_1().children(labels.iter().map(|label| {
|
||||
Badge::new(SharedString::from(format!("label:{label}")), label.clone())
|
||||
.variant(BadgeVariant::Outline)
|
||||
})))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn comments_section(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let store = store.read(cx);
|
||||
let comments: Vec<&Event> = store.comments_of(&root).collect();
|
||||
let title = SharedString::from(format!("Discussions {}", comments.len()));
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(div().typeset(TextRole::Caption2, cx).child(title))
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = directory.read(cx).profile(&comment.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let age = relative_time(comment.created_at);
|
||||
let content = SharedString::from(comment.content.as_str());
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(comment.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(profile.picture())
|
||||
.small(),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("commented"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(div().typeset(TextRole::Body, cx).child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
comment_input: &Entity<InputState>,
|
||||
button_id: &'static str,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let comment_input = comment_input.clone();
|
||||
let store = store.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Input::new("comment-input", comment_input.clone())
|
||||
.rows(4)
|
||||
.placeholder("Leave a comment..."),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(
|
||||
Icon::named(CustomIconName::Markdown)
|
||||
.size(px(12.))
|
||||
.color(cx.theme().colors.muted_foreground),
|
||||
)
|
||||
.child("Markdown is supported"),
|
||||
)
|
||||
.child(
|
||||
Button::new(button_id)
|
||||
.label("Comment")
|
||||
.tooltip(Tooltip::text("Post comment"))
|
||||
.on_click(move |_, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = roots(store.read(cx))
|
||||
.iter()
|
||||
.find(|event| event.id == root)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
store.update(cx, |store, cx| {
|
||||
store.comment(&root, content, cx);
|
||||
});
|
||||
comment_input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||
Subscription, UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::empty_state::EmptyState;
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
use gpui_native_kit::scrollbar::Scrollbar;
|
||||
use gpui_native_kit::segmented::{Segmented, SegmentedOption};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::GitEvent;
|
||||
use signed_state::RepoStore;
|
||||
use utils::relative_time;
|
||||
|
||||
use self::detail::IssueDetailView;
|
||||
use helpers::{IssueFilter, StatusCounts, filter_by_status};
|
||||
|
||||
use crate::shared::panels;
|
||||
use crate::shared::widgets::{repo_tab_avatar, status_badge};
|
||||
|
||||
pub mod detail;
|
||||
pub(crate) mod helpers;
|
||||
pub mod new_issue;
|
||||
|
||||
const ISSUE_ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
pub struct IssuesView {
|
||||
dock: WeakEntity<DockState>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
filter: IssueFilter,
|
||||
visible_issues: Vec<usize>,
|
||||
counts: StatusCounts,
|
||||
// A filter change notifies even when the visible rows are unchanged.
|
||||
synced_filter: IssueFilter,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
/// Profiles for issue authors and the repository owner.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl IssuesView {
|
||||
pub fn new(
|
||||
dock: WeakEntity<DockState>,
|
||||
store: Entity<RepoStore>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
let weak = cx.entity().downgrade();
|
||||
cx.defer(move |cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| this.rebuild(cx)));
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
store,
|
||||
repo_name,
|
||||
filter: IssueFilter::Open,
|
||||
visible_issues: Vec::new(),
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: IssueFilter::Open,
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_issues, counts) = {
|
||||
let store = self.store.read(cx);
|
||||
filter_by_status(
|
||||
&store.issues,
|
||||
|issue| store.status_of(issue),
|
||||
|status| filter.matches(status),
|
||||
)
|
||||
};
|
||||
|
||||
let store = self.store.read(cx);
|
||||
let mut keys: Vec<PublicKey> = store.issues.iter().map(|issue| issue.pubkey).collect();
|
||||
|
||||
if let Some(owner) = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| announcement.owner)
|
||||
{
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
let visible_issues_changed = self.visible_issues != visible_issues;
|
||||
let counts_changed = self.counts != counts;
|
||||
|
||||
if !filter_changed && !visible_issues_changed && !counts_changed {
|
||||
return;
|
||||
}
|
||||
|
||||
self.synced_filter = filter;
|
||||
self.visible_issues = visible_issues;
|
||||
self.counts = counts;
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let counts = self.counts;
|
||||
let muted = cx.theme().colors.muted_foreground;
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.bg(cx.theme().colors.muted)
|
||||
.child(
|
||||
Segmented::new("issue-filter", self.filter)
|
||||
.options(vec![
|
||||
SegmentedOption::new(IssueFilter::All, format!("All ({})", counts.total))
|
||||
.icon(Icon::named(CustomIconName::GitIssueDone).color(muted)),
|
||||
SegmentedOption::new(IssueFilter::Open, format!("Open ({})", counts.open))
|
||||
.icon(Icon::named(CustomIconName::GitIssueOpen).color(muted)),
|
||||
SegmentedOption::new(
|
||||
IssueFilter::Closed,
|
||||
format!("Closed ({})", counts.closed),
|
||||
)
|
||||
.icon(Icon::named(CustomIconName::GitIssueClosed).color(muted)),
|
||||
])
|
||||
.on_change(move |filter, _, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild(cx);
|
||||
}) {
|
||||
log::warn!("failed to switch issue filter: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("new-issue")
|
||||
.label("New issue")
|
||||
.icon(Icon::named(CustomIconName::CirclePlus))
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
new_issue::open(this.store.clone(), cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for IssuesView {
|
||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||
Some(SharedString::from(format!("{}/issues", self.repo_name)))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||
Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for IssuesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
let count = self.visible_issues.len();
|
||||
let visible_issues = self.visible_issues.clone();
|
||||
let directory = self.directory.clone();
|
||||
let store = self.store.clone();
|
||||
let dock = self.dock.clone();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(self.render_header(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.when(count > 0, |this| {
|
||||
this.child(
|
||||
uniform_list("issues", count, move |range, _, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
let issue_ix = visible_issues[ix];
|
||||
let issue = &store.read(cx).issues[issue_ix];
|
||||
let title = issue.activity_subject();
|
||||
let id_hex = issue.id.to_hex();
|
||||
let profile = directory.read(cx).profile(&issue.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let age = relative_time(issue.created_at);
|
||||
let status = store.read(cx).status_of(issue);
|
||||
let issue_id = issue.id;
|
||||
let dock = dock.clone();
|
||||
let store = store.clone();
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.h(px(ISSUE_ROW_HEIGHT))
|
||||
.gap_4()
|
||||
.px_4()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.hover(|this| this.bg(cx.theme().colors.muted_hover))
|
||||
.on_click(move |_, window, cx| {
|
||||
let id =
|
||||
SharedString::from(format!("issue:{issue_id}"));
|
||||
let store = store.clone();
|
||||
panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |_, cx| {
|
||||
cx.new(|cx| {
|
||||
IssueDetailView::new(
|
||||
store.clone(),
|
||||
issue_id,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.child(status_badge(status))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(title),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(issue.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(
|
||||
profile.picture(),
|
||||
)
|
||||
.small(),
|
||||
)
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme()
|
||||
.colors
|
||||
.muted_foreground,
|
||||
)
|
||||
.child(SharedString::from(
|
||||
&id_hex[..8],
|
||||
)),
|
||||
)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(
|
||||
"issues-scroll",
|
||||
self.scroll_handle.clone(),
|
||||
)),
|
||||
)
|
||||
})
|
||||
.when(count == 0, |this| {
|
||||
let message = match filter {
|
||||
IssueFilter::All => "No issues",
|
||||
IssueFilter::Open => "No open issues",
|
||||
IssueFilter::Closed => "No closed issues",
|
||||
};
|
||||
this.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(EmptyState::new(message)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::shared::dialogs::{close_global, open_global};
|
||||
|
||||
pub struct NewIssueDialog {
|
||||
store: Entity<RepoStore>,
|
||||
subject: Entity<InputState>,
|
||||
content: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl NewIssueDialog {
|
||||
pub fn new(store: Entity<RepoStore>, cx: &mut App) -> Entity<Self> {
|
||||
let subject = InputState::new(cx);
|
||||
let content = InputState::builder().multiline().build(cx);
|
||||
|
||||
cx.new(|_| Self {
|
||||
store,
|
||||
subject,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
fn submit(&mut self, cx: &mut Context<Self>) {
|
||||
let subject = self.subject.read(cx).value().to_string();
|
||||
let content = self.content.read(cx).value().to_string();
|
||||
let subject = (!subject.trim().is_empty()).then_some(subject);
|
||||
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.open_issue(subject, content, cx);
|
||||
});
|
||||
|
||||
close_global(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NewIssueDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let subject = self.subject.clone();
|
||||
let content = self.content.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
Dialog::new("new-issue-dialog")
|
||||
.open(true)
|
||||
.min_width(gpui::px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("New issue")
|
||||
.content(move |_, cx| {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Report a bug, ask a question, or propose a change."),
|
||||
)
|
||||
.child(
|
||||
Field::new("issue-title", "Title")
|
||||
.child(Input::new("issue-title-input", subject.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("issue-content", "Content")
|
||||
.child(Input::new("issue-content-input", content.clone()).rows(6)),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, _| {
|
||||
let this = this.clone();
|
||||
vec![
|
||||
Button::new("create-issue")
|
||||
.label("Create issue")
|
||||
.on_click(move |_, _, cx| {
|
||||
crate::shared::log_dropped(this.update(cx, |this, cx| this.submit(cx)));
|
||||
})
|
||||
.into_any_element() as AnyElement,
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the new-issue form for `store`.
|
||||
pub fn open(store: Entity<RepoStore>, cx: &mut App) {
|
||||
open_global(NewIssueDialog::new(store, cx), cx);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
mod app;
|
||||
mod explore;
|
||||
mod identity;
|
||||
mod inbox;
|
||||
mod issues;
|
||||
mod pull_requests;
|
||||
mod repo;
|
||||
mod settings;
|
||||
mod shared;
|
||||
mod sidebar;
|
||||
|
||||
pub use app::AppRoot;
|
||||
pub use app::helpers::{apply_appearance, apply_appearance_mode};
|
||||
|
||||
use gpui::{App, AppContext as _, Entity, Window};
|
||||
|
||||
pub fn root(window: &mut Window, cx: &mut App) -> Entity<AppRoot> {
|
||||
cx.new(|cx| AppRoot::new(window, cx))
|
||||
}
|
||||
@@ -1,627 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, Styled as _, Subscription, UniformListScrollHandle,
|
||||
WeakEntity, Window, div, uniform_list,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::clipboard::ClipboardButton;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::data::tabs::{Tab, Tabs};
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::input::InputState;
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
use gpui_native_kit::scroll_area::ScrollArea;
|
||||
use gpui_native_kit::scrollbar::Scrollbar;
|
||||
use gpui_native_kit::spinner::Spinner;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{EventId, Kind, PublicKey, Url};
|
||||
use signed_core::{GitEvent, PullRequest, RepoAddr};
|
||||
use signed_git::{FileCommit, PatchParser};
|
||||
use signed_state::{Backend, Mirrors, RepoStore};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::dialogs;
|
||||
use crate::issues::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
|
||||
use crate::repo::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::repo::helpers::commit_row;
|
||||
use crate::shared::widgets::{empty_state, repo_tab_avatar, status_badge};
|
||||
|
||||
const NOT_FOUND: &str = "Pull request not found";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct PrBinding {
|
||||
description: String,
|
||||
patch: String,
|
||||
tip: Option<String>,
|
||||
base: Option<String>,
|
||||
clone_urls: Vec<Url>,
|
||||
addr: RepoAddr,
|
||||
has_patch_link: bool,
|
||||
}
|
||||
|
||||
pub struct PullRequestDetailView {
|
||||
dock: WeakEntity<DockState>,
|
||||
store: Entity<RepoStore>,
|
||||
pr_id: EventId,
|
||||
comment_input: Entity<InputState>,
|
||||
repo_name: SharedString,
|
||||
worktree: Option<PathBuf>,
|
||||
description: SharedString,
|
||||
current_commit: Option<SharedString>,
|
||||
commits: Vec<FileCommit>,
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
bound: Option<PrBinding>,
|
||||
load_generation: u64,
|
||||
active_tab: usize,
|
||||
pane: Entity<DiffPane>,
|
||||
commit_scroll_handle: UniformListScrollHandle,
|
||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestDetailView {
|
||||
pub fn new(
|
||||
dock: WeakEntity<DockState>,
|
||||
store: Entity<RepoStore>,
|
||||
pr_id: EventId,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let pane = cx.new(DiffPane::new);
|
||||
let comment_input = InputState::builder().multiline().build(cx);
|
||||
let weak = cx.entity().downgrade();
|
||||
let repo_name = store.read(cx).name();
|
||||
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.sync(cx);
|
||||
});
|
||||
|
||||
cx.defer(move |cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| this.sync(cx)));
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
store,
|
||||
pr_id,
|
||||
comment_input,
|
||||
repo_name,
|
||||
worktree: None,
|
||||
description: SharedString::default(),
|
||||
current_commit: None,
|
||||
commits: Vec::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
bound: None,
|
||||
load_generation: 0,
|
||||
active_tab: 0,
|
||||
pane,
|
||||
commit_scroll_handle: UniformListScrollHandle::new(),
|
||||
tasks: Vec::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn track_participants(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
.map(|root| vec![root.pubkey])
|
||||
.unwrap_or_default();
|
||||
keys.extend(store.comments_of(&self.pr_id).map(|comment| comment.pubkey));
|
||||
if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) {
|
||||
keys.push(owner);
|
||||
}
|
||||
self.directory
|
||||
.update(cx, |directory, cx| directory.track(keys, cx));
|
||||
}
|
||||
|
||||
fn sync(&mut self, cx: &mut Context<Self>) {
|
||||
self.track_participants(cx);
|
||||
let loaded = self.store.read(cx).loaded;
|
||||
let binding = {
|
||||
let store = self.store.read(cx);
|
||||
store.addr().and_then(|addr| {
|
||||
store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
.map(|root| {
|
||||
let update = PullRequest::latest_update(store.pull_requests.iter(), root);
|
||||
let tip = update
|
||||
.and_then(GitEvent::current_commit)
|
||||
.or_else(|| root.current_commit());
|
||||
let base = update
|
||||
.and_then(GitEvent::merge_base)
|
||||
.or_else(|| root.merge_base());
|
||||
let clone_urls = root
|
||||
.clone_urls()
|
||||
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
|
||||
.unwrap_or_default();
|
||||
PrBinding {
|
||||
description: root.content.clone(),
|
||||
patch: PullRequest::new(root).patch(store.patches.iter()),
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr: addr.clone(),
|
||||
has_patch_link: root.tags.event_ids().next().is_some(),
|
||||
}
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
let Some(binding) = binding else {
|
||||
self.bound = None;
|
||||
if !loaded {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
} else if self.error.as_deref() != Some(NOT_FOUND) {
|
||||
self.loading = false;
|
||||
self.error = Some(NOT_FOUND.into());
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
if self.bound.as_ref() == Some(&binding) {
|
||||
return;
|
||||
}
|
||||
self.bound = Some(binding.clone());
|
||||
self.load_diff(binding, cx);
|
||||
}
|
||||
|
||||
fn load_diff(&mut self, binding: PrBinding, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
self.description = binding.description.clone().into();
|
||||
self.current_commit = binding.tip.clone().map(SharedString::from);
|
||||
cx.notify();
|
||||
|
||||
self.load_generation = self.load_generation.wrapping_add(1);
|
||||
let generation = self.load_generation;
|
||||
let PrBinding {
|
||||
patch,
|
||||
tip,
|
||||
base,
|
||||
clone_urls,
|
||||
addr,
|
||||
has_patch_link,
|
||||
..
|
||||
} = binding;
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
|
||||
let nostr_diff = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { PatchParser::patch_diffs(&patch) }
|
||||
})
|
||||
.await;
|
||||
let nostr_commits = cx
|
||||
.background_spawn({
|
||||
let patch = patch.clone();
|
||||
async move { PatchParser::patch_commits(&patch) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let use_nostr = match &nostr_diff {
|
||||
Ok(diff) => has_patch_link || !diff.files.is_empty(),
|
||||
Err(_) => true,
|
||||
};
|
||||
|
||||
let git = if use_nostr {
|
||||
None
|
||||
} else {
|
||||
let addr = addr.clone();
|
||||
let clone_urls = clone_urls.clone();
|
||||
let base = base.clone();
|
||||
let tip = tip.clone();
|
||||
Some(
|
||||
cx.background_spawn(async move {
|
||||
let repo = Mirrors::ensure(&addr, &clone_urls)?;
|
||||
let gix_repo = repo.inner();
|
||||
let workdir = gix_repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
let tip =
|
||||
tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?;
|
||||
let base = match base {
|
||||
Some(base) => base,
|
||||
None => {
|
||||
let head = gix_repo
|
||||
.head_id()
|
||||
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
|
||||
let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?;
|
||||
gix_repo.merge_base(tip_id, head)?.to_string()
|
||||
}
|
||||
};
|
||||
let diff = repo.range_diff(&base, &tip)?;
|
||||
let commits = repo.commit_range(&base, &tip)?;
|
||||
Ok::<_, anyhow::Error>((diff, commits, workdir))
|
||||
})
|
||||
.await,
|
||||
)
|
||||
};
|
||||
|
||||
let (diff, commits, worktree) = match git {
|
||||
Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)),
|
||||
Some(Err(error)) => (Err(error), Vec::new(), None),
|
||||
None => (nostr_diff, nostr_commits, None),
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if this.load_generation != generation {
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
this.commits = commits;
|
||||
match diff {
|
||||
Ok(diff) => this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)),
|
||||
Err(error) => this.error = Some(error.to_string().into()),
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn open_commit_diff(
|
||||
&mut self,
|
||||
worktree: PathBuf,
|
||||
commit_id: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = match self.store.read(cx).addr() {
|
||||
Some(addr) => SharedString::from(format!("commit:{addr}:{commit_id}")),
|
||||
None => SharedString::from(format!("commit:{}:{commit_id}", worktree.display())),
|
||||
};
|
||||
let dock = self.dock.clone();
|
||||
let store = self.store.clone();
|
||||
let repo_name = self.repo_name.clone();
|
||||
let commit_id = commit_id.to_owned();
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
CommitDiffView::new(
|
||||
store.clone(),
|
||||
worktree.clone(),
|
||||
repo_name.clone(),
|
||||
commit_id.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let files = self.pane.read(cx).diff().map(|diff| diff.files.len());
|
||||
let commits = (!self.commits.is_empty()).then_some(self.commits.len());
|
||||
let weak = cx.entity().downgrade();
|
||||
let files_label = match files {
|
||||
Some(count) => format!("Files ({count})"),
|
||||
None => "Files".to_owned(),
|
||||
};
|
||||
let commits_label = match commits {
|
||||
Some(count) => format!("Commits ({count})"),
|
||||
None => "Commits".to_owned(),
|
||||
};
|
||||
|
||||
Tabs::new("pr-tabs")
|
||||
.selected(self.active_tab)
|
||||
.tabs(move |_, _| {
|
||||
vec![
|
||||
Tab::new("discussion", "Discussion"),
|
||||
Tab::new("files", files_label.clone()),
|
||||
Tab::new("commits", commits_label.clone()),
|
||||
]
|
||||
})
|
||||
.on_change(move |index, _, cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
this.active_tab = index;
|
||||
cx.notify();
|
||||
}));
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_discussion(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return centered_spinner();
|
||||
}
|
||||
if let Some(error) = self.error.clone() {
|
||||
return empty_state(error);
|
||||
}
|
||||
let (author, picture, age, root_id, pubkey) = {
|
||||
let store = self.store.read(cx);
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
return empty_state(NOT_FOUND);
|
||||
};
|
||||
let profile = self.directory.read(cx).profile(&root.pubkey, cx);
|
||||
(
|
||||
profile.name(),
|
||||
profile.picture(),
|
||||
relative_time(root.created_at),
|
||||
root.id,
|
||||
root.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.child(
|
||||
ScrollArea::new("pr-discussion-scroll")
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.gap_6()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pubkey)
|
||||
.name(author.clone())
|
||||
.picture(picture)
|
||||
.small(),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(SharedString::from("commented"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.when(!self.description.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child(self.description.clone()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(comments_section(&self.store, root_id, &self.directory, cx))
|
||||
.child(comment_form(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
&self.comment_input,
|
||||
"pr-comment",
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(sidebar_section(
|
||||
&self.store,
|
||||
root_id,
|
||||
pr_roots,
|
||||
true,
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_files(&self, _cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return centered_spinner();
|
||||
}
|
||||
if let Some(error) = self.error.clone() {
|
||||
return empty_state(error);
|
||||
}
|
||||
self.pane.clone().into_any_element()
|
||||
}
|
||||
|
||||
fn render_commits(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return centered_spinner();
|
||||
}
|
||||
if let Some(error) = self.error.clone() {
|
||||
return empty_state(error);
|
||||
}
|
||||
if self.commits.is_empty() {
|
||||
return empty_state("No commits found");
|
||||
}
|
||||
let commits = self.commits.clone();
|
||||
let worktree = self.worktree.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(
|
||||
uniform_list("pr-commits", commits.len(), move |range, _, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
let commit = commits[ix].clone();
|
||||
let id = commit.id.clone();
|
||||
let worktree = worktree.clone();
|
||||
let view = view.clone();
|
||||
commit_row(
|
||||
ix,
|
||||
&commit,
|
||||
move |window, cx| {
|
||||
if let Some(worktree) = worktree.clone() {
|
||||
crate::shared::log_dropped(view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(worktree, &id, window, cx);
|
||||
}));
|
||||
}
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.commit_scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(
|
||||
"pr-commits-scroll",
|
||||
self.commit_scroll_handle.clone(),
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let current_commit = self.current_commit.clone();
|
||||
let (title, status, branch, author) = {
|
||||
let store = self.store.read(cx);
|
||||
let Some(root) = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
|
||||
else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
(
|
||||
root.activity_subject(),
|
||||
store.status_of(root),
|
||||
root.branch_name(),
|
||||
root.pubkey,
|
||||
)
|
||||
};
|
||||
|
||||
let can_update = Backend::global(cx).read(cx).current_user() == Some(author);
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
v_flex()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex().gap_2().child(status_badge(status)).child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Headline, cx)
|
||||
.text_ellipsis()
|
||||
.child(title),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.when_some(branch, |this, branch| {
|
||||
this.child(
|
||||
Button::new("branch")
|
||||
.label(branch)
|
||||
.icon(Icon::named(CustomIconName::GitBranch))
|
||||
.small(),
|
||||
)
|
||||
})
|
||||
.when(can_update, |this| {
|
||||
this.child(Button::new("update-pr").label("Update").small().on_click(
|
||||
move |_, _, cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
let root = this
|
||||
.store
|
||||
.read(cx)
|
||||
.pull_requests
|
||||
.iter()
|
||||
.find(|pr| {
|
||||
pr.id == this.pr_id && pr.kind == Kind::GitPullRequest
|
||||
})
|
||||
.cloned();
|
||||
if let Some(root) = root {
|
||||
dialogs::update::open(this.store.clone(), root, cx);
|
||||
}
|
||||
}));
|
||||
},
|
||||
))
|
||||
})
|
||||
.when_some(current_commit, |this, id| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.font_family(cx.theme().mono_font)
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(id.clone())
|
||||
.child(ClipboardButton::new("pr-commit", &id)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for PullRequestDetailView {
|
||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||
let hex = self.pr_id.to_hex();
|
||||
Some(super::helpers::pr_label(&self.repo_name, &hex[..8]))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||
Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PullRequestDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.id("pull-request-detail")
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.child(self.render_header(cx))
|
||||
.child(self.render_tabs(cx))
|
||||
.map(|this| match self.active_tab {
|
||||
0 => this.child(self.render_discussion(cx)),
|
||||
1 => this.child(self.render_files(cx)),
|
||||
_ => this.child(self.render_commits(cx)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new("pr-loading"))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod update;
|
||||
@@ -1,85 +0,0 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use nostr::prelude::Event;
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::shared::dialogs::{close_global, open_global};
|
||||
|
||||
pub struct UpdatePrDialog {
|
||||
store: Entity<RepoStore>,
|
||||
root: Event,
|
||||
patch: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl UpdatePrDialog {
|
||||
pub fn new(store: Entity<RepoStore>, root: Event, cx: &mut App) -> Entity<Self> {
|
||||
let patch = InputState::builder().multiline().build(cx);
|
||||
cx.new(|_| Self { store, root, patch })
|
||||
}
|
||||
|
||||
fn submit(&mut self, cx: &mut Context<Self>) {
|
||||
let patch = self.patch.read(cx).value().to_string();
|
||||
if patch.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
self.store.update(cx, |store, cx| {
|
||||
store.update_pull_request(&self.root, patch, cx);
|
||||
});
|
||||
close_global(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for UpdatePrDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let patch = self.patch.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
Dialog::new("update-pr-dialog")
|
||||
.open(true)
|
||||
.min_width(gpui::px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Update pull request")
|
||||
.content(move |_, cx| {
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Publish a new revision with the output of `git format-patch`."),
|
||||
)
|
||||
.child(
|
||||
Field::new("update-patch", "Patch")
|
||||
.child(Input::new("update-patch-input", patch.clone()).rows(6)),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, _| {
|
||||
let this = this.clone();
|
||||
vec![
|
||||
Button::new("submit-update")
|
||||
.label("Update pull request")
|
||||
.on_click(move |_, _, cx| {
|
||||
crate::shared::log_dropped(this.update(cx, |this, cx| this.submit(cx)));
|
||||
})
|
||||
.into_any_element() as AnyElement,
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(store: Entity<RepoStore>, root: Event, cx: &mut App) {
|
||||
open_global(UpdatePrDialog::new(store, root, cx), cx);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::{SharedString, WeakEntity};
|
||||
use gpui_native_kit::menu::MenuItem;
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
||||
use utils::middle_truncate;
|
||||
|
||||
use super::NewPullRequestView;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PullRequestFilter {
|
||||
All,
|
||||
Open,
|
||||
Closed,
|
||||
Draft,
|
||||
Merged,
|
||||
}
|
||||
|
||||
impl PullRequestFilter {
|
||||
pub fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Open => status == RepoStatus::Open,
|
||||
Self::Closed => status == RepoStatus::Closed,
|
||||
Self::Draft => status == RepoStatus::Draft,
|
||||
Self::Merged => status == RepoStatus::Applied,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_message(self) -> &'static str {
|
||||
match self {
|
||||
Self::All => "No pull requests",
|
||||
Self::Open => "No open pull requests",
|
||||
Self::Closed => "No closed pull requests",
|
||||
Self::Draft => "No draft pull requests",
|
||||
Self::Merged => "No merged pull requests",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ForkCompare {
|
||||
pub announcement: Announcement,
|
||||
pub namespace: String,
|
||||
pub mirror_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ForkCompare {
|
||||
pub fn base_ref(name: &str) -> String {
|
||||
format!("refs/remotes/origin/{name}")
|
||||
}
|
||||
|
||||
pub fn compare_ref(&self, name: &str) -> String {
|
||||
format!("refs/fork/{}/{}", self.namespace, name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pr_label(repo_name: &SharedString, pr_id: &str) -> SharedString {
|
||||
if repo_name.is_empty() {
|
||||
SharedString::from(pr_id)
|
||||
} else {
|
||||
SharedString::from(format!("{repo_name}/{pr_id}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fork_display_name(announcement: &Announcement) -> SharedString {
|
||||
announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
}
|
||||
|
||||
pub fn shorten_owner(owner: &PublicKey) -> String {
|
||||
owner.to_hex().chars().take(10).collect()
|
||||
}
|
||||
|
||||
pub fn truncate_label(label: &str) -> SharedString {
|
||||
SharedString::from(middle_truncate(label, 18, 0))
|
||||
}
|
||||
|
||||
pub fn source_menu_items(
|
||||
view: WeakEntity<NewPullRequestView>,
|
||||
associated: Vec<PathBuf>,
|
||||
active_path: Option<PathBuf>,
|
||||
candidates: Vec<Announcement>,
|
||||
user: Option<PublicKey>,
|
||||
active_fork: Option<RepoAddr>,
|
||||
) -> Vec<MenuItem> {
|
||||
let mut items = Vec::new();
|
||||
for path in associated {
|
||||
let label = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
let target = view.clone();
|
||||
let path_for_action = path.clone();
|
||||
items.push(
|
||||
MenuItem::new(truncate_label(&label))
|
||||
.checked(active_path.as_ref() == Some(&path))
|
||||
.handler(move |window, cx| {
|
||||
let path = path_for_action.clone();
|
||||
if let Err(error) =
|
||||
target.update(cx, |this, cx| this.apply_folder_path(path, window, cx))
|
||||
{
|
||||
log::warn!("failed to apply checkout: {error}");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let folder_view = view.clone();
|
||||
items.push(
|
||||
MenuItem::new("Choose another folder…").handler(move |window, cx| {
|
||||
if let Err(error) = folder_view.update(cx, |this, cx| this.choose_checkout(window, cx))
|
||||
{
|
||||
log::warn!("failed to choose checkout: {error}");
|
||||
}
|
||||
}),
|
||||
);
|
||||
items.push(MenuItem::separator());
|
||||
|
||||
if candidates.is_empty() {
|
||||
items.push(MenuItem::new("No announced forks of this repository").disabled(true));
|
||||
} else {
|
||||
for candidate in candidates {
|
||||
let subtitle = if Some(candidate.owner) == user {
|
||||
SharedString::from("Your fork")
|
||||
} else {
|
||||
SharedString::from(format!("by {}", shorten_owner(&candidate.owner)))
|
||||
};
|
||||
let title = format!("{} — {}", fork_display_name(&candidate), subtitle);
|
||||
let target = view.clone();
|
||||
let checked = active_fork == Some(candidate.addr());
|
||||
let announcement = candidate;
|
||||
items.push(
|
||||
MenuItem::new(truncate_label(&title))
|
||||
.checked(checked)
|
||||
.handler(move |window, cx| {
|
||||
let announcement = announcement.clone();
|
||||
if let Err(error) =
|
||||
target.update(cx, |this, cx| this.choose_fork(announcement, window, cx))
|
||||
{
|
||||
log::warn!("failed to choose fork: {error}");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _,
|
||||
Subscription, UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list,
|
||||
};
|
||||
use gpui_native_kit::alert::{Alert, AlertSeverity};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::dropdown_menu::DropdownMenu;
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
use gpui_native_kit::menu::MenuItem;
|
||||
use gpui_native_kit::scrollbar::Scrollbar;
|
||||
use gpui_native_kit::segmented::{Segmented, SegmentedOption};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::prelude::{Kind, PublicKey};
|
||||
use signed_core::GitEvent;
|
||||
use signed_state::RepoStore;
|
||||
use utils::relative_time;
|
||||
|
||||
mod detail;
|
||||
mod dialogs;
|
||||
mod helpers;
|
||||
mod new;
|
||||
mod send_patch;
|
||||
|
||||
pub use detail::PullRequestDetailView;
|
||||
use helpers::PullRequestFilter;
|
||||
pub use new::NewPullRequestView;
|
||||
pub use send_patch::SendPatchView;
|
||||
|
||||
use crate::issues::helpers::{StatusCounts, filter_by_status};
|
||||
use crate::shared::widgets::{empty_state, repo_tab_avatar, status_badge};
|
||||
|
||||
const ROW_HEIGHT: f32 = 73.;
|
||||
|
||||
pub struct PullRequestsView {
|
||||
dock: WeakEntity<DockState>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
filter: PullRequestFilter,
|
||||
visible_prs: Vec<usize>,
|
||||
counts: StatusCounts,
|
||||
synced_filter: PullRequestFilter,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
new_pr_menu_open: bool,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl PullRequestsView {
|
||||
pub fn new(
|
||||
dock: WeakEntity<DockState>,
|
||||
store: Entity<RepoStore>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let weak = cx.entity().downgrade();
|
||||
let repo_name = store.read(cx).name();
|
||||
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||
this.rebuild(cx);
|
||||
});
|
||||
|
||||
cx.defer(move |cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
this.rebuild(cx);
|
||||
}));
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
store,
|
||||
repo_name,
|
||||
filter: PullRequestFilter::Open,
|
||||
visible_prs: Vec::new(),
|
||||
counts: StatusCounts::default(),
|
||||
synced_filter: PullRequestFilter::Open,
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
new_pr_menu_open: false,
|
||||
_subscription: subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, cx: &mut Context<Self>) {
|
||||
let store = self.store.read(cx);
|
||||
let filter = self.filter;
|
||||
|
||||
let (visible_prs, counts) = {
|
||||
let roots = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest);
|
||||
|
||||
filter_by_status(
|
||||
roots,
|
||||
|pr| store.status_of(pr),
|
||||
|status| filter.matches(status),
|
||||
)
|
||||
};
|
||||
|
||||
let mut keys: Vec<PublicKey> = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest)
|
||||
.map(|pr| pr.pubkey)
|
||||
.collect();
|
||||
|
||||
if let Some(owner) = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| announcement.owner)
|
||||
{
|
||||
keys.push(owner);
|
||||
}
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(keys, cx);
|
||||
});
|
||||
|
||||
let filter_changed = self.synced_filter != filter;
|
||||
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts {
|
||||
return;
|
||||
}
|
||||
self.synced_filter = filter;
|
||||
self.visible_prs = visible_prs;
|
||||
self.counts = counts;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn open_new(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = SharedString::from(format!("new-pr:{addr}"));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id.clone(),
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
NewPullRequestView::new(
|
||||
dock_for_build.clone(),
|
||||
id.clone(),
|
||||
store.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = SharedString::from(format!("send-patch:{addr}"));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id.clone(),
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
SendPatchView::new(
|
||||
dock_for_build.clone(),
|
||||
id.clone(),
|
||||
store.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let counts = self.counts;
|
||||
let muted = cx.theme().colors.muted_foreground;
|
||||
let weak = cx.entity().downgrade();
|
||||
let open_weak = cx.entity().downgrade();
|
||||
let menu_open_weak = cx.entity().downgrade();
|
||||
let patch_weak = cx.entity().downgrade();
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.bg(cx.theme().colors.muted)
|
||||
.child(
|
||||
Segmented::new("pr-filter", self.filter)
|
||||
.options(vec![
|
||||
SegmentedOption::new(
|
||||
PullRequestFilter::All,
|
||||
format!("All ({})", counts.total),
|
||||
)
|
||||
.icon(Icon::named(CustomIconName::GitPullRequest).color(muted)),
|
||||
SegmentedOption::new(
|
||||
PullRequestFilter::Open,
|
||||
format!("Open ({})", counts.open),
|
||||
)
|
||||
.icon(Icon::named(CustomIconName::GitPullRequest).color(muted)),
|
||||
SegmentedOption::new(
|
||||
PullRequestFilter::Closed,
|
||||
format!("Closed ({})", counts.closed),
|
||||
)
|
||||
.icon(Icon::named(CustomIconName::GitPullRequestClosed).color(muted)),
|
||||
SegmentedOption::new(
|
||||
PullRequestFilter::Draft,
|
||||
format!("Draft ({})", counts.draft),
|
||||
)
|
||||
.icon(Icon::named(CustomIconName::GitPullRequestDraft).color(muted)),
|
||||
SegmentedOption::new(
|
||||
PullRequestFilter::Merged,
|
||||
format!("Merged ({})", counts.applied),
|
||||
)
|
||||
.icon(Icon::named(CustomIconName::GitPullRequestMerged).color(muted)),
|
||||
])
|
||||
.on_change(move |filter, _, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild(cx);
|
||||
}) {
|
||||
log::warn!("failed to switch PR filter: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
DropdownMenu::new("new-pr-actions")
|
||||
.label("New")
|
||||
.icon(Icon::named(CustomIconName::GitPullRequest))
|
||||
.open(self.new_pr_menu_open)
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Err(error) =
|
||||
open_weak.update(cx, |this, cx| this.open_new(window, cx))
|
||||
{
|
||||
log::warn!("failed to open new PR: {error}");
|
||||
}
|
||||
})
|
||||
.on_open_change(move |open, _, cx| {
|
||||
crate::shared::log_dropped(menu_open_weak.update(cx, |this, cx| {
|
||||
this.new_pr_menu_open = open;
|
||||
cx.notify();
|
||||
}));
|
||||
})
|
||||
.items(move |_, _| {
|
||||
vec![MenuItem::new("Send Patch").handler({
|
||||
let weak = patch_weak.clone();
|
||||
move |window, cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
this.open_send_patch(window, cx);
|
||||
}));
|
||||
}
|
||||
})]
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for PullRequestsView {
|
||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||
Some(SharedString::from(format!("{}/pulls", self.repo_name)))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||
Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PullRequestsView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
let count = self.visible_prs.len();
|
||||
let visible = self.visible_prs.clone();
|
||||
let directory = self.directory.clone();
|
||||
let store = self.store.clone();
|
||||
let dock = self.dock.clone();
|
||||
|
||||
let (last_error, last_warning) = {
|
||||
let store = self.store.read(cx);
|
||||
(store.last_error.clone(), store.last_warning.clone())
|
||||
};
|
||||
|
||||
let dismiss_warning_store = self.store.clone();
|
||||
let dismiss_error_store = self.store.clone();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.child(self.render_header(cx))
|
||||
.when_some(last_warning, |this, warning| {
|
||||
this.child(
|
||||
Alert::new("pr-warning")
|
||||
.severity(AlertSeverity::Warning)
|
||||
.title("Pull request warning")
|
||||
.message(warning)
|
||||
.on_dismiss(move |cx| {
|
||||
dismiss_warning_store.update(cx, |store, _| store.last_warning = None);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_some(last_error, |this, error| {
|
||||
this.child(
|
||||
Alert::new("pr-error")
|
||||
.severity(AlertSeverity::Danger)
|
||||
.title("Pull request error")
|
||||
.message(error)
|
||||
.on_dismiss(move |cx| {
|
||||
dismiss_error_store.update(cx, |store, _| store.last_error = None);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.when(count > 0, |this| {
|
||||
this.child(
|
||||
uniform_list("prl", count, move |range, _, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
let pr_ix = visible[ix];
|
||||
let pr = &store.read(cx).pull_requests[pr_ix];
|
||||
let pr_id = pr.id;
|
||||
let title = pr.activity_subject();
|
||||
let id_hex = pr.id.to_hex();
|
||||
let age = relative_time(pr.created_at);
|
||||
let status = store.read(cx).status_of(pr);
|
||||
let profile = directory.read(cx).profile(&pr.pubkey, cx);
|
||||
let author = profile.name();
|
||||
let dock = dock.clone();
|
||||
let store = store.clone();
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.h(px(ROW_HEIGHT))
|
||||
.gap_4()
|
||||
.px_4()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.hover(|this| this.bg(cx.theme().colors.muted_hover))
|
||||
.on_click(move |_, window, cx| {
|
||||
let id = SharedString::from(format!("pr:{pr_id}"));
|
||||
let dock_for_build = dock.clone();
|
||||
let store = store.clone();
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
PullRequestDetailView::new(
|
||||
dock_for_build.clone(),
|
||||
store.clone(),
|
||||
pr_id,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.child(status_badge(status))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(title),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(pr.pubkey)
|
||||
.name(author.clone())
|
||||
.picture(
|
||||
profile.picture(),
|
||||
)
|
||||
.small(),
|
||||
)
|
||||
.child(div().child(author)),
|
||||
)
|
||||
.child(SharedString::from("opened"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(
|
||||
cx.theme()
|
||||
.colors
|
||||
.muted_foreground,
|
||||
)
|
||||
.child(SharedString::from(
|
||||
&id_hex[..8],
|
||||
)),
|
||||
)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(
|
||||
"prl-scroll",
|
||||
self.scroll_handle.clone(),
|
||||
)),
|
||||
)
|
||||
})
|
||||
.when(count == 0, |this| {
|
||||
this.child(empty_state(filter.empty_message()))
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, Styled as _, Subscription, UniformListScrollHandle,
|
||||
WeakEntity, Window, div, px, uniform_list,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::combobox::{Combobox, ComboboxState};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::data::tabs::{Tab, Tabs};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
use gpui_native_kit::menu::Menu;
|
||||
use gpui_native_kit::scrollbar::Scrollbar;
|
||||
use gpui_native_kit::spinner::Spinner;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_state::{CheckoutsStore, RepoStore};
|
||||
|
||||
use super::helpers::{ForkCompare, fork_display_name, truncate_label};
|
||||
use crate::repo::commit_diff::{CommitDiffView, DiffPane};
|
||||
use crate::repo::helpers::commit_row;
|
||||
use crate::shared::widgets::empty_state;
|
||||
|
||||
mod source;
|
||||
|
||||
pub struct NewPullRequestView {
|
||||
dock: WeakEntity<DockState>,
|
||||
panel_id: SharedString,
|
||||
pub(super) store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
pub(super) repo_path: Option<PathBuf>,
|
||||
pub(super) branches: Vec<SharedString>,
|
||||
pub(super) fork: Option<ForkCompare>,
|
||||
pub(super) base: SharedString,
|
||||
pub(super) compare: SharedString,
|
||||
pub(super) base_select: Entity<ComboboxState<SharedString>>,
|
||||
pub(super) compare_select: Entity<ComboboxState<SharedString>>,
|
||||
subject: Entity<InputState>,
|
||||
description: Entity<InputState>,
|
||||
pub(super) merge_base: Option<String>,
|
||||
pub(super) commits: Option<Vec<signed_git::FileCommit>>,
|
||||
pub(super) loading: bool,
|
||||
pub(super) error: Option<SharedString>,
|
||||
submitting: bool,
|
||||
pub(super) compare_generation: u64,
|
||||
pub(super) fork_generation: u64,
|
||||
active_tab: usize,
|
||||
pub(super) pane: Entity<DiffPane>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
pub(super) tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
source_open: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl NewPullRequestView {
|
||||
pub fn new(
|
||||
dock: WeakEntity<DockState>,
|
||||
panel_id: SharedString,
|
||||
store: Entity<RepoStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
let repo_name = store.read(cx).name();
|
||||
let pane = cx.new(DiffPane::new);
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
let subject = InputState::new(cx);
|
||||
let description = InputState::builder().multiline().build(cx);
|
||||
|
||||
let base_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
Combobox::new("pr-base", "Base", Vec::new()).on_change({
|
||||
let weak = weak.clone();
|
||||
move |value, _window, cx| {
|
||||
if let Some(base) = value {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
if this.base == base {
|
||||
return;
|
||||
}
|
||||
this.base = base;
|
||||
this.reload_compare(cx);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let compare_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
Combobox::new("pr-compare", "Compare", Vec::new()).on_change({
|
||||
let weak = weak.clone();
|
||||
move |value, _window, cx| {
|
||||
if let Some(compare) = value {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
if this.compare == compare {
|
||||
return;
|
||||
}
|
||||
this.compare = compare;
|
||||
this.reload_compare(cx);
|
||||
}));
|
||||
}
|
||||
}
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| directory.track([owner], cx));
|
||||
}
|
||||
|
||||
window.defer(cx, move |window, cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
let Some(addr) = this.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(path) = CheckoutsStore::global(cx)
|
||||
.read(cx)
|
||||
.associations_of(&addr)
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
this.apply_folder_path(path, window, cx);
|
||||
}));
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
panel_id,
|
||||
store,
|
||||
repo_name,
|
||||
repo_path: None,
|
||||
branches: Vec::new(),
|
||||
fork: None,
|
||||
base: SharedString::default(),
|
||||
compare: SharedString::default(),
|
||||
base_select,
|
||||
compare_select,
|
||||
subject,
|
||||
description,
|
||||
merge_base: None,
|
||||
commits: None,
|
||||
loading: false,
|
||||
error: None,
|
||||
submitting: false,
|
||||
compare_generation: 0,
|
||||
fork_generation: 0,
|
||||
active_tab: 0,
|
||||
pane,
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
tasks: Vec::new(),
|
||||
directory,
|
||||
source_open: false,
|
||||
_subscriptions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.submitting || self.loading {
|
||||
return;
|
||||
}
|
||||
let Some(merge_base) = self.merge_base.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(repo_path) = self.work_path() else {
|
||||
return;
|
||||
};
|
||||
let subject = self.subject.read(cx).value().to_string();
|
||||
let description = self.description.read(cx).value().to_string();
|
||||
let branch_name = self.compare.to_string();
|
||||
let compare_ref = self.compare_ref();
|
||||
let store = self.store.clone();
|
||||
let dock = self.dock.clone();
|
||||
let panel_id = self.panel_id.clone();
|
||||
|
||||
self.submitting = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
|
||||
let publish = store.update(cx, |store, cx| {
|
||||
store.open_pull_request_from_refs(
|
||||
repo_path,
|
||||
merge_base,
|
||||
compare_ref,
|
||||
(!subject.is_empty()).then_some(subject),
|
||||
description,
|
||||
Some(branch_name),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
if let Err(error) = publish.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.submitting = false;
|
||||
this.error = Some(error.to_string().into());
|
||||
cx.notify();
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
this.update(cx, |this, _cx| {
|
||||
this.submitting = false;
|
||||
})?;
|
||||
crate::shared::log_dropped(dock.update(cx, |dock, cx| dock.close_panel(&panel_id, cx)));
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(repo_path) = self.work_path() else {
|
||||
return;
|
||||
};
|
||||
let id = match self.store.read(cx).addr() {
|
||||
Some(addr) => SharedString::from(format!("commit:{addr}:{commit_id}")),
|
||||
None => SharedString::from(format!("commit:{}:{commit_id}", repo_path.display())),
|
||||
};
|
||||
let dock = self.dock.clone();
|
||||
let store = self.store.clone();
|
||||
let repo_name = self.repo_name.clone();
|
||||
let commit_id = commit_id.to_owned();
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
CommitDiffView::new(
|
||||
store.clone(),
|
||||
repo_path.clone(),
|
||||
repo_name.clone(),
|
||||
commit_id.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_enabled(&self, cx: &App) -> bool {
|
||||
self.has_source()
|
||||
&& !self.loading
|
||||
&& !self.submitting
|
||||
&& self.merge_base.is_some()
|
||||
&& self
|
||||
.commits
|
||||
.as_ref()
|
||||
.is_some_and(|commits| !commits.is_empty())
|
||||
&& !self.subject.read(cx).value().is_empty()
|
||||
}
|
||||
|
||||
fn render_compare_bar(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let weak = cx.entity().downgrade();
|
||||
let weak_menu = cx.entity().downgrade();
|
||||
let source_view = cx.entity().downgrade();
|
||||
let associated = self
|
||||
.store
|
||||
.read(cx)
|
||||
.addr()
|
||||
.map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr))
|
||||
.unwrap_or_default();
|
||||
let active_path = (self.fork.is_none())
|
||||
.then(|| self.repo_path.clone())
|
||||
.flatten();
|
||||
let candidates = self.fork_candidates(cx);
|
||||
let user = signed_state::Backend::global(cx).read(cx).current_user();
|
||||
let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr());
|
||||
let can_submit = self.submit_enabled(cx);
|
||||
let source_label = match &self.fork {
|
||||
Some(fork) => truncate_label(&fork_display_name(&fork.announcement)),
|
||||
None => self.repo_path.as_ref().map_or_else(
|
||||
|| SharedString::from("No source"),
|
||||
|path| truncate_label(&path.display().to_string()),
|
||||
),
|
||||
};
|
||||
let refresh_fork = self.fork.as_ref().map(|fork| fork.announcement.clone());
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.items_end()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(label("Merge Into", cx))
|
||||
.child(div().w(px(160.)).child(self.base_select.clone())),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(label("Pull From", cx))
|
||||
.child(div().w(px(160.)).child(self.compare_select.clone())),
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_1().child(label("Source", cx)).child(
|
||||
Menu::new("pr-source")
|
||||
.open(self.source_open)
|
||||
.on_change(move |open, _, cx| {
|
||||
crate::shared::log_dropped(weak_menu.update(cx, |this, cx| {
|
||||
this.source_open = open;
|
||||
cx.notify();
|
||||
}));
|
||||
})
|
||||
.trigger(move |_, _| {
|
||||
Button::new("compare-source")
|
||||
.label(source_label.clone())
|
||||
.variant(gpui_native_kit::button::ButtonVariant::Secondary)
|
||||
.into_any_element()
|
||||
})
|
||||
.items(move |_, _| {
|
||||
super::helpers::source_menu_items(
|
||||
source_view.clone(),
|
||||
associated.clone(),
|
||||
active_path.clone(),
|
||||
candidates.clone(),
|
||||
user,
|
||||
active_fork.clone(),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when_some(refresh_fork, |this, fork| {
|
||||
this.child(
|
||||
Button::new("refresh-fork")
|
||||
.icon(Icon::named(IconRole::Refresh))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.choose_fork(fork.clone(), window, cx);
|
||||
})),
|
||||
)
|
||||
})
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
Button::new("create-pr")
|
||||
.label("Create pull request")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.loading(self.submitting)
|
||||
.disabled(!can_submit)
|
||||
.on_click(move |_, window, cx| {
|
||||
crate::shared::log_dropped(
|
||||
weak.update(cx, |this, cx| this.submit(window, cx)),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len());
|
||||
let commits = self.commits.as_ref().map_or(0, |commits| commits.len());
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
Tabs::new("new-pr-tabs")
|
||||
.selected(self.active_tab)
|
||||
.tabs(move |_, _| {
|
||||
vec![
|
||||
Tab::new("files", format!("Files ({files})"))
|
||||
.lead(Icon::named(CustomIconName::GitFile)),
|
||||
Tab::new("commits", format!("Commits ({commits})"))
|
||||
.lead(Icon::named(CustomIconName::GitCommit)),
|
||||
]
|
||||
})
|
||||
.on_change(move |index, _, cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| {
|
||||
this.active_tab = index;
|
||||
cx.notify();
|
||||
}));
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_content(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.loading {
|
||||
return v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new("new-pr-loading"))
|
||||
.into_any_element();
|
||||
}
|
||||
if !self.has_source() {
|
||||
return empty_state("Choose a local checkout or an announced fork to compare");
|
||||
}
|
||||
if self.commits.is_none() && self.error.is_some() {
|
||||
return empty_state("Nothing to compare");
|
||||
}
|
||||
match self.active_tab {
|
||||
0 => self.pane.clone().into_any_element(),
|
||||
_ => self.render_commits(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_commits(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(commits) = self.commits.as_ref() else {
|
||||
return empty_state("No commits");
|
||||
};
|
||||
if commits.is_empty() {
|
||||
return empty_state("No commits between the branches");
|
||||
}
|
||||
let commits = commits.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(
|
||||
uniform_list("new-pr-commits", commits.len(), move |range, _, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
let commit = commits[ix].clone();
|
||||
let id = commit.id.clone();
|
||||
let view = view.clone();
|
||||
commit_row(
|
||||
ix,
|
||||
&commit,
|
||||
move |window, cx| {
|
||||
crate::shared::log_dropped(view.update(cx, |this, cx| {
|
||||
this.open_commit_diff(&id, window, cx);
|
||||
}));
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(
|
||||
"new-pr-commits-scroll",
|
||||
self.scroll_handle.clone(),
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for NewPullRequestView {
|
||||
fn title(&self, _cx: &gpui::App) -> Option<SharedString> {
|
||||
Some(SharedString::from(format!(
|
||||
"{}/new-pull-request",
|
||||
self.repo_name
|
||||
)))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &gpui::App) -> Option<AnyElement> {
|
||||
Some(crate::shared::widgets::repo_tab_avatar(
|
||||
self.store.read(cx),
|
||||
&self.directory,
|
||||
cx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NewPullRequestView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.id("new-pr")
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(self.render_compare_bar(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.px_4()
|
||||
.gap_2()
|
||||
.child(
|
||||
Input::new("pr-title", self.subject.clone()).placeholder("Title"),
|
||||
)
|
||||
.child(
|
||||
Input::new("pr-description", self.description.clone())
|
||||
.rows(3)
|
||||
.placeholder("Describe..."),
|
||||
)
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().ramps.destructive.base)
|
||||
.child(error),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(self.render_tabs(cx)),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.child(self.render_content(cx)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn label(text: &'static str, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.typeset(TextRole::Caption2, cx)
|
||||
.child(text)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gpui::{App, AppContext as _, Context, PathPromptOptions, SharedString, Window};
|
||||
use nostr::prelude::Url;
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{GitCache, Repo};
|
||||
use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore};
|
||||
|
||||
use super::NewPullRequestView;
|
||||
use crate::pull_requests::helpers::ForkCompare;
|
||||
|
||||
impl NewPullRequestView {
|
||||
pub(super) fn has_source(&self) -> bool {
|
||||
self.repo_path.is_some() || self.fork.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn work_path(&self) -> Option<PathBuf> {
|
||||
match &self.fork {
|
||||
Some(fork) => Some(fork.mirror_path.clone()),
|
||||
None => self.repo_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn base_ref(&self) -> String {
|
||||
match &self.fork {
|
||||
Some(_) => ForkCompare::base_ref(&self.base),
|
||||
None => self.base.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn compare_ref(&self) -> String {
|
||||
match &self.fork {
|
||||
Some(fork) => fork.compare_ref(&self.compare),
|
||||
None => self.compare.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Choose local checkout".into()),
|
||||
});
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let picked = match prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => paths.pop(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(path) = picked else {
|
||||
return Ok(());
|
||||
};
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.apply_folder_path(path, window, cx)
|
||||
})?;
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
pub(crate) fn apply_folder_path(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.fork_generation = self.fork_generation.wrapping_add(1);
|
||||
let generation = self.fork_generation;
|
||||
let path_string = path.to_string_lossy().to_string();
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> =
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let info = cx
|
||||
.background_spawn({
|
||||
let path = path.clone();
|
||||
async move {
|
||||
let repo = Repo::try_open(&path)?;
|
||||
match repo.branches() {
|
||||
Ok(branches) => Some((branches, repo.current_branch())),
|
||||
Err(error) => {
|
||||
log::warn!("failed to list branches: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if generation != this.fork_generation {
|
||||
return;
|
||||
}
|
||||
this.apply_checkout(path_string, info, window, cx);
|
||||
})?;
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn apply_checkout(
|
||||
&mut self,
|
||||
path: String,
|
||||
info: Option<(Vec<String>, Option<String>)>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.fork = None;
|
||||
let Some((branches, current)) = info else {
|
||||
self.error = Some("The chosen folder is not a git repository".into());
|
||||
self.repo_path = None;
|
||||
self.branches.clear();
|
||||
self.merge_base = None;
|
||||
self.commits = None;
|
||||
self.pane.update(cx, |pane, cx| pane.clear(cx));
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
if branches.is_empty() {
|
||||
self.error = Some("The repository has no branches yet".into());
|
||||
self.repo_path = None;
|
||||
self.branches.clear();
|
||||
self.merge_base = None;
|
||||
self.commits = None;
|
||||
self.pane.update(cx, |pane, cx| pane.clear(cx));
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let announced = self.store.read(cx).head.clone();
|
||||
let base = announced
|
||||
.as_ref()
|
||||
.filter(|branch| branches.contains(branch))
|
||||
.cloned()
|
||||
.or_else(|| branches.iter().find(|branch| *branch == "main").cloned())
|
||||
.unwrap_or_else(|| branches[0].clone());
|
||||
let compare = current
|
||||
.filter(|branch| branches.contains(branch))
|
||||
.unwrap_or_else(|| base.clone());
|
||||
|
||||
self.repo_path = Some(PathBuf::from(&path));
|
||||
self.error = None;
|
||||
self.branches = branches.into_iter().map(SharedString::from).collect();
|
||||
|
||||
if let Some(addr) = self.store.read(cx).addr().cloned() {
|
||||
CheckoutsStore::global(cx).update(cx, |store, cx| {
|
||||
store.record(PathBuf::from(&path), addr, cx);
|
||||
});
|
||||
}
|
||||
|
||||
self.base = SharedString::from(base);
|
||||
self.compare = SharedString::from(compare);
|
||||
self.sync_selectors(window, cx);
|
||||
self.reload_compare(cx);
|
||||
}
|
||||
|
||||
fn sync_selectors(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let options: Vec<(SharedString, SharedString)> = self
|
||||
.branches
|
||||
.iter()
|
||||
.map(|branch| (branch.clone(), branch.clone()))
|
||||
.collect();
|
||||
let base = self.base.clone();
|
||||
let compare = self.compare.clone();
|
||||
|
||||
self.base_select.update(cx, |state, cx| {
|
||||
state.set_options(options.clone(), cx);
|
||||
state.set_selected(Some(base), window, cx);
|
||||
});
|
||||
self.compare_select.update(cx, |state, cx| {
|
||||
state.set_options(options, cx);
|
||||
state.set_selected(Some(compare), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let euc = self
|
||||
.store
|
||||
.read(cx)
|
||||
.announcement
|
||||
.as_ref()
|
||||
.and_then(|a| a.euc.clone());
|
||||
let user = Backend::global(cx).read(cx).current_user();
|
||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||
Announcement::forks_in(&announcements, &addr, euc.as_deref(), user)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn choose_fork(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(base) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
let mirror_path = Mirrors::path(&base);
|
||||
let namespace = GitCache::fork_namespace(&announcement);
|
||||
let clone_urls: Vec<Url> = announcement.clone.clone();
|
||||
let base_clone_urls: Vec<Url> = self
|
||||
.store
|
||||
.read(cx)
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| a.clone.clone())
|
||||
.unwrap_or_default();
|
||||
self.fork_generation = self.fork_generation.wrapping_add(1);
|
||||
let generation = self.fork_generation;
|
||||
let handle = window.window_handle();
|
||||
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn({
|
||||
let namespace = namespace.clone();
|
||||
let mirror_path = mirror_path.clone();
|
||||
async move {
|
||||
Mirrors::ensure(&base, &base_clone_urls)?;
|
||||
let repo = Repo::open(&mirror_path)?;
|
||||
repo.delete_refs_with_prefix("refs/fork")?;
|
||||
repo.fetch_refs(
|
||||
&clone_urls,
|
||||
&format!("+refs/heads/*:refs/fork/{namespace}/*"),
|
||||
)?;
|
||||
let strip = |refs: Vec<String>, prefix: &str| {
|
||||
let mut names: Vec<String> = refs
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
name.strip_prefix(prefix)
|
||||
.map(|rest| rest.trim_start_matches('/').to_owned())
|
||||
})
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
let base_branches = strip(
|
||||
repo.refs_with_prefix("refs/remotes/origin")?,
|
||||
"refs/remotes/origin",
|
||||
);
|
||||
let compare_branches = strip(
|
||||
repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?,
|
||||
&format!("refs/fork/{namespace}"),
|
||||
);
|
||||
Ok::<_, anyhow::Error>((base_branches, compare_branches))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(error) = cx.update_window(handle, |_, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
if generation != this.fork_generation {
|
||||
return;
|
||||
}
|
||||
this.apply_fork(announcement, mirror_path, namespace, result, window, cx);
|
||||
})
|
||||
}) {
|
||||
log::warn!("failed to apply fork: {error}");
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn apply_fork(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
mirror_path: PathBuf,
|
||||
namespace: String,
|
||||
result: Result<(Vec<String>, Vec<String>), anyhow::Error>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.loading = false;
|
||||
let (base_branches, compare_branches) = match result {
|
||||
Ok(branches) => branches,
|
||||
Err(error) => {
|
||||
self.error = Some(format!("Could not compare against the fork: {error}").into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
};
|
||||
if compare_branches.is_empty() {
|
||||
self.error = Some("The fork has no branches to compare".into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if base_branches.is_empty() {
|
||||
self.error = Some("Could not list the target repository's branches.".into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let announced = self.store.read(cx).head.clone();
|
||||
let base = announced
|
||||
.filter(|branch| base_branches.contains(branch))
|
||||
.map(SharedString::from)
|
||||
.or_else(|| {
|
||||
base_branches
|
||||
.iter()
|
||||
.find(|branch| branch.as_str() == "main")
|
||||
.map(|branch| SharedString::from(branch.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(base_branches[0].clone()));
|
||||
let compare = compare_branches
|
||||
.iter()
|
||||
.find(|branch| branch.as_str() == "main")
|
||||
.map(|branch| SharedString::from(branch.clone()))
|
||||
.unwrap_or_else(|| SharedString::from(compare_branches[0].clone()));
|
||||
|
||||
self.fork = Some(ForkCompare {
|
||||
announcement,
|
||||
namespace,
|
||||
mirror_path,
|
||||
});
|
||||
self.error = None;
|
||||
self.base = base;
|
||||
self.compare = compare;
|
||||
self.branches = base_branches.into_iter().map(SharedString::from).collect();
|
||||
self.sync_selectors(window, cx);
|
||||
self.reload_compare(cx);
|
||||
}
|
||||
|
||||
pub(super) fn reload_compare(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(repo_path) = self.work_path() else {
|
||||
return;
|
||||
};
|
||||
let base = self.base_ref();
|
||||
let compare = self.compare_ref();
|
||||
let base_name = self.base.to_string();
|
||||
let compare_name = self.compare.to_string();
|
||||
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
self.compare_generation += 1;
|
||||
let generation = self.compare_generation;
|
||||
cx.notify();
|
||||
|
||||
if base == compare {
|
||||
self.loading = false;
|
||||
self.merge_base = None;
|
||||
self.commits = None;
|
||||
self.pane.update(cx, |pane, cx| pane.clear(cx));
|
||||
self.error = Some("Choose different base and compare branches".into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let task: gpui::Task<Result<(), anyhow::Error>> = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
.background_spawn({
|
||||
async move {
|
||||
let repo = Repo::open(Path::new(&repo_path))?;
|
||||
let merge_base = repo.merge_base(&base, &compare)?.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"{base_name} and {compare_name} share no common ancestor"
|
||||
)
|
||||
})?;
|
||||
let commits = repo.commit_range(&merge_base, &compare)?;
|
||||
let diff = repo.range_diff(&merge_base, &compare)?;
|
||||
Ok::<_, anyhow::Error>((merge_base, commits, diff))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
if generation != this.compare_generation {
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
match result {
|
||||
Ok((merge_base, commits, diff)) => {
|
||||
this.merge_base = Some(merge_base);
|
||||
this.commits = Some(commits);
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.merge_base = None;
|
||||
this.commits = None;
|
||||
this.pane.update(cx, |pane, cx| pane.clear(cx));
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
Ok(())
|
||||
});
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
Render, SharedString, Styled as _, WeakEntity, Window, div,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::shared::widgets::repo_tab_avatar;
|
||||
|
||||
pub struct SendPatchView {
|
||||
dock: WeakEntity<DockState>,
|
||||
panel_id: SharedString,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
subject: Entity<InputState>,
|
||||
description: Entity<InputState>,
|
||||
patch: Entity<InputState>,
|
||||
submitting: bool,
|
||||
error: Option<SharedString>,
|
||||
directory: Entity<ProfileDirectory>,
|
||||
}
|
||||
|
||||
impl SendPatchView {
|
||||
pub fn new(
|
||||
dock: WeakEntity<DockState>,
|
||||
panel_id: SharedString,
|
||||
store: Entity<RepoStore>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let subject = InputState::new(cx);
|
||||
let description = InputState::builder().multiline().build(cx);
|
||||
let patch = InputState::builder().multiline().build(cx);
|
||||
|
||||
let repo_name = store.read(cx).name();
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
dock,
|
||||
panel_id,
|
||||
store,
|
||||
repo_name,
|
||||
subject,
|
||||
description,
|
||||
patch,
|
||||
submitting: false,
|
||||
error: None,
|
||||
directory,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, cx: &mut Context<Self>) {
|
||||
if self.submitting {
|
||||
return;
|
||||
}
|
||||
|
||||
let subject = self.subject.read(cx).value().to_string();
|
||||
let description = self.description.read(cx).value().to_string();
|
||||
|
||||
let patch = self.patch.read(cx).value().to_string();
|
||||
if patch.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.submitting = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let sync_error = self.store.update(cx, |store, cx| {
|
||||
store.open_pull_request(
|
||||
(!subject.is_empty()).then_some(subject),
|
||||
description,
|
||||
None,
|
||||
patch,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
store.last_error.clone()
|
||||
});
|
||||
|
||||
if let Some(error) = sync_error {
|
||||
self.submitting = false;
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let panel_id = self.panel_id.clone();
|
||||
crate::shared::log_dropped(
|
||||
self.dock
|
||||
.update(cx, |dock, cx| dock.close_panel(&panel_id, cx)),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_footer(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let can_submit = !self.submitting
|
||||
&& !self.subject.read(cx).value().is_empty()
|
||||
&& !self.patch.read(cx).value().is_empty();
|
||||
let weak = cx.entity().downgrade();
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.justify_end()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.child(
|
||||
Button::new("send-patch")
|
||||
.label("Send patch")
|
||||
.icon(Icon::named(CustomIconName::ArrowUp))
|
||||
.loading(self.submitting)
|
||||
.disabled(!can_submit)
|
||||
.on_click(move |_, _, cx| {
|
||||
crate::shared::log_dropped(weak.update(cx, |this, cx| this.submit(cx)));
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for SendPatchView {
|
||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||
Some(SharedString::from(format!("{}/send-patch", self.repo_name)))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||
Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SendPatchView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.id("send-patch")
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.p_4()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.child(Input::new("patch-subject", self.subject.clone()).placeholder("Title"))
|
||||
.child(
|
||||
Input::new("patch-description", self.description.clone())
|
||||
.rows(2)
|
||||
.placeholder("Describe the change"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(
|
||||
"You can paste a git diff or a git format-patch patch series here.",
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Input::new("patch-body", self.patch.clone())
|
||||
.rows(12)
|
||||
.placeholder("diff --git a/file.txt b/file.txt"),
|
||||
)
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().ramps.destructive.base)
|
||||
.child(error),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(self.render_footer(cx))
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
use gpui::{App, AppContext as _, SharedString, WeakEntity, Window};
|
||||
use gpui_native_kit::layout::dock::DockState;
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::repo::RepoDetailView;
|
||||
use crate::shared::panels;
|
||||
|
||||
/// An item of a repository to open from outside its detail panel.
|
||||
pub enum RepoItem {
|
||||
Issue(EventId),
|
||||
PullRequest(EventId),
|
||||
Patch,
|
||||
}
|
||||
|
||||
/// Opens a repository as a dock panel, keyed by its address.
|
||||
pub fn open_repo_panel(
|
||||
dock: &WeakEntity<DockState>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let id = SharedString::from(format!("repo:{addr}"));
|
||||
let addr = addr.clone();
|
||||
let hint = hint.cloned();
|
||||
let dock_for_build = dock.clone();
|
||||
|
||||
panels::open(
|
||||
dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
let addr = addr.clone();
|
||||
let hint = hint.clone();
|
||||
cx.new(|cx| RepoDetailView::new(dock_for_build.clone(), addr, hint, window, cx))
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens an issue or pull request using a store built here.
|
||||
pub fn open_repo_item(
|
||||
dock: &WeakEntity<DockState>,
|
||||
addr: &RepoAddr,
|
||||
hint: Option<&Announcement>,
|
||||
item: RepoItem,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let store = cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx));
|
||||
match item {
|
||||
RepoItem::Issue(issue_id) => {
|
||||
let id = SharedString::from(format!("issue:{issue_id}"));
|
||||
let store = store.clone();
|
||||
panels::open(
|
||||
dock,
|
||||
id,
|
||||
move |_, cx| {
|
||||
cx.new(|cx| {
|
||||
crate::issues::detail::IssueDetailView::new(store.clone(), issue_id, cx)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
RepoItem::PullRequest(pr_id) => {
|
||||
let id = SharedString::from(format!("pr:{pr_id}"));
|
||||
let dock_for_build = dock.clone();
|
||||
let store = store.clone();
|
||||
panels::open(
|
||||
dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
crate::pull_requests::PullRequestDetailView::new(
|
||||
dock_for_build.clone(),
|
||||
store.clone(),
|
||||
pr_id,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
RepoItem::Patch => {}
|
||||
}
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Axis, Context, Entity, IntoElement, ParentElement as _,
|
||||
Render, ScrollStrategy, SharedString, Styled as _, Task, UniformListScrollHandle, Window, div,
|
||||
px, uniform_list,
|
||||
};
|
||||
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
||||
use gpui_native_kit::clipboard::ClipboardButton;
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::data::tree::{Tree, TreeItem};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::layout::dock::Panel;
|
||||
use gpui_native_kit::layout::resizable::{Resizable, ResizableState};
|
||||
use gpui_native_kit::scrollbar::Scrollbar;
|
||||
use gpui_native_kit::spinner::Spinner;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff, Repo};
|
||||
use signed_state::RepoStore;
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::helpers::{DiffRow, diff_rows, render_diff_row};
|
||||
use crate::shared::tree::{TreeItemSeed, TreeRow, build_tree_items, collect_expanded, flatten};
|
||||
use crate::shared::widgets::{empty_state, repo_tab_avatar};
|
||||
|
||||
pub struct DiffPane {
|
||||
diff: Option<CommitDiff>,
|
||||
seeds: Vec<TreeItemSeed>,
|
||||
expanded: HashSet<String>,
|
||||
rows: Vec<TreeRow>,
|
||||
selected_file: Option<SharedString>,
|
||||
selected_index: Option<usize>,
|
||||
/// Rows of the selected file's diff, hunk headers and lines.
|
||||
diff_rows: Vec<DiffRow>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
split: Entity<ResizableState>,
|
||||
}
|
||||
|
||||
impl DiffPane {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
diff: None,
|
||||
seeds: Vec::new(),
|
||||
expanded: HashSet::new(),
|
||||
rows: Vec::new(),
|
||||
selected_file: None,
|
||||
selected_index: None,
|
||||
diff_rows: Vec::new(),
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
split: {
|
||||
let split = ResizableState::new(cx);
|
||||
split.update(cx, |split, cx| split.set_sizes([px(260.)], cx));
|
||||
split
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diff(&self) -> Option<&CommitDiff> {
|
||||
self.diff.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
|
||||
let mut paths: Vec<PathBuf> = diff
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| PathBuf::from(&file.path))
|
||||
.collect();
|
||||
paths.sort();
|
||||
self.seeds = build_tree_items(&paths);
|
||||
self.expanded = collect_expanded(&self.seeds);
|
||||
self.rows = flatten(&self.seeds, &self.expanded);
|
||||
|
||||
let first = diff
|
||||
.files
|
||||
.first()
|
||||
.map(|file| SharedString::from(file.path.as_str()));
|
||||
|
||||
self.selected_file = first.clone();
|
||||
self.selected_index = first
|
||||
.as_ref()
|
||||
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
||||
self.diff = Some(diff);
|
||||
|
||||
if let Some(path) = first {
|
||||
self.set_diff_rows(path.as_ref());
|
||||
} else {
|
||||
self.diff_rows.clear();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||
self.diff = None;
|
||||
self.seeds.clear();
|
||||
self.expanded.clear();
|
||||
self.rows.clear();
|
||||
self.selected_file = None;
|
||||
self.selected_index = None;
|
||||
self.diff_rows.clear();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.selected_index = self.rows.iter().position(|row| row.id == path);
|
||||
self.set_diff_rows(path);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_diff_rows(&mut self, path: &str) {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
|
||||
return;
|
||||
};
|
||||
self.diff_rows = diff_rows(file);
|
||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||
}
|
||||
|
||||
fn toggle_folder(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
if !self.expanded.remove(id) {
|
||||
self.expanded.insert(id.to_owned());
|
||||
}
|
||||
self.rows = flatten(&self.seeds, &self.expanded);
|
||||
self.selected_index = self
|
||||
.selected_file
|
||||
.as_ref()
|
||||
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let rows = self.rows.clone();
|
||||
let selected = self.selected_index;
|
||||
let view = cx.entity().downgrade();
|
||||
let change_view = view.clone();
|
||||
|
||||
v_flex()
|
||||
.h_full()
|
||||
.min_h_0()
|
||||
.p_2()
|
||||
.child(
|
||||
Tree::new("commit-diff-tree")
|
||||
.selected(selected)
|
||||
.items(move |_, _| {
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let item = TreeItem::new(row.id.clone(), row.label.clone())
|
||||
.depth(row.depth)
|
||||
.expandable(row.expandable)
|
||||
.expanded(row.expanded);
|
||||
|
||||
if row.expandable {
|
||||
item.icon(Icon::named(IconRole::Folder))
|
||||
} else {
|
||||
item.icon(Icon::named(IconRole::File))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.on_change(move |index, window, cx| {
|
||||
if let Some(index) = index
|
||||
&& let Err(error) = change_view.update(cx, |this, cx| {
|
||||
this.activate_row(index, window, cx);
|
||||
})
|
||||
{
|
||||
log::warn!("failed to activate diff tree row: {error}");
|
||||
}
|
||||
})
|
||||
.on_disclosure({
|
||||
let view = view;
|
||||
move |index, _, cx| {
|
||||
if let Err(error) = view.update(cx, |this, cx| {
|
||||
let id = this.rows.get(index).map(|row| row.id.clone());
|
||||
if let Some(id) = id {
|
||||
this.toggle_folder(&id, cx);
|
||||
}
|
||||
}) {
|
||||
log::warn!("failed to toggle diff folder: {error}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn activate_row(&mut self, index: usize, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(row) = self.rows.get(index) else {
|
||||
return;
|
||||
};
|
||||
let id = row.id.clone();
|
||||
let expandable = row.expandable;
|
||||
self.selected_index = Some(index);
|
||||
|
||||
if expandable {
|
||||
self.toggle_folder(&id, cx);
|
||||
} else {
|
||||
self.select_file(&id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(diff) = self.diff.as_ref() else {
|
||||
return empty_state("No changes");
|
||||
};
|
||||
let Some(path) = self.selected_file.clone() else {
|
||||
return if diff.files.is_empty() {
|
||||
empty_state("No files changed")
|
||||
} else {
|
||||
empty_state("Select a file")
|
||||
};
|
||||
};
|
||||
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
|
||||
return empty_state("File not found");
|
||||
};
|
||||
self.render_file_diff(file, cx)
|
||||
}
|
||||
|
||||
fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement {
|
||||
let status_label = match file.status {
|
||||
DiffStatus::Added => "A",
|
||||
DiffStatus::Modified => "M",
|
||||
DiffStatus::Deleted => "D",
|
||||
DiffStatus::Renamed => "R",
|
||||
DiffStatus::Copied => "C",
|
||||
};
|
||||
let status_color = match file.status {
|
||||
DiffStatus::Added => cx.theme().ramps.success.base,
|
||||
DiffStatus::Modified => cx.theme().ramps.info.base,
|
||||
DiffStatus::Deleted => cx.theme().ramps.destructive.base,
|
||||
DiffStatus::Renamed | DiffStatus::Copied => cx.theme().colors.muted_foreground,
|
||||
};
|
||||
let title = match &file.old_path {
|
||||
Some(old) => format!("{old} → {}", file.path),
|
||||
None => file.path.clone(),
|
||||
};
|
||||
|
||||
let body: AnyElement = if file.binary {
|
||||
empty_state("Diff not available")
|
||||
} else if file.hunks.is_empty() {
|
||||
empty_state("No content changes")
|
||||
} else {
|
||||
let hunks = file.hunks.clone();
|
||||
let rows = self.diff_rows.clone();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
uniform_list("commit-diff-rows", rows.len(), move |range, _, cx| {
|
||||
range
|
||||
.map(|ix| render_diff_row(&hunks, rows[ix], cx))
|
||||
.collect()
|
||||
})
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(
|
||||
"commit-diff-scroll",
|
||||
self.scroll_handle.clone(),
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_3()
|
||||
.h_9()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption2, cx)
|
||||
.text_color(status_color)
|
||||
.child(status_label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Caption2, cx)
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(title),
|
||||
)
|
||||
.when(!file.binary, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().ramps.success.base)
|
||||
.child(format!("+{}", file.insertions)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().ramps.destructive.base)
|
||||
.child(format!("-{}", file.deletions)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DiffPane {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let tree = self.render_tree_column(cx);
|
||||
let detail = self.render_detail_column(cx);
|
||||
|
||||
h_flex()
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.bg(cx.theme().colors.background)
|
||||
.child(
|
||||
Resizable::new("commit-diff-split", self.split.clone())
|
||||
.axis(Axis::Horizontal)
|
||||
.pane_with_min(tree, px(120.))
|
||||
.pane_with_min(detail, px(240.)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommitDiffView {
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
repo_name: SharedString,
|
||||
commit: FileCommit,
|
||||
/// The diff is being computed on a background task.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
pane: Entity<DiffPane>,
|
||||
/// Profile for the repository owner in the tab title.
|
||||
directory: Entity<ProfileDirectory>,
|
||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||
}
|
||||
|
||||
impl CommitDiffView {
|
||||
pub fn new(
|
||||
store: Entity<RepoStore>,
|
||||
worktree: PathBuf,
|
||||
repo_name: SharedString,
|
||||
commit_id: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let pane = cx.new(DiffPane::new);
|
||||
let directory = ProfileDirectory::new(cx);
|
||||
|
||||
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([owner], cx);
|
||||
});
|
||||
}
|
||||
|
||||
let weak = cx.entity().downgrade();
|
||||
window.defer(cx, move |window, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.load(window, cx)) {
|
||||
log::warn!("failed to load commit diff: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
worktree,
|
||||
repo_name,
|
||||
commit: FileCommit {
|
||||
id: commit_id,
|
||||
summary: String::new(),
|
||||
description: None,
|
||||
author: String::new(),
|
||||
time: 0,
|
||||
},
|
||||
loading: true,
|
||||
error: None,
|
||||
pane,
|
||||
directory,
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let worktree = self.worktree.clone();
|
||||
let id = self.commit.id.clone();
|
||||
|
||||
let task: Task<Result<(), anyhow::Error>> = cx.spawn_in(window, async move |this, cx| {
|
||||
let commit = cx
|
||||
.background_spawn({
|
||||
let worktree = worktree.clone();
|
||||
let id = id.clone();
|
||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
|
||||
})
|
||||
.await;
|
||||
let diff = cx
|
||||
.background_spawn({
|
||||
let worktree = worktree.clone();
|
||||
let id = id.clone();
|
||||
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.loading = false;
|
||||
if let Ok(Some(commit)) = commit {
|
||||
this.commit = commit;
|
||||
}
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let commit = &self.commit;
|
||||
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
|
||||
(
|
||||
diff.files.len(),
|
||||
diff.files.iter().map(|file| file.insertions).sum(),
|
||||
diff.files.iter().map(|file| file.deletions).sum(),
|
||||
)
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Headline, cx)
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child(SharedString::from(format!("{} committed", commit.author)))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(SharedString::from(&commit.id))
|
||||
.child(ClipboardButton::new("commit-id", &commit.id)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(commit.description.as_ref(), |this, description| {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child(SharedString::from(description)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.child(Badge::new("commit-files", format!("{files} files changed")))
|
||||
.when(insertions > 0, |this| {
|
||||
this.child(
|
||||
Badge::new("commit-insertions", format!("+ {insertions}"))
|
||||
.variant(BadgeVariant::Success),
|
||||
)
|
||||
})
|
||||
.when(deletions > 0, |this| {
|
||||
this.child(
|
||||
Badge::new("commit-deletions", format!("- {deletions}"))
|
||||
.variant(BadgeVariant::Danger),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for CommitDiffView {
|
||||
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||
Some(SharedString::from(format!(
|
||||
"{}/{}",
|
||||
self.repo_name, self.commit.id
|
||||
)))
|
||||
}
|
||||
|
||||
fn lead(&self, cx: &App) -> Option<AnyElement> {
|
||||
Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CommitDiffView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let body: AnyElement = if self.loading {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Spinner::new("commit-diff-loading"))
|
||||
.into_any_element()
|
||||
} else if let Some(error) = self.error.clone() {
|
||||
empty_state(error)
|
||||
} else {
|
||||
self.pane.clone().into_any_element()
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_h_0()
|
||||
.child(self.render_header(cx))
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _,
|
||||
PathPromptOptions, Render, Styled as _, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::layout::dock::DockState;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use settings::SettingsStore;
|
||||
use signed_state::{Backend, CheckoutsStore};
|
||||
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use crate::repo::actions::open_repo_panel;
|
||||
use crate::shared::dialogs::{close_global, open_global};
|
||||
use crate::shared::progress::{DialogProgress, error_row, push_steps_row};
|
||||
|
||||
/// Progress of the create-repository flow, so async results can be rendered.
|
||||
pub struct CreateRepoDialog {
|
||||
name_input: Entity<InputState>,
|
||||
desc_input: Entity<InputState>,
|
||||
folder_input: Entity<InputState>,
|
||||
relay_input: Entity<InputState>,
|
||||
state: Entity<DialogProgress>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
dock: WeakEntity<DockState>,
|
||||
}
|
||||
|
||||
impl CreateRepoDialog {
|
||||
pub fn new(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let settings = SettingsStore::global(cx);
|
||||
let default_folder = settings
|
||||
.read(cx)
|
||||
.settings()
|
||||
.create_repository
|
||||
.default_folder
|
||||
.clone()
|
||||
.unwrap_or_else(paths::desktop_dir);
|
||||
let grasp_settings = settings.read(cx).settings().grasp_servers.clone();
|
||||
|
||||
let name_input = InputState::new(cx);
|
||||
let desc_input = InputState::builder().multiline().build(cx);
|
||||
let folder_input = InputState::builder()
|
||||
.text(default_folder.to_string_lossy())
|
||||
.build(cx);
|
||||
folder_input.update(cx, |state, cx| state.set_read_only(true, cx));
|
||||
|
||||
let relay_input = InputState::new(cx);
|
||||
let state = cx.new(|_| DialogProgress::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
cx.new(|_| Self {
|
||||
name_input,
|
||||
desc_input,
|
||||
folder_input,
|
||||
relay_input,
|
||||
state,
|
||||
grasp_state,
|
||||
dock,
|
||||
})
|
||||
}
|
||||
|
||||
fn create(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.state.read(cx).busy {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = self.name_input.read(cx).value().trim().to_owned();
|
||||
let description = self.desc_input.read(cx).value().trim().to_owned();
|
||||
let folder = PathBuf::from(self.folder_input.read(cx).value().trim());
|
||||
let servers = self.grasp_state.read(cx).grasp_servers.clone();
|
||||
|
||||
if name.is_empty() {
|
||||
self.fail("Repository name is required", cx);
|
||||
return;
|
||||
}
|
||||
if servers.is_empty() {
|
||||
self.fail("Add at least one grasp server", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
self.fail("Sign in to create a repository", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let addr =
|
||||
signed_core::RepoAddr::new(user, signed_core::RepoAddr::identifier_from_name(&name));
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.begin_push(addr, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.create_repository(&name, &description, folder, servers, cx)
|
||||
});
|
||||
let state = self.state.clone();
|
||||
let dock = self.dock.clone();
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |_this, cx| match task.await {
|
||||
Ok((announcement, local_path)) => {
|
||||
cx.update_window(handle, |_, window, cx| {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |store, cx| {
|
||||
store.record(local_path.clone(), announcement.addr(), cx);
|
||||
});
|
||||
cx.open_with_system(&local_path);
|
||||
open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx);
|
||||
close_global(cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.fail(error.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn fail(&mut self, message: impl Into<gpui::SharedString>, cx: &mut Context<Self>) {
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.fail(message);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CreateRepoDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let name_input = self.name_input.clone();
|
||||
let desc_input = self.desc_input.clone();
|
||||
let folder_input = self.folder_input.clone();
|
||||
let relay_input = self.relay_input.clone();
|
||||
let state = self.state.clone();
|
||||
let grasp_state = self.grasp_state.clone();
|
||||
let action_state = state.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
Dialog::new("create-repo-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Create repository")
|
||||
.content(move |_window, cx| {
|
||||
let progress = state.read(cx);
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Publish a new repository to your grasp servers."),
|
||||
)
|
||||
.child(
|
||||
Field::new("repo-name", "Repository name")
|
||||
.description("Max 100 characters")
|
||||
.child(Input::new("repo-name-input", name_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("repo-description", "Description").child(
|
||||
Input::new("repo-description-input", desc_input.clone()).rows(3),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new("repo-folder", "Folder")
|
||||
.description("Where the repository's working copy is created.")
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(div().flex_1().child(Input::new(
|
||||
"repo-folder-input",
|
||||
folder_input.clone(),
|
||||
)))
|
||||
.child(
|
||||
Button::new("choose-folder")
|
||||
.icon(Icon::named(IconRole::Folder))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Choose folder"))
|
||||
.on_click({
|
||||
let folder_input = folder_input.clone();
|
||||
move |_, window, cx| {
|
||||
choose_folder(&folder_input, window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
|
||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
||||
el.child(steps)
|
||||
})
|
||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, cx| {
|
||||
let busy = action_state.read(cx).busy;
|
||||
let this = this.clone();
|
||||
|
||||
let create: AnyElement = Button::new("create-repository")
|
||||
.label("Create repository")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click(move |_, window, cx| {
|
||||
crate::shared::log_dropped(
|
||||
this.update(cx, |this, cx| this.create(window, cx)),
|
||||
);
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![create]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let store = SettingsStore::global(cx);
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Choose folder".into()),
|
||||
});
|
||||
let handle = window.window_handle();
|
||||
let folder_input = folder_input.clone();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = prompt.await
|
||||
&& let Some(path) = paths.pop()
|
||||
&& let Err(error) = cx.update_window(handle, |_, window, cx| {
|
||||
let text = path.to_string_lossy().to_string();
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.create_repository.default_folder = Some(path.clone());
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
folder_input.update(cx, |input, cx| input.set_value(text, window, cx));
|
||||
})
|
||||
{
|
||||
log::warn!("failed to apply selected folder: {error}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Opens the create-repository form.
|
||||
pub fn open(dock: WeakEntity<DockState>, window: &mut Window, cx: &mut App) {
|
||||
open_global(CreateRepoDialog::new(dock, window, cx), cx);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, SharedString,
|
||||
Styled as _, Window, div,
|
||||
};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::switch::Switch;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use nostr::prelude::RelayUrl;
|
||||
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
|
||||
use signed_state::Backend;
|
||||
|
||||
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
|
||||
#[derive(Default)]
|
||||
pub struct GraspServersState {
|
||||
/// Set while the user's kind `10317` grasp list loads.
|
||||
pub loading_servers: bool,
|
||||
pub grasp_servers: Vec<RelayUrl>,
|
||||
pub servers_enabled: bool,
|
||||
/// Error from the last grasp-server edit, such as an invalid relay URL.
|
||||
pub error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl GraspServersState {
|
||||
/// Defaults used until the user's grasp list loads and replaces them.
|
||||
pub fn new_default(settings: &GraspServersSettings) -> Self {
|
||||
let urls: Vec<String> = if settings.default_servers.is_empty() {
|
||||
DEFAULT_GRASP_SERVERS
|
||||
.iter()
|
||||
.map(|url| (*url).to_owned())
|
||||
.collect()
|
||||
} else {
|
||||
settings.default_servers.clone()
|
||||
};
|
||||
Self {
|
||||
loading_servers: true,
|
||||
servers_enabled: false,
|
||||
grasp_servers: urls
|
||||
.iter()
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Grasp servers section shared by the publish dialogs.
|
||||
pub fn grasp_servers_field(
|
||||
state: &Entity<GraspServersState>,
|
||||
relay_input: &Entity<InputState>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let state = state.clone();
|
||||
let relay_input = relay_input.clone();
|
||||
|
||||
let servers = state.read(cx).grasp_servers.clone();
|
||||
let loading_servers = state.read(cx).loading_servers;
|
||||
let servers_enabled = state.read(cx).servers_enabled;
|
||||
let error = state.read(cx).error.clone();
|
||||
|
||||
let add_state = state.clone();
|
||||
let add_input = relay_input.clone();
|
||||
let toggle_state = state.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(div().typeset(TextRole::Body, cx).child("Grasp servers"))
|
||||
.child(
|
||||
Switch::new("grasp-servers-toggle")
|
||||
.checked(servers_enabled)
|
||||
.on_change(move |checked, _, cx| {
|
||||
toggle_state.update(cx, |state, cx| {
|
||||
state.servers_enabled = checked;
|
||||
cx.notify();
|
||||
});
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when(servers_enabled, |section| {
|
||||
section
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Where the repository is hosted"),
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_1().children(
|
||||
servers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new("grasp-relay-input", relay_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Button::new("add-relay")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Add grasp server"))
|
||||
.on_click(move |_, window, cx| {
|
||||
add_relay(&add_state, &add_input, window, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.when(loading_servers, |section| {
|
||||
section.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Loading your grasp servers..."),
|
||||
)
|
||||
})
|
||||
.when_some(error, |section, error| {
|
||||
section.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().ramps.destructive.base)
|
||||
.child(error),
|
||||
)
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_server_row(
|
||||
ix: usize,
|
||||
relay: &RelayUrl,
|
||||
state: Entity<GraspServersState>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.bg(cx.theme().colors.muted)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.typeset(TextRole::Body, cx)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(server_host(relay)),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("remove-relay:{ix}")))
|
||||
.icon(Icon::named(IconRole::Close))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Remove"))
|
||||
.on_click(move |_, _, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
if ix < state.grasp_servers.len() {
|
||||
state.grasp_servers.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Accepts a bare host as well as a full URL.
|
||||
fn add_relay(
|
||||
state: &Entity<GraspServersState>,
|
||||
input: &Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match normalize_server(&value) {
|
||||
Some((_, relay)) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.error = None;
|
||||
if !state.grasp_servers.contains(&relay) {
|
||||
state.grasp_servers.push(relay);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
}
|
||||
None => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.error = Some(format!("Invalid grasp server URL: {value}").into());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the user's kind `10317` grasp list from the local database.
|
||||
pub fn load_user_grasp_servers(
|
||||
state: Entity<GraspServersState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let backend = Backend::global(cx);
|
||||
let (user, client) = {
|
||||
let backend = backend.read(cx);
|
||||
(backend.current_user(), backend.client())
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
state.update(cx, |state, _| state.loading_servers = false);
|
||||
return;
|
||||
};
|
||||
|
||||
let task =
|
||||
cx.background_spawn(
|
||||
async move { signed_state::user_grasp_list_servers(&client, user).await },
|
||||
);
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let result = task.await;
|
||||
|
||||
if let Err(error) = cx.update_window(handle, |_, _window, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.loading_servers = false;
|
||||
if let Ok(servers) = result
|
||||
&& !servers.is_empty()
|
||||
{
|
||||
state.grasp_servers = servers;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}) {
|
||||
log::warn!("failed to apply grasp servers: {error}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
|
||||
pub fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
|
||||
let text = if input.contains("://") {
|
||||
input.to_owned()
|
||||
} else {
|
||||
format!("wss://{input}")
|
||||
};
|
||||
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
|
||||
}
|
||||
|
||||
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
|
||||
pub fn server_host(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.domain()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(relay.to_string()))
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, WeakEntity, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::field::Field;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::select::{Select, SelectOption};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use settings::SettingsStore;
|
||||
use signed_core::RepoAddr;
|
||||
use signed_git::Repo;
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
use crate::repo::RepoDetailView;
|
||||
use crate::shared::dialogs::{close_global, open_global};
|
||||
use crate::shared::progress::{DialogProgress, error_row, push_steps_row};
|
||||
|
||||
pub struct InitRepoDialog {
|
||||
local_path: PathBuf,
|
||||
name_input: Entity<InputState>,
|
||||
desc_input: Entity<InputState>,
|
||||
relay_input: Entity<InputState>,
|
||||
state: Entity<DialogProgress>,
|
||||
grasp_state: Entity<GraspServersState>,
|
||||
branches: Vec<SharedString>,
|
||||
selected_branch: Option<SharedString>,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
}
|
||||
|
||||
impl InitRepoDialog {
|
||||
pub fn new(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let default_name = local_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let grasp_settings = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.grasp_servers
|
||||
.clone();
|
||||
|
||||
let state = cx.new(|_| DialogProgress::default());
|
||||
let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings));
|
||||
|
||||
let name_input = InputState::builder()
|
||||
.text(default_name)
|
||||
.read_only()
|
||||
.build(cx);
|
||||
|
||||
let desc_input = InputState::builder().multiline().build(cx);
|
||||
let relay_input = InputState::new(cx);
|
||||
|
||||
load_user_grasp_servers(grasp_state.clone(), window, cx);
|
||||
|
||||
let dialog = cx.new(|_| Self {
|
||||
local_path,
|
||||
name_input,
|
||||
desc_input,
|
||||
relay_input,
|
||||
state,
|
||||
grasp_state,
|
||||
branches: Vec::new(),
|
||||
selected_branch: None,
|
||||
view,
|
||||
});
|
||||
|
||||
load_branches(dialog.clone(), cx);
|
||||
dialog
|
||||
}
|
||||
|
||||
fn init_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.state.read(cx).busy {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = self.name_input.read(cx).value().trim().to_owned();
|
||||
let description = self.desc_input.read(cx).value().trim().to_owned();
|
||||
let servers = self.grasp_state.read(cx).grasp_servers.clone();
|
||||
let default_branch = self
|
||||
.selected_branch
|
||||
.as_ref()
|
||||
.map(|branch| branch.trim().to_owned())
|
||||
.filter(|branch| !branch.is_empty());
|
||||
|
||||
if name.is_empty() {
|
||||
self.fail("Repository name is required", cx);
|
||||
return;
|
||||
}
|
||||
if servers.is_empty() {
|
||||
self.fail("Add at least one grasp server", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
self.fail("Sign in to publish a repository", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name));
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.begin_push(addr, cx);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let local_path = self.local_path.clone();
|
||||
let task = backend.update(cx, |backend, cx| {
|
||||
backend.publish_local_repo(local_path, &name, &description, servers, default_branch, cx)
|
||||
});
|
||||
let state = self.state.clone();
|
||||
let view = self.view.clone();
|
||||
let handle = window.window_handle();
|
||||
|
||||
cx.spawn(async move |_this, cx| match task.await {
|
||||
Ok(announcement) => {
|
||||
cx.update_window(handle, |_, _window, cx| {
|
||||
close_global(cx);
|
||||
if let Some(view) = view.upgrade() {
|
||||
view.update(cx, |this, cx| this.apply_announcement(announcement, cx));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
state.update(cx, |state, cx| {
|
||||
state.fail(error.to_string());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn fail(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.state.update(cx, |state, cx| {
|
||||
state.fail(message);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InitRepoDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let name_input = self.name_input.clone();
|
||||
let desc_input = self.desc_input.clone();
|
||||
let relay_input = self.relay_input.clone();
|
||||
let state = self.state.clone();
|
||||
let grasp_state = self.grasp_state.clone();
|
||||
let action_state = state.clone();
|
||||
let this = cx.entity().downgrade();
|
||||
let folder = self.local_path.display().to_string();
|
||||
let branches = self.branches.clone();
|
||||
let selected_branch = self.selected_branch.clone();
|
||||
|
||||
Dialog::new("init-repo-dialog")
|
||||
.open(true)
|
||||
.min_width(px(520.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Initialize repository")
|
||||
.content(move |_, cx| {
|
||||
let progress = state.read(cx);
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Publish this local repository to Nostr."),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-name", "Repository name")
|
||||
.description("Max 100 characters")
|
||||
.child(Input::new("init-name-input", name_input.clone())),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-description", "Description").child(
|
||||
Input::new("init-description-input", desc_input.clone()).rows(3),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-folder", "Folder")
|
||||
.description("The local repository being published")
|
||||
.child(
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.bg(cx.theme().colors.muted)
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(folder.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Field::new("init-branch", "Default branch")
|
||||
.description("The local branch to announce as the default")
|
||||
.child(
|
||||
Select::new("init-branch-select")
|
||||
.options(
|
||||
branches
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|branch| SelectOption::new(branch.clone(), branch))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.selected(selected_branch.clone())
|
||||
.placeholder("Select branch"),
|
||||
),
|
||||
)
|
||||
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
|
||||
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
||||
el.child(steps)
|
||||
})
|
||||
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, cx| {
|
||||
let busy = action_state.read(cx).busy;
|
||||
let this = this.clone();
|
||||
|
||||
let init: AnyElement = Button::new("initialize-repository")
|
||||
.label("Initialize")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.loading(busy)
|
||||
.disabled(busy)
|
||||
.on_click(move |_, window, cx| {
|
||||
crate::shared::log_dropped(
|
||||
this.update(cx, |this, cx| this.init_repository(window, cx)),
|
||||
);
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![init]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_branches(dialog: Entity<InitRepoDialog>, cx: &mut App) {
|
||||
let local_path = dialog.read(cx).local_path.clone();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let loaded = cx
|
||||
.background_spawn(async move {
|
||||
let repo = Repo::open(&local_path)?;
|
||||
let current: Option<SharedString> = repo.current_branch().map(Into::into);
|
||||
Ok::<_, anyhow::Error>((repo.branches()?, current))
|
||||
})
|
||||
.await;
|
||||
|
||||
let Ok((branches, current)) = loaded else {
|
||||
log::warn!("failed to list branches for the default branch selector");
|
||||
return;
|
||||
};
|
||||
|
||||
dialog.update(cx, |dialog, cx| {
|
||||
dialog.branches = branches.into_iter().map(Into::into).collect();
|
||||
dialog.selected_branch = current;
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Opens the initialize form for `local_path`.
|
||||
pub fn open(
|
||||
local_path: PathBuf,
|
||||
view: WeakEntity<RepoDetailView>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
open_global(InitRepoDialog::new(local_path, view, window, cx), cx);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod about;
|
||||
pub mod create;
|
||||
pub mod grasp_servers;
|
||||
pub mod init;
|
||||
pub mod reset;
|
||||
@@ -1,99 +0,0 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use signed_state::RepoStore;
|
||||
|
||||
use crate::shared::dialogs::{close_global, open_global};
|
||||
|
||||
pub struct ResetRemoteDialog {
|
||||
store: Entity<RepoStore>,
|
||||
deleted: Vec<String>,
|
||||
}
|
||||
|
||||
impl ResetRemoteDialog {
|
||||
pub fn new(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) -> Entity<Self> {
|
||||
cx.new(|_| Self { store, deleted })
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ResetRemoteDialog {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let deleted = self.deleted.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
Dialog::new("reset-remote-dialog")
|
||||
.open(true)
|
||||
.min_width(px(480.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Reset remote to local")
|
||||
.content(move |_, cx| {
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(
|
||||
"Grasp servers are overwritten to match the local mirror. Refs that do not exist locally are deleted from the servers. This cannot be undone.",
|
||||
),
|
||||
)
|
||||
.child(if deleted.is_empty() {
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("No server refs will be deleted.")
|
||||
.into_any_element()
|
||||
} else {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(deleted.iter().map(|name| {
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.font_family(cx.theme().mono_font)
|
||||
.child(name.clone())
|
||||
}))
|
||||
.into_any_element()
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.actions(move |_, _| {
|
||||
let store = store.clone();
|
||||
let cancel: AnyElement = Button::new("cancel-reset-remote")
|
||||
.label("Cancel")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.on_click(|_, _, cx| close_global(cx))
|
||||
.into_any_element();
|
||||
|
||||
let confirm: AnyElement = Button::new("confirm-reset-remote")
|
||||
.label("Reset remote")
|
||||
.variant(ButtonVariant::Destructive)
|
||||
.on_click(move |_, _, cx| {
|
||||
let task = store.update(cx, |store, cx| store.reset_repository(cx));
|
||||
cx.spawn(async move |_cx| {
|
||||
if let Err(error) = task.await {
|
||||
log::error!("failed to reset remote: {error:#}");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
close_global(cx);
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
vec![cancel, confirm]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the reset confirmation listing the refs the reset deletes.
|
||||
pub fn open(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) {
|
||||
open_global(ResetRemoteDialog::new(store, deleted, cx), cx);
|
||||
}
|
||||
@@ -1,673 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, ClipboardItem, Context, IntoElement, ParentElement as _,
|
||||
SharedString, Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::clipboard::ClipboardButton;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::data::tabs::{Tab, Tabs};
|
||||
use gpui_native_kit::dropdown_menu::DropdownMenu;
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::menu::MenuItem;
|
||||
use gpui_native_kit::popover::Popover;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use signed_state::{Backend, Nip34Kind};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::actions;
|
||||
use super::dialogs;
|
||||
use super::helpers::{
|
||||
ShareTargets, bound_repo_label, clone_group, fork_row, nostr_clone_url, repo_display_name,
|
||||
};
|
||||
use crate::issues::IssuesView;
|
||||
use crate::pull_requests::{NewPullRequestView, PullRequestsView, SendPatchView};
|
||||
|
||||
impl RepoDetailView {
|
||||
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self
|
||||
.announcement(cx)
|
||||
.and_then(|announcement| announcement.upstream.as_ref())
|
||||
.and_then(|upstream| upstream.addr.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
actions::open_repo_panel(&self.dock, &addr, None, window, cx);
|
||||
}
|
||||
|
||||
/// Open the dialog that publishes the local repository to NIP-34.
|
||||
fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(local_path) = self.store.read(cx).path.clone() else {
|
||||
return;
|
||||
};
|
||||
dialogs::init::open(local_path, cx.entity().downgrade(), window, cx);
|
||||
}
|
||||
|
||||
fn open_pulls(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = SharedString::from(format!("pulls:{addr}"));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |_, cx| {
|
||||
cx.new(|cx| PullRequestsView::new(dock_for_build.clone(), store.clone(), cx))
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn open_new_pull_request(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = SharedString::from(format!("new-pr:{addr}"));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id.clone(),
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
NewPullRequestView::new(
|
||||
dock_for_build.clone(),
|
||||
id.clone(),
|
||||
store.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let id = SharedString::from(format!("send-patch:{addr}"));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id.clone(),
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
SendPatchView::new(
|
||||
dock_for_build.clone(),
|
||||
id.clone(),
|
||||
store.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_issues(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||
return;
|
||||
};
|
||||
let id = SharedString::from(format!("issues:{addr}"));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
let store = self.store.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |_, cx| cx.new(|cx| IssuesView::new(dock_for_build.clone(), store.clone(), cx)),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if self.store.read(cx).addr().is_none() {
|
||||
return self.render_local_header(cx);
|
||||
}
|
||||
|
||||
let store = self.store.read(cx);
|
||||
let pushing = store.pushing;
|
||||
let cloning = store.cloning;
|
||||
|
||||
let Some(announcement) = store.announcement.clone() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let nip05 = self
|
||||
.directory
|
||||
.read(cx)
|
||||
.profile(&announcement.owner, cx)
|
||||
.metadata()
|
||||
.nip05
|
||||
.clone()
|
||||
.filter(|nip05| !nip05.trim().is_empty());
|
||||
|
||||
let share = ShareTargets::from_announcement(&announcement);
|
||||
let nostr_url = nostr_clone_url(&announcement, nip05.as_deref());
|
||||
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
|
||||
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
|
||||
let git_commands: Vec<String> = announcement.clone_urls();
|
||||
|
||||
let name = repo_display_name(self.store.read(cx));
|
||||
let description = announcement.description();
|
||||
let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id));
|
||||
|
||||
let share_naddr = share.naddr.clone();
|
||||
let share_for_menu = share.clone();
|
||||
let announcement_for_menu = announcement.clone();
|
||||
let weak_share = cx.entity().downgrade();
|
||||
let weak_menu_open = cx.entity().downgrade();
|
||||
let weak_menu_items = cx.entity().downgrade();
|
||||
let weak_clone = cx.entity().downgrade();
|
||||
let weak_download = cx.entity().downgrade();
|
||||
let ngit_for_popover = ngit_command.clone();
|
||||
let nak_for_popover = nak_command.clone();
|
||||
let git_for_popover = git_commands.clone();
|
||||
|
||||
v_flex()
|
||||
.flex_none()
|
||||
.p_4()
|
||||
.w_full()
|
||||
.gap_6()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Headline, cx)
|
||||
.child(avatar)
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.line_clamp(2)
|
||||
.child(description),
|
||||
)
|
||||
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
|
||||
.child(
|
||||
h_flex()
|
||||
.mt_2()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption2, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child("Maintainers:"),
|
||||
)
|
||||
.child(self.render_maintainers(cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child({
|
||||
let weak_pulls = cx.entity().downgrade();
|
||||
let weak_pulls_open = cx.entity().downgrade();
|
||||
let _store_for_pulls = self.store.clone();
|
||||
let pulls_count = self.store.read(cx).pull_request_count();
|
||||
|
||||
DropdownMenu::new("pulls")
|
||||
.label(format!("Pull requests {pulls_count}"))
|
||||
.icon(Icon::named(CustomIconName::GitPullRequest))
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.open(self.pulls_menu_open)
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Err(error) = weak_pulls.update(cx, |this, cx| {
|
||||
this.open_pulls(window, cx);
|
||||
}) {
|
||||
log::warn!("failed to open pull requests: {error}");
|
||||
}
|
||||
})
|
||||
.on_open_change(move |open, _, cx| {
|
||||
if let Err(error) = weak_pulls_open.update(cx, |this, cx| {
|
||||
this.pulls_menu_open = open;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::warn!("failed to update pulls menu: {error}");
|
||||
}
|
||||
})
|
||||
.items({
|
||||
let weak_new = cx.entity().downgrade();
|
||||
let weak_patch = cx.entity().downgrade();
|
||||
move |_, _| {
|
||||
let weak_new = weak_new.clone();
|
||||
let weak_patch = weak_patch.clone();
|
||||
vec![
|
||||
MenuItem::new("New pull request").handler(move |window, cx| {
|
||||
crate::shared::log_dropped(weak_new.update(cx, |this, cx| {
|
||||
this.open_new_pull_request(window, cx);
|
||||
}));
|
||||
}),
|
||||
MenuItem::new("Send patch").handler(move |window, cx| {
|
||||
crate::shared::log_dropped(weak_patch.update(cx, |this, cx| {
|
||||
this.open_send_patch(window, cx);
|
||||
}));
|
||||
}),
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
.child({
|
||||
let weak_issues = cx.entity().downgrade();
|
||||
let weak_issues_open = cx.entity().downgrade();
|
||||
let store_for_issues = self.store.clone();
|
||||
let issues_count = self.store.read(cx).issue_count();
|
||||
|
||||
DropdownMenu::new("issues")
|
||||
.label(format!("Issues {issues_count}"))
|
||||
.icon(Icon::named(CustomIconName::GitIssueDone))
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.open(self.issues_menu_open)
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Err(error) = weak_issues.update(cx, |this, cx| {
|
||||
this.open_issues(window, cx);
|
||||
}) {
|
||||
log::warn!("failed to open issues: {error}");
|
||||
}
|
||||
})
|
||||
.on_open_change(move |open, _, cx| {
|
||||
if let Err(error) =
|
||||
weak_issues_open.update(cx, |this, cx| {
|
||||
this.issues_menu_open = open;
|
||||
cx.notify();
|
||||
})
|
||||
{
|
||||
log::warn!("failed to update issues menu: {error}");
|
||||
}
|
||||
})
|
||||
.items(move |_, _| {
|
||||
vec![MenuItem::new("New issue").handler({
|
||||
let store = store_for_issues.clone();
|
||||
move |_, cx| {
|
||||
crate::issues::new_issue::open(store.clone(), cx);
|
||||
}
|
||||
})]
|
||||
})
|
||||
})
|
||||
.child(
|
||||
DropdownMenu::new("share")
|
||||
.icon(Icon::named(IconRole::Copy))
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.open(self.share_open)
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
share_naddr.clone(),
|
||||
));
|
||||
})
|
||||
.on_open_change(move |open, _, cx| {
|
||||
if let Err(error) = weak_share.update(cx, |this, cx| {
|
||||
this.share_open = open;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::warn!("failed to update share menu: {error}");
|
||||
}
|
||||
})
|
||||
.items(move |_, _| share_for_menu.menu()),
|
||||
)
|
||||
.child(
|
||||
DropdownMenu::new("repo-menu")
|
||||
.icon(Icon::named(CustomIconName::EllipsisVertical))
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.loading(pushing)
|
||||
.disabled(pushing)
|
||||
.open(self.repo_menu_open)
|
||||
.on_open_change(move |open, _, cx| {
|
||||
if let Err(error) = weak_menu_open.update(cx, |this, cx| {
|
||||
this.repo_menu_open = open;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::warn!("failed to update repo menu: {error}");
|
||||
}
|
||||
})
|
||||
.items(move |_, cx| {
|
||||
let owner = Backend::global(cx)
|
||||
.read(cx)
|
||||
.current_user()
|
||||
== Some(announcement_for_menu.owner);
|
||||
|
||||
let mut items = vec![MenuItem::new("About").handler({
|
||||
let weak = weak_menu_items.clone();
|
||||
let announcement = announcement_for_menu.clone();
|
||||
move |_, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
dialogs::about::open(
|
||||
announcement.clone(),
|
||||
this.directory.clone(),
|
||||
cx,
|
||||
);
|
||||
}) {
|
||||
log::warn!("failed to open about: {error}");
|
||||
}
|
||||
}
|
||||
})];
|
||||
|
||||
if owner {
|
||||
items.push(MenuItem::new("Republish").handler({
|
||||
let weak = weak_menu_items.clone();
|
||||
move |window, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.push_repository(window, cx);
|
||||
}) {
|
||||
log::warn!("failed to republish: {error}");
|
||||
}
|
||||
}
|
||||
}));
|
||||
items.push(MenuItem::new("Reset remote to local").handler({
|
||||
let weak = weak_menu_items.clone();
|
||||
move |window, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.reset_remote(window, cx);
|
||||
}) {
|
||||
log::warn!("failed to reset remote: {error}");
|
||||
}
|
||||
}
|
||||
}));
|
||||
items.push(MenuItem::separator());
|
||||
items.push(
|
||||
MenuItem::new("Delete")
|
||||
.destructive(true)
|
||||
.handler({
|
||||
let weak = weak_menu_items.clone();
|
||||
move |window, cx| {
|
||||
if let Err(error) =
|
||||
weak.update(cx, |this, cx| {
|
||||
this.delete_repository(window, cx);
|
||||
})
|
||||
{
|
||||
log::warn!(
|
||||
"failed to delete repository: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Popover::new("clone")
|
||||
.open(self.clone_open)
|
||||
.on_change(move |open, _, cx| {
|
||||
if let Err(error) = weak_clone.update(cx, |this, cx| {
|
||||
this.clone_open = open;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::warn!("failed to update clone popover: {error}");
|
||||
}
|
||||
})
|
||||
.trigger(move |_, _| {
|
||||
Button::new("clone")
|
||||
.icon(Icon::named(CustomIconName::GitClone))
|
||||
.tooltip(Tooltip::text("Clone"))
|
||||
.loading(cloning)
|
||||
.disabled(cloning)
|
||||
.into_any_element()
|
||||
})
|
||||
.content(move |_, cx| {
|
||||
v_flex()
|
||||
.w(px(440.))
|
||||
.p_3()
|
||||
.gap_4()
|
||||
.child(clone_group(
|
||||
"Clone with ngit",
|
||||
"copy-ngit",
|
||||
&ngit_for_popover,
|
||||
cx,
|
||||
))
|
||||
.child(clone_group(
|
||||
"Clone with nak",
|
||||
"copy-nak",
|
||||
&nak_for_popover,
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(
|
||||
cx.theme().colors.muted_foreground,
|
||||
)
|
||||
.child("Grasp Servers"),
|
||||
)
|
||||
.when(git_for_popover.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.child("No git clone urls."),
|
||||
)
|
||||
})
|
||||
.children(git_for_popover.iter().enumerate().map(
|
||||
|(ix, command)| {
|
||||
ClipboardButton::new(
|
||||
SharedString::from(format!(
|
||||
"copy-git-{ix}"
|
||||
)),
|
||||
command.clone(),
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex().justify_end().child(
|
||||
Button::new("download")
|
||||
.icon(Icon::named(CustomIconName::GitClone))
|
||||
.label("Download")
|
||||
.on_click({
|
||||
let weak = weak_download.clone();
|
||||
move |_, window, cx| {
|
||||
if let Err(error) =
|
||||
weak.update(cx, |this, cx| {
|
||||
this.clone_to_folder(window, cx);
|
||||
})
|
||||
{
|
||||
log::warn!(
|
||||
"failed to clone: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(self.render_tabs(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_local_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let name = repo_display_name(self.store.read(cx));
|
||||
let path = self
|
||||
.store
|
||||
.read(cx)
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let avatar = Avatar::new(path.clone());
|
||||
|
||||
let bound = self.store.read(cx).nip34.clone();
|
||||
let action = match bound
|
||||
.as_ref()
|
||||
.filter(|binding| binding.kind == Nip34Kind::Initialized)
|
||||
{
|
||||
Some(binding) => bound_repo_label(binding, &self.directory, cx),
|
||||
None => Button::new("init")
|
||||
.icon(Icon::named(CustomIconName::Init))
|
||||
.label("Initialize on Nostr")
|
||||
.tooltip(Tooltip::text("Publish this repository to Nostr"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.open_init_dialog(window, cx);
|
||||
}))
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.pb_4()
|
||||
.w_full()
|
||||
.gap_6()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Headline, cx)
|
||||
.child(avatar)
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(path),
|
||||
),
|
||||
)
|
||||
.child(action),
|
||||
)
|
||||
.child(self.render_tabs(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let count = self.history.read(cx).commit_count();
|
||||
let muted = cx.theme().colors.muted_foreground;
|
||||
let weak = cx.entity().downgrade();
|
||||
let commits_label = match count {
|
||||
Some(count) => SharedString::from(format!("Commits ({count})")),
|
||||
None => SharedString::from("Commits"),
|
||||
};
|
||||
|
||||
Tabs::new("repo-tabs")
|
||||
.selected(self.active_tab)
|
||||
.tabs(move |_, _| {
|
||||
vec![
|
||||
Tab::new("files", "Files")
|
||||
.lead(Icon::named(CustomIconName::GitFile).color(muted)),
|
||||
Tab::new("commits", commits_label.clone())
|
||||
.lead(Icon::named(CustomIconName::GitCommit).color(muted)),
|
||||
]
|
||||
})
|
||||
.on_change(move |index, _, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.active_tab = index;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::warn!("failed to switch repo tab: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_maintainers(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(announcement) = self.announcement(cx) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let rest: Vec<_> = announcement
|
||||
.maintainers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| key != &announcement.owner && seen.insert(*key))
|
||||
.collect();
|
||||
|
||||
let owner = self.directory.read(cx).profile(&announcement.owner, cx);
|
||||
let owner_name = owner.name();
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner_name.clone())
|
||||
.picture(owner.picture())
|
||||
.small(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.whitespace_nowrap()
|
||||
.child(owner_name),
|
||||
),
|
||||
)
|
||||
.when(!rest.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(SharedString::from(format!("+{}", rest.len()))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
|
||||
px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::clipboard::ClipboardButton;
|
||||
use gpui_native_kit::combobox::ComboboxState;
|
||||
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::menu::MenuItem;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use metadata::ProfileDirectory;
|
||||
use nostr::nips::nip19::Nip19Coordinate;
|
||||
use nostr::prelude::{RelayUrl, ToBech32};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff, Repo};
|
||||
use signed_state::{Nip34Binding, RepoListStore, RepoStore};
|
||||
use utils::{middle_truncate, relative_time_secs};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::shared::tree::{TreeItemSeed, build_tree_items};
|
||||
|
||||
const GUTTER_WIDTH: f32 = 44.;
|
||||
pub const DIFF_ROW_HEIGHT: f32 = 20.;
|
||||
pub const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum DiffRow {
|
||||
Hunk {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
},
|
||||
Line {
|
||||
hunk: usize,
|
||||
line: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct RepoData {
|
||||
pub(super) tree: Vec<TreeItemSeed>,
|
||||
pub(super) entries: Vec<PathBuf>,
|
||||
pub(super) readme_path: Option<PathBuf>,
|
||||
pub(super) readme: Option<Vec<u8>>,
|
||||
pub(super) worktree: Option<PathBuf>,
|
||||
pub(super) branches: Vec<String>,
|
||||
pub(super) tags: Vec<String>,
|
||||
pub(super) current_branch: Option<String>,
|
||||
pub(super) head_commit: Option<FileCommit>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ShareTargets {
|
||||
/// NIP-19 `naddr1...` of the announcement, with its announced relays.
|
||||
pub(super) naddr: String,
|
||||
/// Hex ID of the announcement event itself.
|
||||
event_id: String,
|
||||
/// NIP-34 coordinate `30617:<pubkey>:<repo-id>`.
|
||||
coordinate: String,
|
||||
gitworkshop: String,
|
||||
ditto: String,
|
||||
}
|
||||
|
||||
impl ShareTargets {
|
||||
pub(super) fn from_announcement(announcement: &Announcement) -> Self {
|
||||
let addr = announcement.addr();
|
||||
let coordinate = addr.to_string();
|
||||
let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned())
|
||||
.to_bech32()
|
||||
.unwrap_or_else(|_| coordinate.clone());
|
||||
|
||||
Self {
|
||||
naddr: naddr.clone(),
|
||||
event_id: announcement
|
||||
.event_id
|
||||
.to_bech32()
|
||||
.unwrap_or_else(|_| announcement.event_id.to_string()),
|
||||
coordinate,
|
||||
gitworkshop: format!("https://gitworkshop.dev/{naddr}"),
|
||||
ditto: format!("https://ditto.pub/{naddr}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn menu(&self) -> Vec<MenuItem> {
|
||||
let items = [
|
||||
("GitWorkshop", truncate_naddr_link(&self.gitworkshop, 4)),
|
||||
("Ditto", truncate_naddr_link(&self.ditto, 4)),
|
||||
("Event ID", middle_truncate(&self.event_id, 10, 10)),
|
||||
("Coordinate", middle_truncate(&self.coordinate, 10, 10)),
|
||||
];
|
||||
|
||||
let values = [
|
||||
self.gitworkshop.clone(),
|
||||
self.ditto.clone(),
|
||||
self.event_id.clone(),
|
||||
self.coordinate.clone(),
|
||||
];
|
||||
|
||||
items
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.map(|((label, detail), value)| {
|
||||
MenuItem::new(SharedString::from(format!("{label} {detail}"))).handler(
|
||||
move |_, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(value.clone()));
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn clone_group(
|
||||
label: &str,
|
||||
id: &'static str,
|
||||
command: &SharedString,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.child(SharedString::from(label)),
|
||||
)
|
||||
.child(ClipboardButton::new(id, command.clone()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(super) fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
|
||||
let owner = announcement.owner;
|
||||
let user = nip05
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_string()));
|
||||
|
||||
let mut url = format!("nostr://{user}");
|
||||
if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) {
|
||||
url.push('/');
|
||||
url.push_str(hint);
|
||||
}
|
||||
url.push('/');
|
||||
url.push_str(&announcement.id);
|
||||
|
||||
SharedString::from(url)
|
||||
}
|
||||
|
||||
pub(super) fn fork_row(
|
||||
announcement: &Announcement,
|
||||
cx: &mut Context<RepoDetailView>,
|
||||
) -> Option<AnyElement> {
|
||||
let upstream = announcement.upstream.as_ref()?;
|
||||
|
||||
let (label, clickable) = match &upstream.addr {
|
||||
Some(addr) => {
|
||||
let name = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == *addr)
|
||||
.map(|a| {
|
||||
a.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(addr.identifier().to_owned()));
|
||||
(SharedString::from(format!("Forked from {name}")), true)
|
||||
}
|
||||
None => (SharedString::from(upstream.display().as_str()), false),
|
||||
};
|
||||
|
||||
let row = h_flex()
|
||||
.gap_1()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(Icon::named(CustomIconName::GitBranch).small())
|
||||
.child(div().whitespace_nowrap().text_ellipsis().child(label));
|
||||
|
||||
Some(if clickable {
|
||||
row.id("fork-upstream")
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_upstream(window, cx)))
|
||||
.into_any_element()
|
||||
} else {
|
||||
row.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn truncate_naddr_link(url: &str, tail: usize) -> String {
|
||||
let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else {
|
||||
return url.to_string();
|
||||
};
|
||||
if url.len() - end <= tail + 3 {
|
||||
return url.to_string();
|
||||
}
|
||||
format!("{}...{}", &url[..end], &url[url.len() - tail..])
|
||||
}
|
||||
|
||||
pub(super) fn load_repo_data(repo: &Repo) -> Result<RepoData, Error> {
|
||||
let snapshot = repo.snapshot()?;
|
||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
||||
let tree = build_tree_items(&snapshot.entries);
|
||||
|
||||
Ok(RepoData {
|
||||
tree,
|
||||
entries: snapshot.entries,
|
||||
readme_path: snapshot.readme_path,
|
||||
readme: snapshot.readme,
|
||||
worktree,
|
||||
branches: snapshot.branches,
|
||||
tags: snapshot.tags,
|
||||
current_branch: snapshot.current_branch,
|
||||
head_commit: snapshot.head_commit,
|
||||
})
|
||||
}
|
||||
|
||||
/// The announcement's name or ID for announced repositories, the directory name for local ones.
|
||||
pub fn repo_display_name(store: &RepoStore) -> SharedString {
|
||||
if store.addr().is_none() {
|
||||
return store
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|path| {
|
||||
SharedString::from(
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string()),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| {
|
||||
announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The owner and identifier a local repository is already bound to.
|
||||
pub(super) fn bound_repo_label(
|
||||
binding: &Nip34Binding,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx));
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.child("Initialized by")
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.when_some(profile, |this, profile| {
|
||||
let name = profile.name();
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().colors.foreground)
|
||||
.child(
|
||||
Avatar::new(profile.public_key())
|
||||
.name(name.clone())
|
||||
.picture(profile.picture())
|
||||
.small(),
|
||||
)
|
||||
.child(name),
|
||||
)
|
||||
})
|
||||
.when_some(binding.identifier.as_deref(), |this, ident| {
|
||||
this.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption2, cx)
|
||||
.child(SharedString::from(format!("/{ident}"))),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Computes a source hash from the given text.
|
||||
pub(super) fn source_hash(text: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub(super) fn preview_spinner() -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(gpui_native_kit::spinner::Spinner::new(
|
||||
"repo-preview-spinner",
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The kit grammar for a path; only four languages are supported.
|
||||
pub(super) fn code_language(path: &str) -> gpui_native_kit::editor::highlight::Language {
|
||||
use gpui_native_kit::editor::highlight::Language;
|
||||
match Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("rs") => Language::Rust,
|
||||
Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript,
|
||||
Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript,
|
||||
Some("py") => Language::Python,
|
||||
_ => Language::Plain,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a file path has a markdown extension.
|
||||
pub(super) fn is_markdown_path(path: &str) -> bool {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"md" | "markdown" | "mdown" | "mkdn"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
|
||||
rows.push(DiffRow::Hunk {
|
||||
old_start: hunk.old_start,
|
||||
old_lines: hunk.old_lines,
|
||||
new_start: hunk.new_start,
|
||||
new_lines: hunk.new_lines,
|
||||
});
|
||||
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
|
||||
hunk: hunk_ix,
|
||||
line,
|
||||
}));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
|
||||
match row {
|
||||
DiffRow::Hunk {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
} => div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font)
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.bg(cx.theme().colors.muted)
|
||||
.border_y_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(SharedString::from(format!(
|
||||
"@@ -{},{} +{},{} @@",
|
||||
old_start, old_lines, new_start, new_lines
|
||||
)))
|
||||
.into_any_element(),
|
||||
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
|
||||
let bg = match line.kind {
|
||||
DiffLineKind::Addition => Some(cx.theme().ramps.success.base.opacity(0.2)),
|
||||
DiffLineKind::Deletion => Some(cx.theme().ramps.destructive.base.opacity(0.2)),
|
||||
DiffLineKind::Context => None,
|
||||
};
|
||||
let gutter = cx.theme().colors.muted_foreground;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h(px(DIFF_ROW_HEIGHT))
|
||||
.font_family(cx.theme().mono_font)
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.when_some(bg, |this, bg| this.bg(bg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex_none()
|
||||
.pr_2()
|
||||
.text_right()
|
||||
.text_color(gutter)
|
||||
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.text_color(cx.theme().colors.foreground)
|
||||
.child(line.text.clone()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(crate) fn commit_row(
|
||||
ix: usize,
|
||||
commit: &FileCommit,
|
||||
on_click: impl Fn(&mut Window, &mut gpui::App) + 'static,
|
||||
cx: &gpui::App,
|
||||
) -> gpui::AnyElement {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.h(px(COMMIT_ROW_HEIGHT))
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors.border)
|
||||
.hover(|this| this.bg(cx.theme().colors.muted_hover))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.font_family(cx.theme().mono_font)
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(commit.id.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(commit.summary.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(commit.author.clone())
|
||||
.child(relative_time_secs(commit.time)),
|
||||
),
|
||||
)
|
||||
.on_click(move |_, window, cx| on_click(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(super) fn sync_selector(
|
||||
select: &Entity<ComboboxState<SharedString>>,
|
||||
cached: &mut Vec<SharedString>,
|
||||
items: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let items_changed = *cached != items;
|
||||
|
||||
let selection_changed = selected
|
||||
.as_ref()
|
||||
.is_some_and(|value| select.read(cx).selected() != Some(value));
|
||||
|
||||
if !items_changed && !selection_changed {
|
||||
return false;
|
||||
}
|
||||
|
||||
select.update(cx, |state, cx| {
|
||||
if items_changed {
|
||||
state.set_options(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| (item.clone(), item.clone()))
|
||||
.collect(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
if let Some(value) = selected
|
||||
&& (items_changed || selection_changed)
|
||||
{
|
||||
state.set_selected(Some(value), window, cx);
|
||||
}
|
||||
});
|
||||
*cached = items;
|
||||
|
||||
true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,101 +0,0 @@
|
||||
use gpui::{App, AppContext as _, Entity, SharedString, WeakEntity, Window};
|
||||
use gpui_native_kit::combobox::{Combobox, ComboboxState};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::helpers::sync_selector;
|
||||
|
||||
pub(super) struct RefSwitcher {
|
||||
pub(super) branch_select: Entity<ComboboxState<SharedString>>,
|
||||
pub(super) tag_select: Entity<ComboboxState<SharedString>>,
|
||||
ref_branches: Vec<SharedString>,
|
||||
ref_tags: Vec<SharedString>,
|
||||
pub(super) switching_ref: bool,
|
||||
}
|
||||
|
||||
impl RefSwitcher {
|
||||
pub(super) fn new(view: WeakEntity<RepoDetailView>, window: &mut Window, cx: &mut App) -> Self {
|
||||
let branch_view = view.clone();
|
||||
let branch_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
Combobox::new("branch-select", "Branch", Vec::new())
|
||||
.placeholder("Branch")
|
||||
.on_change(move |value, window, cx| {
|
||||
if let Some(name) = value
|
||||
&& let Err(error) = branch_view.update(cx, |this, cx| {
|
||||
this.switch_ref(super::RefKind::Branch, name, window, cx);
|
||||
})
|
||||
{
|
||||
log::warn!("failed to switch branch: {error}");
|
||||
}
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let tag_view = view;
|
||||
let tag_select = cx.new(|cx| {
|
||||
ComboboxState::new(
|
||||
Combobox::new("tag-select", "Tag", Vec::new())
|
||||
.placeholder("Tag")
|
||||
.on_change(move |value, window, cx| {
|
||||
if let Some(name) = value
|
||||
&& let Err(error) = tag_view.update(cx, |this, cx| {
|
||||
this.switch_ref(super::RefKind::Tag, name, window, cx);
|
||||
})
|
||||
{
|
||||
log::warn!("failed to switch tag: {error}");
|
||||
}
|
||||
}),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
branch_select,
|
||||
tag_select,
|
||||
ref_branches: Vec::new(),
|
||||
ref_tags: Vec::new(),
|
||||
switching_ref: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_branches(
|
||||
&mut self,
|
||||
branches: Vec<SharedString>,
|
||||
selected: Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(
|
||||
&self.branch_select,
|
||||
&mut self.ref_branches,
|
||||
branches,
|
||||
selected,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn set_tags(
|
||||
&mut self,
|
||||
tags: Vec<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx)
|
||||
}
|
||||
|
||||
pub(super) fn restore_selection(
|
||||
&self,
|
||||
select: &Entity<ComboboxState<SharedString>>,
|
||||
previous: &Option<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
select.update(cx, |state, cx| {
|
||||
state.set_selected(previous.clone(), window, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, PathPromptOptions,
|
||||
SharedString, Styled as _, Window, div, px,
|
||||
};
|
||||
use gpui_native_kit::button::Button;
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::input::{Input, InputState};
|
||||
use gpui_native_kit::select::{Select, SelectOption};
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use settings::{AppearanceMode, EventFetchingStrategy, SettingsStore};
|
||||
|
||||
use crate::repo::dialogs::grasp_servers::{normalize_server, server_host};
|
||||
|
||||
/// 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,
|
||||
) -> AnyElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.child(div().typeset(TextRole::Headline, cx).child(title.into()))
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(description.into()),
|
||||
),
|
||||
)
|
||||
.child(div().w(px(200.)).flex_shrink_0().child(control))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// A full-width settings block with title and subtitle in one header.
|
||||
pub fn setting_block(
|
||||
cx: &App,
|
||||
title: impl Into<SharedString>,
|
||||
description: impl Into<SharedString>,
|
||||
control: impl IntoElement,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().typeset(TextRole::Headline, cx).child(title.into()))
|
||||
.child(
|
||||
div()
|
||||
.typeset(TextRole::Caption, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(description.into()),
|
||||
),
|
||||
)
|
||||
.child(control)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub fn appearance_select(cx: &App) -> Select<AppearanceMode> {
|
||||
let current = SettingsStore::global(cx).read(cx).settings().appearance;
|
||||
Select::new("settings-appearance")
|
||||
.options(vec![
|
||||
SelectOption::new(AppearanceMode::System, "System"),
|
||||
SelectOption::new(AppearanceMode::Light, "Light"),
|
||||
SelectOption::new(AppearanceMode::Dark, "Dark"),
|
||||
])
|
||||
.selected(Some(current))
|
||||
.on_change(|value, _, cx| {
|
||||
let Some(mode) = value else {
|
||||
return;
|
||||
};
|
||||
SettingsStore::global(cx).update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.appearance = mode, cx);
|
||||
});
|
||||
crate::apply_appearance_mode(mode, cx);
|
||||
})
|
||||
}
|
||||
|
||||
pub fn event_fetching_select(cx: &App) -> Select<EventFetchingStrategy> {
|
||||
let current = SettingsStore::global(cx).read(cx).settings().event_fetching;
|
||||
Select::new("settings-event-fetching")
|
||||
.options(vec![
|
||||
SelectOption::new(EventFetchingStrategy::Curated, "Curated"),
|
||||
SelectOption::new(EventFetchingStrategy::Uncensored, "Uncensored"),
|
||||
])
|
||||
.selected(Some(current))
|
||||
.on_change(|value, _, cx| {
|
||||
let Some(strategy) = value else {
|
||||
return;
|
||||
};
|
||||
SettingsStore::global(cx).update(cx, |store, cx| {
|
||||
store.edit(|settings| settings.event_fetching = strategy, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
pub fn grasp_servers_editor(input: &Entity<InputState>, cx: &App) -> AnyElement {
|
||||
let servers = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.grasp_servers
|
||||
.default_servers
|
||||
.clone();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.children(servers.iter().enumerate().map(|(ix, server)| {
|
||||
let store = SettingsStore::global(cx);
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.bg(cx.theme().colors.muted)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.typeset(TextRole::Body, cx)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(display_server(server)),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("settings-remove-server:{ix}"))
|
||||
.icon(Icon::named(IconRole::Close))
|
||||
.variant(gpui_native_kit::button::ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Remove"))
|
||||
.on_click({
|
||||
let store = store.clone();
|
||||
move |_, _, cx| {
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.grasp_servers.default_servers.remove(ix);
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new("settings-grasp-input", input.clone())),
|
||||
)
|
||||
.child(
|
||||
Button::new("settings-add-server")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.variant(gpui_native_kit::button::ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Add grasp server"))
|
||||
.on_click({
|
||||
let input = input.clone();
|
||||
move |_, window, cx| add_server(&input, window, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn display_server(server: &str) -> SharedString {
|
||||
match normalize_server(server) {
|
||||
Some((_, relay)) => server_host(&relay),
|
||||
None => SharedString::from(server.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_server(input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some((normalized, _)) = normalize_server(&value) else {
|
||||
return;
|
||||
};
|
||||
SettingsStore::global(cx).update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
if !settings.grasp_servers.default_servers.contains(&normalized) {
|
||||
settings
|
||||
.grasp_servers
|
||||
.default_servers
|
||||
.push(normalized.clone());
|
||||
}
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
input.update(cx, |input, cx| input.set_value("", window, cx));
|
||||
}
|
||||
|
||||
pub fn scan_paths_editor(cx: &App) -> AnyElement {
|
||||
let scan_paths = SettingsStore::global(cx)
|
||||
.read(cx)
|
||||
.settings()
|
||||
.local_repos
|
||||
.scan_paths
|
||||
.clone();
|
||||
let store = SettingsStore::global(cx);
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.children(scan_paths.iter().enumerate().map(|(ix, path)| {
|
||||
let store = store.clone();
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h_8()
|
||||
.px_2()
|
||||
.bg(cx.theme().colors.muted)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.typeset(TextRole::Body, cx)
|
||||
.rounded(cx.theme().radius.control)
|
||||
.child(path.display().to_string()),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("settings-remove-path:{ix}"))
|
||||
.icon(Icon::named(IconRole::Close))
|
||||
.variant(gpui_native_kit::button::ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Remove"))
|
||||
.on_click(move |_, _, cx| {
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
settings.local_repos.scan_paths.remove(ix);
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}))
|
||||
.child(
|
||||
h_flex().justify_end().child(
|
||||
Button::new("settings-add-path")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.label("Add directory")
|
||||
.on_click(|_, _, cx| add_scan_path(cx)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn add_scan_path(cx: &mut App) {
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: true,
|
||||
prompt: Some("Choose directories".into()),
|
||||
});
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Ok(Some(paths))) = prompt.await {
|
||||
cx.update(|cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| {
|
||||
for path in paths {
|
||||
let path = path.to_string_lossy().to_string();
|
||||
if !settings
|
||||
.local_repos
|
||||
.scan_paths
|
||||
.iter()
|
||||
.any(|existing| existing.to_string_lossy() == path)
|
||||
{
|
||||
settings.local_repos.scan_paths.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn default_folder_selector(default_folder: &Entity<InputState>) -> AnyElement {
|
||||
let folder = default_folder.clone();
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.child(Input::new("settings-default-folder", folder.clone())),
|
||||
)
|
||||
.child(
|
||||
Button::new("settings-choose-folder")
|
||||
.icon(Icon::named(IconRole::Folder))
|
||||
.variant(gpui_native_kit::button::ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Choose folder"))
|
||||
.on_click(move |_, window, cx| {
|
||||
choose_default_folder(&folder, window, cx);
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn choose_default_folder(default_folder: &Entity<InputState>, window: &mut Window, cx: &mut App) {
|
||||
let handle = window.window_handle();
|
||||
let default_folder = default_folder.clone();
|
||||
let prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Choose default folder".into()),
|
||||
});
|
||||
cx.spawn(async move |cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = prompt.await
|
||||
&& let Some(path) = paths.pop()
|
||||
{
|
||||
let text = path.to_string_lossy().to_string();
|
||||
if let Err(error) = cx.update_window(handle, |_, window, cx| {
|
||||
let store = SettingsStore::global(cx);
|
||||
store.update(cx, |store, cx| {
|
||||
store.edit(
|
||||
|settings| settings.create_repository.default_folder = Some(path.clone()),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
default_folder.update(cx, |input, cx| input.set_value(text, window, cx));
|
||||
}) {
|
||||
log::warn!("failed to apply default folder: {error}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
mod helpers;
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
|
||||
Window, px,
|
||||
};
|
||||
use gpui_native_kit::common::v_flex;
|
||||
use gpui_native_kit::dialog::Dialog;
|
||||
use gpui_native_kit::input::InputState;
|
||||
use gpui_native_kit::separator::Separator;
|
||||
use helpers::{
|
||||
appearance_select, default_folder_selector, event_fetching_select, grasp_servers_editor,
|
||||
scan_paths_editor, setting_block, setting_row,
|
||||
};
|
||||
use settings::SettingsStore;
|
||||
|
||||
use crate::shared::dialogs::open_global;
|
||||
|
||||
pub struct SettingsDialog {
|
||||
grasp_input: Entity<InputState>,
|
||||
default_folder: Entity<InputState>,
|
||||
}
|
||||
|
||||
impl SettingsDialog {
|
||||
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||
let store = SettingsStore::global(cx);
|
||||
let settings = store.read(cx).settings().clone();
|
||||
let grasp_input = InputState::new(cx);
|
||||
|
||||
let folder = settings
|
||||
.create_repository
|
||||
.default_folder
|
||||
.clone()
|
||||
.unwrap_or_else(paths::desktop_dir);
|
||||
|
||||
let default_folder = InputState::builder()
|
||||
.text(folder.to_string_lossy())
|
||||
.read_only()
|
||||
.build(cx);
|
||||
|
||||
cx.new(|_| Self {
|
||||
grasp_input,
|
||||
default_folder,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SettingsDialog {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let grasp_input = self.grasp_input.clone();
|
||||
let default_folder = self.default_folder.clone();
|
||||
|
||||
Dialog::new("settings-dialog")
|
||||
.open(true)
|
||||
.min_width(px(650.))
|
||||
.height(px(560.))
|
||||
.on_change(|open, _, cx| {
|
||||
if !open {
|
||||
crate::shared::dialogs::close_global(cx);
|
||||
}
|
||||
})
|
||||
.title("Settings")
|
||||
.content(move |_, cx| {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Appearance",
|
||||
"Choose whether the app follows the system theme or uses a light/dark theme.",
|
||||
appearance_select(cx),
|
||||
))
|
||||
.child(Separator::horizontal())
|
||||
.child(setting_row(
|
||||
cx,
|
||||
"Event Fetching Strategy",
|
||||
"Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.",
|
||||
event_fetching_select(cx),
|
||||
))
|
||||
.child(Separator::horizontal())
|
||||
.child(setting_block(
|
||||
cx,
|
||||
"Grasp Servers",
|
||||
"Servers used to host your git repositories via the Grasp protocol",
|
||||
grasp_servers_editor(&grasp_input, cx),
|
||||
))
|
||||
.child(Separator::horizontal())
|
||||
.child(setting_block(
|
||||
cx,
|
||||
"Scan Directories",
|
||||
"Directories scanned for local git repositories.",
|
||||
scan_paths_editor(cx),
|
||||
))
|
||||
.child(setting_block(
|
||||
cx,
|
||||
"Default Folder",
|
||||
"The folder for newly created repositories.",
|
||||
default_folder_selector(&default_folder),
|
||||
))
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the settings dialog.
|
||||
pub fn open(cx: &mut App) {
|
||||
open_global(SettingsDialog::new(cx), cx);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyView, App, Context, Entity, Global, IntoElement, ParentElement as _, Render, Styled as _,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
struct GlobalDialogHost(Entity<DialogHost>);
|
||||
|
||||
impl Global for GlobalDialogHost {}
|
||||
|
||||
/// Owns the modal currently mounted at the window root.
|
||||
pub struct DialogHost {
|
||||
open: Option<AnyView>,
|
||||
}
|
||||
|
||||
impl DialogHost {
|
||||
pub fn new(_cx: &mut Context<Self>) -> Self {
|
||||
Self { open: None }
|
||||
}
|
||||
|
||||
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalDialogHost(entity));
|
||||
}
|
||||
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalDialogHost>().0.clone()
|
||||
}
|
||||
|
||||
pub fn open(&mut self, view: AnyView, cx: &mut Context<Self>) {
|
||||
self.open = Some(view);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn close(&mut self, cx: &mut Context<Self>) {
|
||||
self.open = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DialogHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.when_some(self.open.clone(), |host, view| host.child(view))
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the mounted modal with `view`.
|
||||
pub fn open_global(view: impl Into<AnyView>, cx: &mut App) {
|
||||
let host = DialogHost::global(cx);
|
||||
host.update(cx, |host, cx| host.open(view.into(), cx));
|
||||
}
|
||||
|
||||
/// Unmounts the current modal, if any.
|
||||
pub fn close_global(cx: &mut App) {
|
||||
let host = DialogHost::global(cx);
|
||||
host.update(cx, |host, cx| host.close(cx));
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
pub mod dialogs;
|
||||
pub mod panels;
|
||||
pub mod progress;
|
||||
pub mod tree;
|
||||
pub mod widgets;
|
||||
|
||||
/// Logs a discarded `Result` with its source location instead of dropping it silently.
|
||||
#[track_caller]
|
||||
pub fn log_dropped<T>(result: Result<T, anyhow::Error>) {
|
||||
if let Err(error) = result {
|
||||
let location = std::panic::Location::caller();
|
||||
log::warn!(
|
||||
"{}:{}: dropped result: {error}",
|
||||
location.file(),
|
||||
location.line()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use gpui::{App, Entity, SharedString, WeakEntity, Window};
|
||||
use gpui_native_kit::layout::dock::{DockState, Panel};
|
||||
|
||||
/// Registers a panel builder under `id` and opens it in the dock.
|
||||
pub fn open<P, F>(
|
||||
dock: &WeakEntity<DockState>,
|
||||
id: impl Into<SharedString>,
|
||||
build: F,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) where
|
||||
P: Panel + 'static,
|
||||
F: Fn(&mut Window, &mut App) -> Entity<P> + 'static,
|
||||
{
|
||||
let id = id.into();
|
||||
|
||||
if let Err(error) = dock.update(cx, |dock, cx| {
|
||||
dock.register_panel(id.clone(), build);
|
||||
dock.open_panel(&id, window, cx);
|
||||
}) {
|
||||
log::warn!("failed to open panel {id}: {error}");
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Context, IntoElement as _, ParentElement as _, SharedString, Styled as _,
|
||||
Subscription, div,
|
||||
};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::spinner::Spinner;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use signed_core::RepoAddr;
|
||||
use signed_state::{Backend, BackendEvent, PushStepStatus};
|
||||
|
||||
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DialogProgress {
|
||||
pub busy: bool,
|
||||
pub error: Option<SharedString>,
|
||||
// The repository whose push checklist is shown; `None` outside a publish.
|
||||
push_addr: Option<RepoAddr>,
|
||||
pub push_steps: Vec<PushStepStatus>,
|
||||
_push_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl DialogProgress {
|
||||
/// Marks an action as started, disabling the form and clearing the previous error.
|
||||
pub fn begin(&mut self) {
|
||||
self.busy = true;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// Starts a publish for the repository at `addr` and listens for its push steps.
|
||||
pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
self.begin();
|
||||
self.push_addr = Some(addr);
|
||||
self.push_steps.clear();
|
||||
|
||||
if self._push_subscription.is_none() {
|
||||
let backend = Backend::global(cx);
|
||||
self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let BackendEvent::PushProgress { addr, status } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if this.push_addr.as_ref() != Some(addr) {
|
||||
return;
|
||||
}
|
||||
|
||||
match this
|
||||
.push_steps
|
||||
.iter_mut()
|
||||
.find(|step| step.step() == status.step())
|
||||
{
|
||||
Some(step) => *step = status.clone(),
|
||||
None => this.push_steps.push(status.clone()),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks an action as failed, enabling the form and showing `message`.
|
||||
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||
self.busy = false;
|
||||
self.error = Some(message.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared error line under a dialog form, `None` when there is no error.
|
||||
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
||||
error.as_ref().map(|message| {
|
||||
div()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().ramps.destructive.base)
|
||||
.child(message.clone())
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
/// The push checklist of a running publish, `None` before any step is reported.
|
||||
pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option<AnyElement> {
|
||||
if steps.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rows = steps
|
||||
.iter()
|
||||
.map(|status| {
|
||||
let label = SharedString::from(status.step().label());
|
||||
|
||||
match status {
|
||||
PushStepStatus::Running(_) => h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(Spinner::new("push-step"))
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Done(_) => h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().colors.foreground)
|
||||
.child(
|
||||
Icon::named(IconRole::Check)
|
||||
.small()
|
||||
.color(cx.theme().colors.foreground),
|
||||
)
|
||||
.child(label)
|
||||
.into_any_element(),
|
||||
PushStepStatus::Failed(_, reason) => h_flex()
|
||||
.gap_2()
|
||||
.typeset(TextRole::Body, cx)
|
||||
.text_color(cx.theme().ramps.destructive.base)
|
||||
.child(
|
||||
Icon::named(IconRole::Close)
|
||||
.small()
|
||||
.color(cx.theme().ramps.destructive.base),
|
||||
)
|
||||
.child(SharedString::from(format!(
|
||||
"{}: {reason}",
|
||||
status.step().label()
|
||||
)))
|
||||
.into_any_element(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Some(v_flex().gap_1().children(rows).into_any_element())
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct TreeItemSeed {
|
||||
/// Path of the node, relative to the worktree root.
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub children: Vec<TreeItemSeed>,
|
||||
}
|
||||
|
||||
/// One visible row of the flattened tree.
|
||||
#[derive(Clone)]
|
||||
pub struct TreeRow {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub depth: usize,
|
||||
pub expandable: bool,
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
/// Flattens the seeds into the rows a flat tree renders, honoring `expanded`.
|
||||
pub fn flatten(seeds: &[TreeItemSeed], expanded: &HashSet<String>) -> Vec<TreeRow> {
|
||||
fn push(
|
||||
seed: &TreeItemSeed,
|
||||
depth: usize,
|
||||
expanded: &HashSet<String>,
|
||||
rows: &mut Vec<TreeRow>,
|
||||
) {
|
||||
let is_expanded = expanded.contains(&seed.id);
|
||||
rows.push(TreeRow {
|
||||
id: seed.id.clone(),
|
||||
label: seed.label.clone(),
|
||||
depth,
|
||||
expandable: !seed.children.is_empty(),
|
||||
expanded: is_expanded,
|
||||
});
|
||||
|
||||
if is_expanded {
|
||||
for child in &seed.children {
|
||||
push(child, depth + 1, expanded, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for seed in seeds {
|
||||
push(seed, 0, expanded, &mut rows);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Every folder id, for starting with the tree fully expanded.
|
||||
pub fn collect_expanded(seeds: &[TreeItemSeed]) -> HashSet<String> {
|
||||
fn push(seed: &TreeItemSeed, expanded: &mut HashSet<String>) {
|
||||
if !seed.children.is_empty() {
|
||||
expanded.insert(seed.id.clone());
|
||||
for child in &seed.children {
|
||||
push(child, expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut expanded = HashSet::new();
|
||||
for seed in seeds {
|
||||
push(seed, &mut expanded);
|
||||
}
|
||||
expanded
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat entry list sorted dirs-first.
|
||||
pub fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||
// Node indices by full path, so parents resolve in constant time while inserting.
|
||||
let mut index: HashMap<String, usize> = HashMap::new();
|
||||
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut path = String::new();
|
||||
for part in entry.components() {
|
||||
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||
path = if path.is_empty() {
|
||||
label.clone()
|
||||
} else {
|
||||
format!("{path}/{label}")
|
||||
};
|
||||
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||
let ix = nodes.len();
|
||||
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||
match parent {
|
||||
Some(parent) => nodes[parent].2.push(ix),
|
||||
None => roots.push(ix),
|
||||
}
|
||||
ix
|
||||
});
|
||||
parent = Some(ix);
|
||||
}
|
||||
}
|
||||
|
||||
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||
let (id, label, children) = &nodes[ix];
|
||||
TreeItemSeed {
|
||||
id: id.clone(),
|
||||
label: label.clone(),
|
||||
children: children
|
||||
.iter()
|
||||
.map(|child| assemble(*child, nodes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||
}
|
||||
|
||||
/// Sorted relative paths of a worktree snapshot.
|
||||
pub fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec<String> {
|
||||
let mut paths: Vec<String> = entries
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(rows: &[TreeRow]) -> Vec<(&str, usize)> {
|
||||
rows.iter()
|
||||
.map(|row| (row.id.as_str(), row.depth))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_folders_hide_children() {
|
||||
let seeds = build_tree_items(&[
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
]);
|
||||
|
||||
let rows = flatten(&seeds, &HashSet::new());
|
||||
|
||||
assert_eq!(ids(&rows), vec![("src", 0), ("README.md", 0)]);
|
||||
assert!(rows[0].expandable);
|
||||
assert!(!rows[0].expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_a_folder_shows_children_in_order() {
|
||||
let seeds = build_tree_items(&[
|
||||
PathBuf::from("src"),
|
||||
PathBuf::from("src/lib.rs"),
|
||||
PathBuf::from("src/views/mod.rs"),
|
||||
PathBuf::from("README.md"),
|
||||
]);
|
||||
let expanded = HashSet::from(["src".to_owned(), "src/views".to_owned()]);
|
||||
|
||||
let rows = flatten(&seeds, &expanded);
|
||||
|
||||
assert_eq!(
|
||||
ids(&rows),
|
||||
vec![
|
||||
("src", 0),
|
||||
("src/lib.rs", 1),
|
||||
("src/views", 1),
|
||||
("src/views/mod.rs", 2),
|
||||
("README.md", 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indices_map_to_row_ids() {
|
||||
let seeds = build_tree_items(&[
|
||||
PathBuf::from("docs"),
|
||||
PathBuf::from("docs/guide.md"),
|
||||
PathBuf::from("README.md"),
|
||||
]);
|
||||
let expanded = HashSet::from(["docs".to_owned()]);
|
||||
let rows = flatten(&seeds, &expanded);
|
||||
|
||||
assert_eq!(rows[0].id, "docs");
|
||||
assert_eq!(rows[1].id, "docs/guide.md");
|
||||
assert_eq!(rows[2].id, "README.md");
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::{
|
||||
AnyElement, App, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, div,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
||||
use gpui_native_kit::common::Sizable as _;
|
||||
use gpui_native_kit::empty_state::EmptyState;
|
||||
use gpui_native_kit::icons::Icon;
|
||||
use gpui_native_kit::theme::{ActiveTheme as _, TextRole, Typeset as _};
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_core::RepoStatus;
|
||||
use signed_state::RepoStore;
|
||||
|
||||
/// The status pill shown on issue and pull-request rows.
|
||||
pub fn status_badge(status: RepoStatus) -> Badge {
|
||||
let (label, variant, icon) = match status {
|
||||
RepoStatus::Open => ("Open", BadgeVariant::Neutral, CustomIconName::GitIssueOpen),
|
||||
RepoStatus::Closed => (
|
||||
"Closed",
|
||||
BadgeVariant::Danger,
|
||||
CustomIconName::GitIssueClosed,
|
||||
),
|
||||
RepoStatus::Draft => (
|
||||
"Draft",
|
||||
BadgeVariant::Warning,
|
||||
CustomIconName::GitIssueOngoing,
|
||||
),
|
||||
RepoStatus::Applied => (
|
||||
"Applied",
|
||||
BadgeVariant::Success,
|
||||
CustomIconName::GitIssueDone,
|
||||
),
|
||||
};
|
||||
|
||||
Badge::new(format!("status-{label}"), label)
|
||||
.variant(variant)
|
||||
.icon(Icon::named(icon))
|
||||
.tooltip(Tooltip::text(label))
|
||||
}
|
||||
|
||||
/// A centered empty-state message filling the available space.
|
||||
pub fn empty_state(message: impl Into<SharedString>) -> AnyElement {
|
||||
let message = message.into();
|
||||
gpui_native_kit::common::v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(EmptyState::new(message))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The profile avatar shown on a repository's panel tab.
|
||||
pub fn repo_tab_avatar(
|
||||
store: &RepoStore,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let seed = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| format!("{}:{}", announcement.owner, announcement.id))
|
||||
.or_else(|| store.addr().map(|addr| addr.to_string()))
|
||||
.or_else(|| {
|
||||
store
|
||||
.path
|
||||
.as_ref()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let owner = store
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|announcement| directory.read(cx).profile(&announcement.owner, cx));
|
||||
|
||||
let (name, picture) = owner
|
||||
.map(|profile| (Some(profile.name()), profile.picture()))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
let avatar = match name {
|
||||
Some(name) => Avatar::new(seed).name(name),
|
||||
None => Avatar::new(seed),
|
||||
};
|
||||
|
||||
avatar.picture(picture).small().into_any_element()
|
||||
}
|
||||
|
||||
/// A one-line muted label used by section headers.
|
||||
pub fn section_label(text: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.typeset(TextRole::Caption2, cx)
|
||||
.text_color(cx.theme().colors.muted_foreground)
|
||||
.child(text.into())
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, SharedString};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use metadata::ProfileDirectory;
|
||||
use signed_state::{Nip34Binding, ResolvedLocalRepo};
|
||||
|
||||
pub(super) fn pick_banner() -> SharedString {
|
||||
let num = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.subsec_nanos())
|
||||
.unwrap_or(1)
|
||||
% 3
|
||||
+ 1;
|
||||
format!("backgrounds/banner{num}.jpg").into()
|
||||
}
|
||||
|
||||
/// The platform that bound a repository, `"nak"` or `"ngit"`.
|
||||
pub(super) fn local_platform(binding: &Nip34Binding) -> Option<&'static str> {
|
||||
let signals = &binding.signals;
|
||||
|
||||
if signals.nip34_json || signals.nip34_grasp_remote || signals.nip34_state_refs {
|
||||
Some("nak")
|
||||
} else if signals.nostr_repo_config {
|
||||
Some("ngit")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound owner's avatar, falling back to the path's pixel avatar when unowned.
|
||||
pub(super) fn local_avatar(
|
||||
entry: &ResolvedLocalRepo,
|
||||
directory: &gpui::Entity<ProfileDirectory>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
|
||||
return Avatar::new(entry.path.to_string_lossy()).into_any_element();
|
||||
};
|
||||
|
||||
let profile = directory.read(cx).profile(&owner, cx);
|
||||
|
||||
Avatar::new(owner)
|
||||
.name(profile.name())
|
||||
.picture(profile.picture())
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, IntoElement, ObjectFit, ParentElement as _,
|
||||
SharedString, Styled as _, StyledImage as _, Subscription, WeakEntity, Window, div, img, px,
|
||||
};
|
||||
use gpui_native_kit::avatar::Avatar;
|
||||
use gpui_native_kit::badge::{Badge, BadgeVariant};
|
||||
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||
use gpui_native_kit::common::{h_flex, v_flex};
|
||||
use gpui_native_kit::data::sidebar::{Sidebar, SidebarEntry};
|
||||
use gpui_native_kit::icons::{Icon, IconRole};
|
||||
use gpui_native_kit::layout::dock::DockState;
|
||||
use gpui_native_kit::menu::{Menu, MenuItem};
|
||||
use gpui_native_kit::theme::ActiveTheme as _;
|
||||
use gpui_native_kit::tooltip::Tooltip;
|
||||
use helpers::{local_avatar, local_platform, pick_banner};
|
||||
use metadata::{Profile, ProfileDirectory};
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Kind, RepoListStore,
|
||||
ResolvedLocalRepo, SyncStatusDirectory,
|
||||
};
|
||||
|
||||
use crate::explore::ExploreView;
|
||||
use crate::inbox::InboxView;
|
||||
use crate::repo::dialogs;
|
||||
use crate::shared::log_dropped;
|
||||
use crate::{identity, settings};
|
||||
|
||||
mod helpers;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SidebarSlot {
|
||||
Inbox,
|
||||
Browse,
|
||||
Search,
|
||||
Header,
|
||||
Repo(usize),
|
||||
Local(usize),
|
||||
}
|
||||
|
||||
pub struct SidebarView {
|
||||
weak: WeakEntity<Self>,
|
||||
dock: WeakEntity<DockState>,
|
||||
banner: SharedString,
|
||||
scanning: bool,
|
||||
/// User's announced repositories.
|
||||
announcements: Arc<Vec<Announcement>>,
|
||||
/// Local repositories found by the scan that are not announced yet.
|
||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||
/// Unpushed commit counts per announced repository.
|
||||
unpushed: HashMap<RepoAddr, usize>,
|
||||
/// Profiles rendered by the sidebar.
|
||||
directory: gpui::Entity<ProfileDirectory>,
|
||||
/// Sync statuses for the announced repositories.
|
||||
sync_directory: gpui::Entity<SyncStatusDirectory>,
|
||||
user_menu_open: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SidebarView {
|
||||
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let repos = RepoListStore::global(cx);
|
||||
let local = LocalReposStore::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||
|
||||
if signer_required {
|
||||
this.banner = pick_banner();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
if this.refresh(cx) || signer_changed {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// Push statuses are recomputed in the background, so only the badge counts change.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
if this.refresh_unpushed(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
weak: cx.entity().downgrade(),
|
||||
dock,
|
||||
banner: pick_banner(),
|
||||
announcements: Arc::new(Vec::new()),
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
directory: ProfileDirectory::new(cx),
|
||||
sync_directory: SyncStatusDirectory::new(cx),
|
||||
user_menu_open: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let backend = Backend::global(cx);
|
||||
let user = backend.read(cx).current_user();
|
||||
|
||||
let (announcements, local_repos, scanning) = {
|
||||
let repo_list = RepoListStore::global(cx).read(cx);
|
||||
|
||||
let announcements = user
|
||||
.as_ref()
|
||||
.map(|user| repo_list.announcements_of(user))
|
||||
.unwrap_or_default();
|
||||
|
||||
let local = LocalReposStore::global(cx).read(cx);
|
||||
let local_repos =
|
||||
LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements);
|
||||
|
||||
(announcements, local_repos, local.scanning)
|
||||
};
|
||||
|
||||
let announcements_changed = *self.announcements != announcements;
|
||||
let local_changed = *self.local_repos != local_repos;
|
||||
let scanning_changed = self.scanning != scanning;
|
||||
|
||||
self.announcements = Arc::new(announcements);
|
||||
self.local_repos = Arc::new(local_repos);
|
||||
self.scanning = scanning;
|
||||
|
||||
let mut profiles: Vec<PublicKey> = user.into_iter().collect();
|
||||
let owners: Vec<PublicKey> = self
|
||||
.local_repos
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.nip34
|
||||
.as_ref()
|
||||
.and_then(|binding| binding.owner)
|
||||
.filter(|owner| !profiles.contains(owner))
|
||||
})
|
||||
.collect();
|
||||
|
||||
profiles.extend(owners);
|
||||
|
||||
self.directory.update(cx, |directory, cx| {
|
||||
directory.track(profiles, cx);
|
||||
});
|
||||
|
||||
let addrs: Vec<RepoAddr> = self
|
||||
.announcements
|
||||
.iter()
|
||||
.map(|announcement| announcement.addr())
|
||||
.collect();
|
||||
|
||||
self.sync_directory.update(cx, |directory, cx| {
|
||||
directory.track(addrs, cx);
|
||||
});
|
||||
|
||||
if announcements_changed {
|
||||
self.request_push_watches(cx);
|
||||
self.unpushed.clear();
|
||||
}
|
||||
|
||||
announcements_changed || local_changed || scanning_changed
|
||||
}
|
||||
|
||||
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = checkouts.read(cx).unpushed(&addr);
|
||||
if count > 0 {
|
||||
unpushed.insert(addr, count);
|
||||
}
|
||||
}
|
||||
|
||||
if unpushed == self.unpushed {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.unpushed = unpushed;
|
||||
true
|
||||
}
|
||||
|
||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
for announcement in self.announcements.iter() {
|
||||
checkouts.request_push_statuses(&announcement.addr(), cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Renders the sidebar for the current identity state.
|
||||
pub fn build(&self, cx: &App) -> Sidebar {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
if backend.read(cx).current_user().is_none() {
|
||||
return Sidebar::new("signed-sidebar").content(self.render_sign_in(cx));
|
||||
}
|
||||
|
||||
let weak = self.weak.clone();
|
||||
let announcements = self.announcements.clone();
|
||||
let local_repos = self.local_repos.clone();
|
||||
let unpushed = self.unpushed.clone();
|
||||
let directory = self.directory.clone();
|
||||
let sync_directory = self.sync_directory.clone();
|
||||
|
||||
Sidebar::new("signed-sidebar")
|
||||
.transparent(true)
|
||||
.footer(self.render_footer(cx))
|
||||
.entries(move |_, cx| {
|
||||
let muted = cx.theme().colors.muted_foreground;
|
||||
|
||||
let mut entries = vec![
|
||||
SidebarEntry::row("inbox", "Inbox")
|
||||
.prefix(Icon::named(CustomIconName::Inbox).color(muted)),
|
||||
SidebarEntry::row("browse", "Browse")
|
||||
.prefix(Icon::named(CustomIconName::Globe).color(muted)),
|
||||
SidebarEntry::row("search", "Search")
|
||||
.prefix(Icon::named(IconRole::Search).color(muted)),
|
||||
SidebarEntry::header("Repositories"),
|
||||
];
|
||||
|
||||
for announcement in announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let owner = directory.read(cx).profile(&announcement.owner, cx);
|
||||
|
||||
let count = unpushed.get(&addr).copied().unwrap_or(0);
|
||||
let unsynced = sync_directory.read(cx).unsynced(&addr, cx);
|
||||
|
||||
let name = announcement
|
||||
.name
|
||||
.as_deref()
|
||||
.map(SharedString::from)
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||
|
||||
let mut row = SidebarEntry::row(format!("repo:{}", announcement.id), name)
|
||||
.prefix(
|
||||
Avatar::new(announcement.owner)
|
||||
.name(owner.name())
|
||||
.picture(owner.picture()),
|
||||
);
|
||||
|
||||
if count > 0 || unsynced > 0 {
|
||||
let mut badges = h_flex().gap_1();
|
||||
if count > 0 {
|
||||
badges = badges.child(
|
||||
Badge::new(format!("unpushed:{addr}"), count.to_string())
|
||||
.variant(BadgeVariant::Warning),
|
||||
);
|
||||
}
|
||||
if unsynced > 0 {
|
||||
badges = badges.child(
|
||||
Badge::new(format!("unsynced:{addr}"), unsynced.to_string())
|
||||
.variant(BadgeVariant::Accent),
|
||||
);
|
||||
}
|
||||
row = row.suffix(badges);
|
||||
}
|
||||
|
||||
entries.push(row);
|
||||
}
|
||||
|
||||
for entry in local_repos.iter() {
|
||||
let suffix: AnyElement = match entry.nip34.as_ref() {
|
||||
Some(binding) => {
|
||||
let (label, note) = match binding.kind {
|
||||
Nip34Kind::Initialized => {
|
||||
let platform = local_platform(binding).unwrap_or("Grasp");
|
||||
(platform, "Initialized for NIP-34")
|
||||
}
|
||||
Nip34Kind::Cloned => ("Cloned", "Cloned but not initialized"),
|
||||
Nip34Kind::ToolingOnly => ("Tooling", "Grasp tooling only"),
|
||||
};
|
||||
Badge::new(format!("lbadge:{}", entry.path.display()), label)
|
||||
.tooltip(Tooltip::text(note))
|
||||
.into_any_element()
|
||||
}
|
||||
None => Badge::new(format!("un-{}", entry.path.display()), "Unpublished")
|
||||
.variant(BadgeVariant::Outline)
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
entries.push(
|
||||
SidebarEntry::row(format!("lrepo:{}", entry.path.display()), entry.name())
|
||||
.prefix(local_avatar(entry, &directory, cx))
|
||||
.suffix(suffix),
|
||||
);
|
||||
}
|
||||
|
||||
debug_assert_eq!(
|
||||
entries.len(),
|
||||
slots(announcements.len(), local_repos.len()).len()
|
||||
);
|
||||
entries
|
||||
})
|
||||
.on_change(move |index, window, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| this.activate(index, window, cx)) {
|
||||
log::warn!("failed to activate sidebar row: {error}");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn activate(&mut self, index: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(index) = index else {
|
||||
return;
|
||||
};
|
||||
let Some(slot) = slots(self.announcements.len(), self.local_repos.len())
|
||||
.get(index)
|
||||
.copied()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
match slot {
|
||||
SidebarSlot::Inbox => self.open_inbox(window, cx),
|
||||
SidebarSlot::Browse | SidebarSlot::Search => self.open_explore(window, cx),
|
||||
SidebarSlot::Header => {}
|
||||
SidebarSlot::Repo(ix) => {
|
||||
if let Some(announcement) = self.announcements.get(ix).cloned() {
|
||||
let addr = announcement.addr();
|
||||
crate::repo::actions::open_repo_panel(
|
||||
&self.dock,
|
||||
&addr,
|
||||
Some(&announcement),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
SidebarSlot::Local(ix) => {
|
||||
if let Some(entry) = self.local_repos.get(ix).cloned() {
|
||||
self.open_local_entry(entry, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A local repository opens as the announced repository when its binding matches one.
|
||||
fn open_local_entry(
|
||||
&mut self,
|
||||
entry: ResolvedLocalRepo,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let ResolvedLocalRepo {
|
||||
path,
|
||||
nip34,
|
||||
announcement,
|
||||
} = entry;
|
||||
|
||||
if let Some(announcement) = announcement {
|
||||
let id = SharedString::from(format!("repo:{}", announcement.addr()));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
crate::repo::RepoDetailView::new_local_announced(
|
||||
dock_for_build.clone(),
|
||||
announcement.clone(),
|
||||
path.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = SharedString::from(format!("repo-local:{}", path.display()));
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
id,
|
||||
move |window, cx| {
|
||||
cx.new(|cx| {
|
||||
crate::repo::RepoDetailView::new_local(
|
||||
dock_for_build.clone(),
|
||||
path.clone(),
|
||||
nip34.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_footer(&self, _cx: &App) -> impl IntoElement {
|
||||
let guide_weak = self.weak.clone();
|
||||
|
||||
v_flex()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("guide")
|
||||
.icon(IconRole::Info)
|
||||
.label("Guide")
|
||||
.side(gpui_native_kit::button::ButtonSide::Left)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| {
|
||||
log_dropped(guide_weak.update(cx, |this, cx| {
|
||||
this.open_explore(window, cx);
|
||||
}));
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("settings")
|
||||
.icon(IconRole::Settings)
|
||||
.label("Settings")
|
||||
.side(gpui_native_kit::button::ButtonSide::Left)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(|_, _, cx| {
|
||||
settings::open(cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_inbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
"inbox",
|
||||
move |_, cx| cx.new(|cx| InboxView::new(dock_for_build.clone(), cx)),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let dock = self.dock.clone();
|
||||
let dock_for_build = dock.clone();
|
||||
|
||||
crate::shared::panels::open(
|
||||
&dock,
|
||||
"explore",
|
||||
move |_window, cx| cx.new(|cx| ExploreView::new(dock_for_build.clone(), cx)),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Err(error) = self
|
||||
.dock
|
||||
.update(cx, |dock, cx| dock.open_panel(id, window, cx))
|
||||
{
|
||||
log::warn!("failed to open panel {id}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_titlebar(&self, cx: &App) -> Option<AnyElement> {
|
||||
let profile = Backend::global(cx)
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.map(|public_key| self.directory.read(cx).profile(&public_key, cx))?;
|
||||
|
||||
Some(
|
||||
self.render_user(&profile, self.scanning, cx)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_user(&self, profile: &Profile, scanning: bool, cx: &App) -> impl IntoElement {
|
||||
let name = profile.name();
|
||||
let picture = profile.picture();
|
||||
let public_key = profile.public_key();
|
||||
let weak = self.weak.clone();
|
||||
let weak_for_open = self.weak.clone();
|
||||
let muted = cx.theme().colors.muted_foreground;
|
||||
let dock = self.dock.clone();
|
||||
|
||||
let menu = Menu::new("user-menu")
|
||||
.open(self.user_menu_open)
|
||||
.on_change({
|
||||
let weak = weak_for_open.clone();
|
||||
move |open, _, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.user_menu_open = open;
|
||||
cx.notify();
|
||||
}) {
|
||||
log::warn!("failed to update user menu: {error}");
|
||||
}
|
||||
}
|
||||
})
|
||||
.trigger({
|
||||
let name = name.clone();
|
||||
let picture = picture.clone();
|
||||
move |_, _| {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.child(
|
||||
Avatar::new(public_key)
|
||||
.name(name.clone())
|
||||
.picture(picture.clone()),
|
||||
)
|
||||
.child(
|
||||
Icon::named(IconRole::ChevronDown)
|
||||
.size(px(12.))
|
||||
.color(muted),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
})
|
||||
.items(move |_, _| {
|
||||
let profile_weak = weak.clone();
|
||||
let relays_weak = weak.clone();
|
||||
|
||||
vec![
|
||||
MenuItem::new("View Profile").handler(move |window, cx| {
|
||||
if let Err(error) = profile_weak.update(cx, |this, cx| {
|
||||
this.open_panel("profile", window, cx);
|
||||
}) {
|
||||
log::warn!("failed to open profile: {error}");
|
||||
}
|
||||
}),
|
||||
MenuItem::new("View Relays").handler(move |window, cx| {
|
||||
if let Err(error) = relays_weak.update(cx, |this, cx| {
|
||||
this.open_panel("relays", window, cx);
|
||||
}) {
|
||||
log::warn!("failed to open relays: {error}");
|
||||
}
|
||||
}),
|
||||
MenuItem::separator(),
|
||||
MenuItem::new("Exit")
|
||||
.destructive(true)
|
||||
.handler(|_, cx| identity::helpers::open_exit(cx)),
|
||||
]
|
||||
});
|
||||
|
||||
h_flex().w_full().justify_between().child(menu).child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("rescan-repos")
|
||||
.icon(Icon::named(IconRole::Loader))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.loading(scanning)
|
||||
.disabled(scanning)
|
||||
.tooltip(Tooltip::text("Rescan for local repositories"))
|
||||
.on_click(move |_, _, cx| {
|
||||
let local_repos = LocalReposStore::global(cx);
|
||||
local_repos.update(cx, |store, cx| store.rescan(cx));
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("add-repo")
|
||||
.icon(Icon::named(IconRole::Plus))
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(Tooltip::text("Create repository"))
|
||||
.on_click({
|
||||
let dock = dock.clone();
|
||||
move |_, window, cx| {
|
||||
dialogs::create::open(dock.clone(), window, cx);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Shown while no identity is signed in.
|
||||
fn render_sign_in(&self, _cx: &App) -> impl IntoElement {
|
||||
let weak = self.weak.clone();
|
||||
let import_weak = self.weak.clone();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.child(
|
||||
div().absolute().inset_0().child(
|
||||
img(self.banner.clone())
|
||||
.size_full()
|
||||
.object_fit(ObjectFit::Cover),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_end()
|
||||
.p_4()
|
||||
.gap_4()
|
||||
.child(img("backgrounds/headline.png").max_w_48())
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.w_full()
|
||||
.child(Button::new("onboarding").label("Join now").on_click(
|
||||
move |_, window, cx| {
|
||||
if let Err(error) = weak.update(cx, |this, cx| {
|
||||
this.open_panel("welcome", window, cx);
|
||||
identity::helpers::open_onboarding(cx);
|
||||
}) {
|
||||
log::warn!("failed to open onboarding: {error}");
|
||||
}
|
||||
},
|
||||
))
|
||||
.child(
|
||||
Button::new("import")
|
||||
.label("Import identity")
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.on_click(move |_, _, cx| {
|
||||
if let Err(error) = import_weak
|
||||
.update(cx, |_, cx| identity::helpers::open_import(cx))
|
||||
{
|
||||
log::warn!("failed to open import: {error}");
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn slots(announcements: usize, local_repos: usize) -> Vec<SidebarSlot> {
|
||||
let mut slots = vec![
|
||||
SidebarSlot::Inbox,
|
||||
SidebarSlot::Browse,
|
||||
SidebarSlot::Search,
|
||||
SidebarSlot::Header,
|
||||
];
|
||||
slots.extend((0..announcements).map(SidebarSlot::Repo));
|
||||
slots.extend((0..local_repos).map(SidebarSlot::Local));
|
||||
slots
|
||||
}
|
||||
@@ -55,6 +55,7 @@ impl RepoAddr {
|
||||
.identifier(self.identifier())
|
||||
}
|
||||
|
||||
// Statuses may omit the `a` tag per NIP-34; those are not matched here.
|
||||
pub fn activity_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(crate::filters::ACTIVITY_KINDS)
|
||||
|
||||
@@ -4,16 +4,13 @@ use nostr::prelude::*;
|
||||
|
||||
pub struct Deletions {
|
||||
ids: HashSet<(EventId, PublicKey)>,
|
||||
// All versions of the addressable event up to `cutoff` are deleted.
|
||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||
vanished: Vec<(PublicKey, Timestamp)>,
|
||||
}
|
||||
|
||||
impl Deletions {
|
||||
/// Builds a [`Deletions`] instance from a collection of events.
|
||||
pub fn from_events<E>(events: E) -> Self
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
{
|
||||
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
|
||||
let mut ids = HashSet::new();
|
||||
let mut coords = Vec::new();
|
||||
let mut vanished = Vec::new();
|
||||
@@ -28,6 +25,8 @@ impl Deletions {
|
||||
.map(|c| (c, event.pubkey, event.created_at)),
|
||||
);
|
||||
} else if event.kind == Kind::RequestToVanish {
|
||||
// We can't verify which relay the request targeted, so any
|
||||
// vanish request is honored for the author's events.
|
||||
vanished.push((event.pubkey, event.created_at));
|
||||
}
|
||||
}
|
||||
@@ -39,7 +38,8 @@ impl Deletions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given event is deleted.
|
||||
// A request is valid when its author matches the deleted event's author,
|
||||
// per NIP-09. Addressable events are deleted up to the request's `created_at`.
|
||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||
if self
|
||||
.vanished
|
||||
|
||||
@@ -40,13 +40,16 @@ pub fn is_repo_kind(kind: Kind) -> bool {
|
||||
|| ACTIVITY_KINDS.contains(&kind)
|
||||
}
|
||||
|
||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||
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())
|
||||
.and_then(|value| value.parse::<Kind>().ok())
|
||||
}
|
||||
|
||||
fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
|
||||
tag_value(event, name)?.parse::<Kind>().ok()
|
||||
}
|
||||
|
||||
pub struct Filters;
|
||||
@@ -71,48 +74,12 @@ impl Filters {
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-51: code (people who produce NIP-34 events) follow list
|
||||
pub fn git_authors(public_key: PublicKey) -> Filter {
|
||||
Filter::new().kind(Kind::Custom(10017)).author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-51: NIP-34 followed repositories
|
||||
pub fn git_repos(public_key: PublicKey) -> Filter {
|
||||
Filter::new().kind(Kind::Custom(10018)).author(public_key)
|
||||
}
|
||||
|
||||
/// Replaceable events, so the latest of each kind is all we need.
|
||||
pub fn user_metadata(public_key: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
Self::grasp_list(public_key).limit(1),
|
||||
Self::git_authors(public_key).limit(1),
|
||||
Self::git_repos(public_key).limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::ContactList)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::Metadata)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::MuteList)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
Filter::new()
|
||||
.kind(Kind::BlossomServerList)
|
||||
.author(public_key)
|
||||
.limit(1),
|
||||
]
|
||||
}
|
||||
|
||||
// Two filters: 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() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
@@ -130,6 +97,7 @@ impl Filters {
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
// `Filter::pubkey` matches the git events' lowercase `p` tag.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
Self::notification_comments(me),
|
||||
@@ -137,6 +105,8 @@ impl Filters {
|
||||
]
|
||||
}
|
||||
|
||||
// A comment on an unrelated kind matches too, so results must be filtered
|
||||
// through `GitEvent::is_git_activity` before display.
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||
}
|
||||
@@ -149,11 +119,13 @@ impl Filters {
|
||||
Filter::new().kind(Kind::RepoState)
|
||||
}
|
||||
|
||||
// Quantized to whole days so identical filters hash the same.
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
// Deletion requests must be known before any other event is shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
pub trait GitEvent {
|
||||
/// Returns the NIP-34 subject or the first non-empty content line.
|
||||
fn activity_subject(&self) -> String;
|
||||
|
||||
/// Returns the current commit id.
|
||||
fn current_commit(&self) -> Option<String>;
|
||||
|
||||
/// Returns the merge base commit id.
|
||||
fn merge_base(&self) -> Option<String>;
|
||||
|
||||
/// Returns the advertised clone URLs.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||
|
||||
/// Returns the branch name.
|
||||
fn branch_name(&self) -> Option<String>;
|
||||
|
||||
/// Returns whether the event is git activity.
|
||||
fn is_git_activity(&self) -> bool;
|
||||
|
||||
/// Returns whether an `e` or `E` tag points at the root event.
|
||||
fn references_root(&self, root: &EventId) -> bool;
|
||||
}
|
||||
|
||||
impl GitEvent for Event {
|
||||
/// Reads the subject tag, then the first content line, then "Untitled".
|
||||
fn activity_subject(&self) -> String {
|
||||
let subject = self
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
subject
|
||||
.or_else(|| {
|
||||
self.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Reads the `commit` tag.
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `merge-base` tag.
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `clone` tag.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the `branch-name` tag.
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Classifies git issue, patch, pull request, comment, and status kinds.
|
||||
fn is_git_activity(&self) -> bool {
|
||||
match self.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => crate::filters::is_git_comment(self),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares `e` and `E` tag contents against the root id.
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
self.tags
|
||||
.iter()
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
||||
/// Delegates to the referenced event.
|
||||
fn activity_subject(&self) -> String {
|
||||
(*self).activity_subject()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
(*self).current_commit()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
(*self).merge_base()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
(*self).clone_urls()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
(*self).branch_name()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn is_git_activity(&self) -> bool {
|
||||
(*self).is_git_activity()
|
||||
}
|
||||
|
||||
/// Delegates to the referenced event.
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
(*self).references_root(root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{GitEvent, RepoAddr};
|
||||
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
pub root_event: Option<Event>,
|
||||
pub address: Option<RepoAddr>,
|
||||
pub events: Vec<Event>,
|
||||
pub own_events: Vec<Event>,
|
||||
pub unread_ids: Vec<EventId>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.activity_subject())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||
lookup: &'a L,
|
||||
}
|
||||
|
||||
impl<'a, L> ThreadResolver<'a, L>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||
{
|
||||
pub fn new(lookup: &'a L) -> Self {
|
||||
Self { lookup }
|
||||
}
|
||||
|
||||
// Kind → root mapping:
|
||||
// - 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): NIP-10 root `e`
|
||||
// Returns `None` when the event is not git-related, or when its root is a
|
||||
// coordinate rather than an event.
|
||||
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||
Some(parent) => self.resolve_thread_root(parent),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => self
|
||||
.first_uppercase_e_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => self
|
||||
.nip10_root_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = (self.lookup)(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match self.parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors gitworkshop's `getParentId`.
|
||||
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, marker))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != "e" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slice = tag.as_slice();
|
||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||
|
||||
if is_mention {
|
||||
return None;
|
||||
}
|
||||
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
self.first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| self.first_e_id(event))
|
||||
}
|
||||
|
||||
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != name {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option<EventId> {
|
||||
let slice = tag.as_slice();
|
||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn group<E, O, L>(
|
||||
events: E,
|
||||
own: O,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &L,
|
||||
) -> Vec<InboxItem>
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
O: IntoIterator<Item = Event>,
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
let resolver = ThreadResolver::new(lookup);
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = resolver.notification_root(&event) else {
|
||||
continue;
|
||||
};
|
||||
groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in own {
|
||||
let root = resolver.notification_root(&event).unwrap_or(event.id);
|
||||
own_groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
|
||||
let mut items: Vec<InboxItem> = roots
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
utils::sort_newest_first(&mut events);
|
||||
utils::sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next())
|
||||
.map(RepoAddr::from),
|
||||
root_event,
|
||||
events,
|
||||
own_events,
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
item.apply_state(state);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
b.latest_activity()
|
||||
.cmp(&a.latest_activity())
|
||||
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
||||
});
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
// High-water-mark model: events at or before the cutoff
|
||||
// are covered without an entry in the id set.
|
||||
#[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 {
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
#[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 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 comment_resolves_to_its_uppercase_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), comment.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_patch_resolves_to_the_root_patch() {
|
||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||
let events = [root_patch.clone(), child_patch.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
||||
Some(root_patch.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_resolves_via_the_root_marker() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let status = signed(
|
||||
&keys(2),
|
||||
Kind::GitStatusClosed,
|
||||
vec![marked_e_tag(&issue, "root")],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), status.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_comment_chain_follows_to_the_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||
let events = [issue.clone(), reply, nested.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_merges_own_events_into_the_matching_thread() {
|
||||
let me = keys(1);
|
||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
||||
let mine = signed(
|
||||
&me,
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
150,
|
||||
);
|
||||
let reply = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
||||
let items = group(
|
||||
[reply.clone()],
|
||||
[issue.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&context),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
assert_eq!(
|
||||
items[0].root_event.as_ref().map(|event| event.id),
|
||||
Some(issue.id)
|
||||
);
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.timeline(5)
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use utils::sort_newest_first;
|
||||
|
||||
use super::{InboxItem, InboxReadState, ThreadResolver};
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Groups events into inbox threads, merging the user's own events.
|
||||
pub fn group<E, O, L>(
|
||||
events: E,
|
||||
own: O,
|
||||
me: PublicKey,
|
||||
state: &InboxReadState,
|
||||
lookup: &L,
|
||||
) -> Vec<InboxItem>
|
||||
where
|
||||
E: IntoIterator<Item = Event>,
|
||||
O: IntoIterator<Item = Event>,
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
{
|
||||
let resolver = ThreadResolver::new(lookup);
|
||||
let mut groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in events {
|
||||
if event.pubkey == me {
|
||||
continue;
|
||||
}
|
||||
let Some(root) = resolver.notification_root(&event) else {
|
||||
continue;
|
||||
};
|
||||
groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
|
||||
for event in own {
|
||||
let root = resolver.notification_root(&event).unwrap_or(event.id);
|
||||
own_groups.entry(root).or_default().push(event);
|
||||
}
|
||||
|
||||
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
|
||||
let mut items: Vec<InboxItem> = roots
|
||||
.into_iter()
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
sort_newest_first(&mut events);
|
||||
sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
let mut item = InboxItem {
|
||||
root,
|
||||
address: root_event
|
||||
.as_ref()
|
||||
.and_then(|event| event.tags.coordinates().next())
|
||||
.map(RepoAddr::from),
|
||||
root_event,
|
||||
events,
|
||||
own_events,
|
||||
unread_ids: Vec::new(),
|
||||
archived: false,
|
||||
};
|
||||
item.apply_state(state);
|
||||
item
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| {
|
||||
b.latest_activity()
|
||||
.cmp(&a.latest_activity())
|
||||
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
|
||||
});
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Derives deterministic signer keys from a seed byte.
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds an uppercase `E` tag for the event.
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
/// Builds an event lookup closure over a slice.
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Builds a git issue event with a subject tag.
|
||||
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
|
||||
signed(
|
||||
author,
|
||||
Kind::GitIssue,
|
||||
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
|
||||
at,
|
||||
)
|
||||
}
|
||||
|
||||
/// Merges the user's own events into the matching thread.
|
||||
#[test]
|
||||
fn group_merges_own_events_into_the_matching_thread() {
|
||||
let me = keys(1);
|
||||
let issue = titled_issue(&me, "Add retry logic", 100);
|
||||
let mine = signed(
|
||||
&me,
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
150,
|
||||
);
|
||||
let reply = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
|
||||
let context = [issue.clone(), mine.clone(), reply.clone()];
|
||||
let items = group(
|
||||
[reply.clone()],
|
||||
[issue.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
&InboxReadState::default(),
|
||||
&lookup(&context),
|
||||
);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].root, issue.id);
|
||||
assert_eq!(
|
||||
items[0].root_event.as_ref().map(|event| event.id),
|
||||
Some(issue.id)
|
||||
);
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
.timeline(5)
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use super::InboxReadState;
|
||||
use crate::{GitEvent, RepoAddr};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
pub root: EventId,
|
||||
pub root_event: Option<Event>,
|
||||
pub address: Option<RepoAddr>,
|
||||
pub events: Vec<Event>,
|
||||
pub own_events: Vec<Event>,
|
||||
pub unread_ids: Vec<EventId>,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Returns the thread title from the root, own events, or events.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.activity_subject())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
/// Returns the kind of the thread's primary event.
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.or_else(|| self.own_events.first())
|
||||
.or_else(|| self.events.first())
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
/// Returns the newest timestamp among the root, own, and other events.
|
||||
pub fn latest_activity(&self) -> Timestamp {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.chain(self.own_events.first())
|
||||
.chain(self.events.first())
|
||||
.map(|event| event.created_at)
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Builds a chronological timeline capped at `limit` events, root first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||
let mut seen: HashSet<EventId> = HashSet::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
|
||||
if let Some(root) = &self.root_event {
|
||||
seen.insert(root.id);
|
||||
events.push(root.clone());
|
||||
}
|
||||
|
||||
let mut rest: Vec<Event> = self
|
||||
.own_events
|
||||
.iter()
|
||||
.chain(self.events.iter())
|
||||
.filter(|event| seen.insert(event.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
rest.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
rest.truncate(limit.saturating_sub(events.len()));
|
||||
events.extend(rest);
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
events
|
||||
}
|
||||
|
||||
/// Returns whether the thread has unread events and is not archived.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
|
||||
/// Recomputes unread ids and the archived flag from the read state.
|
||||
pub fn apply_state(&mut self, state: &InboxReadState) {
|
||||
self.unread_ids = self
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| !state.is_read(event))
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
|
||||
self.archived =
|
||||
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
mod group;
|
||||
mod item;
|
||||
mod read_state;
|
||||
mod resolver;
|
||||
|
||||
pub use group::group;
|
||||
pub use item::InboxItem;
|
||||
pub use read_state::InboxReadState;
|
||||
pub use resolver::ThreadResolver;
|
||||
@@ -1,109 +0,0 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)]
|
||||
pub read_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub read_ids: HashSet<EventId>,
|
||||
#[serde(default)]
|
||||
pub archived_before: Timestamp,
|
||||
#[serde(default)]
|
||||
pub archived_ids: HashSet<EventId>,
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
/// Returns whether the event falls under the read high-water mark or id set.
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Returns whether the event falls under the archived high-water mark or id set.
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Records the event as read when it is past the current cutoff.
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the event as archived when it is past the current cutoff.
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks all events from other authors within the window as read.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = now - MARK_ALL_WINDOW;
|
||||
self.read_before = cutoff;
|
||||
self.read_ids = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && event.created_at > cutoff)
|
||||
.map(|event| event.id)
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Advances the read cutoff as far as possible, then prunes the id set.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||
self.read_before = cutoff;
|
||||
prune_ids(&mut self.read_ids, all, cutoff);
|
||||
}
|
||||
|
||||
/// Advances the archived cutoff as far as possible, then prunes the id set.
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||
self.is_archived(event)
|
||||
});
|
||||
self.archived_before = cutoff;
|
||||
prune_ids(&mut self.archived_ids, all, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the newest cutoff that leaves unmarked events uncovered.
|
||||
fn advance_cutoff<M>(
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
now: Timestamp,
|
||||
current: Timestamp,
|
||||
is_marked: M,
|
||||
) -> Timestamp
|
||||
where
|
||||
M: Fn(&Event) -> bool,
|
||||
{
|
||||
let fallback = now - ADVANCE_WINDOW;
|
||||
|
||||
let oldest = all
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me && !is_marked(event))
|
||||
.map(|event| event.created_at)
|
||||
.min();
|
||||
|
||||
let candidate = match oldest {
|
||||
Some(at) if at < fallback => at - 1,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
candidate.max(current)
|
||||
}
|
||||
|
||||
/// Drops ids whose events predate the cutoff.
|
||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||
let created_at: HashMap<EventId, Timestamp> = all
|
||||
.iter()
|
||||
.map(|event| (event.id, event.created_at))
|
||||
.collect();
|
||||
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||
lookup: &'a L,
|
||||
}
|
||||
|
||||
impl<'a, L> ThreadResolver<'a, L>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||
{
|
||||
/// Creates a resolver over the given event lookup.
|
||||
pub fn new(lookup: &'a L) -> Self {
|
||||
Self { lookup }
|
||||
}
|
||||
|
||||
/// Maps a git event kind to the thread root it notifies about.
|
||||
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||
Some(parent) => self.resolve_thread_root(parent),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => self
|
||||
.first_uppercase_e_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => self
|
||||
.nip10_root_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows parent pointers until a git issue or pull request root is reached.
|
||||
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = (self.lookup)(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match self.parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors gitworkshop's `getParentId` parent resolution.
|
||||
fn parent_id(&self, event: &Event) -> Option<EventId> {
|
||||
for marker in ["reply", "root"] {
|
||||
if let Some(id) = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, marker))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != "e" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let slice = tag.as_slice();
|
||||
let is_mention = slice.len() == 4 && slice[3] == "mention";
|
||||
|
||||
if is_mention {
|
||||
return None;
|
||||
}
|
||||
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}) {
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
self.first_uppercase_e_id(event)
|
||||
}
|
||||
|
||||
/// Reads the NIP-10 `root` marker, falling back to the first `e` tag.
|
||||
fn nip10_root_id(&self, event: &Event) -> Option<EventId> {
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| self.e_tag_with_marker(tag, "root"))
|
||||
.or_else(|| self.first_e_id(event))
|
||||
}
|
||||
|
||||
/// Reads the first lowercase `e` tag id.
|
||||
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
/// Reads the first uppercase `E` tag id.
|
||||
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "E")
|
||||
}
|
||||
|
||||
/// Reads the first event id from tags of the given kind.
|
||||
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
if tag.kind() != name {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a four-value `e` tag carrying the given marker.
|
||||
fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option<EventId> {
|
||||
let slice = tag.as_slice();
|
||||
if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker {
|
||||
return None;
|
||||
}
|
||||
tag.content()
|
||||
.and_then(|content| EventId::from_hex(content).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Derives deterministic signer keys from a seed byte.
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
hex.push_str(&format!("{seed:02x}"));
|
||||
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
|
||||
}
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from_secs(created_at))
|
||||
.finalize(author)
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds a plain lowercase `e` tag for the event.
|
||||
fn e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||
}
|
||||
|
||||
/// Builds a four-value `e` tag with the given marker.
|
||||
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
|
||||
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
|
||||
.expect("valid e tag")
|
||||
}
|
||||
|
||||
/// Builds an uppercase `E` tag for the event.
|
||||
fn uppercase_e_tag(event: &Event) -> Tag {
|
||||
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
|
||||
}
|
||||
|
||||
/// Builds an event lookup closure over a slice.
|
||||
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
|
||||
move |id| events.iter().find(|event| event.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Builds a git issue event.
|
||||
fn issue(author: &Keys, at: u64) -> Event {
|
||||
signed(author, Kind::GitIssue, Vec::new(), at)
|
||||
}
|
||||
|
||||
/// Resolves a comment to its uppercase `E` root.
|
||||
#[test]
|
||||
fn comment_resolves_to_its_uppercase_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let comment = signed(
|
||||
&keys(2),
|
||||
Kind::Comment,
|
||||
vec![
|
||||
uppercase_e_tag(&issue),
|
||||
Tag::parse(["K", "1621"]).expect("valid K tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), comment.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves a child patch to its root patch.
|
||||
#[test]
|
||||
fn child_patch_resolves_to_the_root_patch() {
|
||||
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
|
||||
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
|
||||
let events = [root_patch.clone(), child_patch.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
|
||||
Some(root_patch.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves a status through its `root` marker tag.
|
||||
#[test]
|
||||
fn status_resolves_via_the_root_marker() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let status = signed(
|
||||
&keys(2),
|
||||
Kind::GitStatusClosed,
|
||||
vec![marked_e_tag(&issue, "root")],
|
||||
200,
|
||||
);
|
||||
let events = [issue.clone(), status.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&status),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// Follows a nested comment chain to the issue root.
|
||||
#[test]
|
||||
fn nested_comment_chain_follows_to_the_root() {
|
||||
let issue = issue(&keys(1), 100);
|
||||
let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200);
|
||||
let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300);
|
||||
let events = [issue.clone(), reply, nested.clone()];
|
||||
assert_eq!(
|
||||
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
|
||||
Some(issue.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
pub mod addr;
|
||||
pub mod announcement;
|
||||
pub mod deletions;
|
||||
pub mod filters;
|
||||
pub mod git_event;
|
||||
pub mod inbox;
|
||||
pub mod pull_request;
|
||||
pub mod model;
|
||||
pub mod state;
|
||||
pub mod status;
|
||||
pub mod upstream;
|
||||
|
||||
pub use addr::RepoAddr;
|
||||
pub use announcement::Announcement;
|
||||
pub use deletions::Deletions;
|
||||
pub use filters::Filters;
|
||||
pub use git_event::GitEvent;
|
||||
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
|
||||
pub use pull_request::PullRequest;
|
||||
pub use model::{Announcement, GitEvent, PullRequest};
|
||||
pub use state::RepoState;
|
||||
pub use status::RepoStatus;
|
||||
pub use upstream::Upstream;
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::collections::HashSet;
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
use crate::upstream::Upstream;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Announcement {
|
||||
@@ -22,8 +21,276 @@ pub struct Announcement {
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
// The `u` tag of a fork announcement, per NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
pub raw: String,
|
||||
// `None` for the git-URL form.
|
||||
pub addr: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
fn parse(raw: &str) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
.parse::<Coordinate>()
|
||||
.ok()
|
||||
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
||||
.map(RepoAddr::from);
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => addr.to_string(),
|
||||
None => self.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait GitEvent {
|
||||
fn activity_subject(&self) -> String;
|
||||
|
||||
fn current_commit(&self) -> Option<String>;
|
||||
|
||||
fn merge_base(&self) -> Option<String>;
|
||||
|
||||
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||
|
||||
fn branch_name(&self) -> Option<String>;
|
||||
|
||||
fn is_git_activity(&self) -> bool;
|
||||
|
||||
// Matches both NIP-10 lowercase `e` and NIP-22 uppercase `E` root pointers.
|
||||
fn references_root(&self, root: &EventId) -> bool;
|
||||
}
|
||||
|
||||
impl GitEvent for Event {
|
||||
fn activity_subject(&self) -> String {
|
||||
let subject = self
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
subject
|
||||
.or_else(|| {
|
||||
self.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Clone(urls)) => Some(urls),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
self.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::BranchName(name)) => Some(name),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_git_activity(&self) -> bool {
|
||||
match self.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => crate::filters::is_git_comment(self),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => crate::filters::is_git_status(self),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
self.tags
|
||||
.iter()
|
||||
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: GitEvent + ?Sized> GitEvent for &T {
|
||||
fn activity_subject(&self) -> String {
|
||||
(*self).activity_subject()
|
||||
}
|
||||
|
||||
fn current_commit(&self) -> Option<String> {
|
||||
(*self).current_commit()
|
||||
}
|
||||
|
||||
fn merge_base(&self) -> Option<String> {
|
||||
(*self).merge_base()
|
||||
}
|
||||
|
||||
fn clone_urls(&self) -> Option<Vec<Url>> {
|
||||
(*self).clone_urls()
|
||||
}
|
||||
|
||||
fn branch_name(&self) -> Option<String> {
|
||||
(*self).branch_name()
|
||||
}
|
||||
|
||||
fn is_git_activity(&self) -> bool {
|
||||
(*self).is_git_activity()
|
||||
}
|
||||
|
||||
fn references_root(&self, root: &EventId) -> bool {
|
||||
(*self).references_root(root)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PullRequest<'a>(pub &'a Event);
|
||||
|
||||
impl<'a> PullRequest<'a> {
|
||||
pub fn new(event: &'a Event) -> Self {
|
||||
Self(event)
|
||||
}
|
||||
|
||||
// The PR references its root patch via an `e` tag.
|
||||
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
|
||||
if let Some(root_id) = pr.tags.event_ids().next()
|
||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||
{
|
||||
return Self::forward_series(root, &patches);
|
||||
}
|
||||
|
||||
let Some(tip) = pr.current_commit() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(last) = patches
|
||||
.iter()
|
||||
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||
.max_by_key(|patch| patch.created_at)
|
||||
.copied()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut series = vec![last];
|
||||
loop {
|
||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||
break;
|
||||
};
|
||||
let Some(prev) = patches
|
||||
.iter()
|
||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||
.copied()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
series.push(prev);
|
||||
}
|
||||
series.reverse();
|
||||
series
|
||||
}
|
||||
|
||||
// Falls back to the root event's content when no patch set is found.
|
||||
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = self.patches(patches.iter().copied());
|
||||
if series.is_empty() {
|
||||
return pr.content.clone();
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|patch| patch.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// 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(
|
||||
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)
|
||||
}
|
||||
|
||||
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
let next = patches
|
||||
.iter()
|
||||
.filter(|patch| !series.contains(patch))
|
||||
.filter(|patch| {
|
||||
patch
|
||||
.tags
|
||||
.event_ids()
|
||||
.any(|id| id == series.last().unwrap().id)
|
||||
})
|
||||
.max_by_key(|patch| patch.created_at);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
series.push(next);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
// Lets clients find existing patches for a specific commit.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
/// Lists forks of the base, the user's own first.
|
||||
// The user's own forks are listed first.
|
||||
pub fn forks_in<'a>(
|
||||
announcements: &'a [Announcement],
|
||||
base: &RepoAddr,
|
||||
@@ -44,7 +311,9 @@ impl Announcement {
|
||||
own.into_iter().chain(others).collect()
|
||||
}
|
||||
|
||||
/// Parses a kind 30617 event, rejecting other kinds and missing `d` tags.
|
||||
/// 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;
|
||||
@@ -76,6 +345,8 @@ impl Announcement {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
|
||||
// Only the first `u` tag is used.
|
||||
if upstream.is_none() && tag.kind() == "u" {
|
||||
let values = tag.as_slice();
|
||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||
@@ -102,17 +373,15 @@ impl Announcement {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the owner and identifier as a repository address.
|
||||
pub fn addr(&self) -> RepoAddr {
|
||||
RepoAddr::new(self.owner, self.id.clone())
|
||||
}
|
||||
|
||||
/// Returns the name, defaulting to "Untitled".
|
||||
pub fn name(&self) -> String {
|
||||
self.name.clone().unwrap_or("Untitled".into())
|
||||
}
|
||||
|
||||
/// Matches a `u` tag pointing at the base or a shared earliest unique commit.
|
||||
// The `u` tag pointing at `base` also covers permanent forks whose EUC diverged.
|
||||
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||
if self.addr() == *base {
|
||||
return false;
|
||||
@@ -123,14 +392,14 @@ impl Announcement {
|
||||
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||
}
|
||||
|
||||
/// Returns the description, defaulting to "No description".
|
||||
pub fn description(&self) -> String {
|
||||
self.description
|
||||
.clone()
|
||||
.unwrap_or("No description".to_string())
|
||||
}
|
||||
|
||||
/// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork.
|
||||
// A `u` tag marking the repository as a subordinate fork excludes the
|
||||
// announcement author from the maintainers, 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) {
|
||||
@@ -139,7 +408,6 @@ impl Announcement {
|
||||
maintainers
|
||||
}
|
||||
|
||||
/// Returns deduplicated `git clone` commands for the advertised URLs.
|
||||
pub fn clone_urls(&self) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
self.clone
|
||||
@@ -156,7 +424,6 @@ mod tests {
|
||||
|
||||
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
|
||||
|
||||
/// Returns the test signer keys.
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
@@ -164,7 +431,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a signed repo announcement event from raw tags.
|
||||
fn announcement_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
@@ -177,7 +443,6 @@ mod tests {
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Parses an event carrying every supported tag.
|
||||
#[test]
|
||||
fn parses_full_announcement() {
|
||||
let event = announcement_event(&[
|
||||
@@ -225,7 +490,6 @@ mod tests {
|
||||
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
|
||||
}
|
||||
|
||||
/// Skips malformed clone, relay, and maintainer values.
|
||||
#[test]
|
||||
fn drops_malformed_values() {
|
||||
let event = announcement_event(&[
|
||||
@@ -245,7 +509,6 @@ mod tests {
|
||||
assert!(announcement.maintainers.is_empty());
|
||||
}
|
||||
|
||||
/// Parses the `u` tag into an upstream coordinate.
|
||||
#[test]
|
||||
fn parses_upstream_tag() {
|
||||
let event = announcement_event(&[
|
||||
@@ -277,7 +540,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Matches a fork through its `u` tag coordinate.
|
||||
#[test]
|
||||
fn is_fork_of_matches_the_u_tag_coordinate() {
|
||||
let base = RepoAddr::new(
|
||||
@@ -290,7 +552,6 @@ mod tests {
|
||||
assert!(fork.is_fork_of(&base, None));
|
||||
}
|
||||
|
||||
/// Matches forks sharing the base's earliest unique commit.
|
||||
#[test]
|
||||
fn is_fork_of_matches_a_shared_euc() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
@@ -312,7 +573,6 @@ mod tests {
|
||||
assert!(!fork.is_fork_of(&base_addr, None));
|
||||
}
|
||||
|
||||
/// Matches permanent forks whose `u` tag points at the base despite a diverged EUC.
|
||||
#[test]
|
||||
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
|
||||
let base = RepoAddr::new(
|
||||
@@ -330,7 +590,6 @@ mod tests {
|
||||
assert!(fork.is_fork_of(&base, Some(base_euc)));
|
||||
}
|
||||
|
||||
/// Includes the owner among maintainers for primary repositories.
|
||||
#[test]
|
||||
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||
@@ -343,7 +602,6 @@ mod tests {
|
||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||
}
|
||||
|
||||
/// Excludes the owner from maintainers for subordinate forks.
|
||||
#[test]
|
||||
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
|
||||
let event = announcement_event(&[
|
||||
@@ -362,13 +620,151 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(Kind::GitPullRequest, content)
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(Kind::GitPatch, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patch([&root, &second]),
|
||||
"patch-one\npatch-two"
|
||||
);
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patches([&root, &second]),
|
||||
vec![&root, &second]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_walks_the_reply_chain_in_order() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two", "patch-three"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let tip = "1111111111111111111111111111111111111111";
|
||||
let last = patch_event(
|
||||
"patch-two",
|
||||
vec![
|
||||
Tag::event(root.id),
|
||||
Tag::parse(["r", tip]).expect("valid tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let pr = pr_event(
|
||||
"description",
|
||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||
);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&root, &last]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two"]
|
||||
);
|
||||
}
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||
|
||||
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 latest_update_picks_newest_revision_of_the_root() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
|
||||
let revision = |created_at: u64| {
|
||||
signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
|
||||
created_at,
|
||||
)
|
||||
};
|
||||
let unrelated = signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||
999,
|
||||
);
|
||||
|
||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
||||
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
||||
|
||||
assert_eq!(latest.created_at.as_secs(), 300);
|
||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_update_ignores_other_authors() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
let other = Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.expect("valid secret key"),
|
||||
);
|
||||
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
|
||||
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
|
||||
.custom_created_at(Timestamp::from(999))
|
||||
.finalize(&other)
|
||||
.expect("signed event");
|
||||
|
||||
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
|
||||
const OWNER_KEYS: [&str; 3] = [
|
||||
"0000000000000000000000000000000000000000000000000000000000000001",
|
||||
"0000000000000000000000000000000000000000000000000000000000000002",
|
||||
"0000000000000000000000000000000000000000000000000000000000000003",
|
||||
];
|
||||
|
||||
/// Builds an announcement event signed by the given owner key.
|
||||
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
|
||||
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
|
||||
let tags: Vec<Tag> = tags
|
||||
@@ -381,7 +777,6 @@ mod tests {
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds one announcement owned by the owner key at the given index.
|
||||
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
|
||||
vec![
|
||||
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
|
||||
@@ -389,7 +784,6 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// Places the user's own forks before everyone else's.
|
||||
#[test]
|
||||
fn fork_candidates_orders_own_forks_first() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
@@ -434,7 +828,6 @@ mod tests {
|
||||
assert_eq!(ids, vec!["my-fork", "their-fork"]);
|
||||
}
|
||||
|
||||
/// Excludes the base itself, unrelated repos, and repos without clone URLs.
|
||||
#[test]
|
||||
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
@@ -1,279 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::git_event::GitEvent;
|
||||
|
||||
pub struct PullRequest<'a>(pub &'a Event);
|
||||
|
||||
impl<'a> PullRequest<'a> {
|
||||
/// Wraps a pull request root event.
|
||||
pub fn new(event: &'a Event) -> Self {
|
||||
Self(event)
|
||||
}
|
||||
|
||||
/// Returns the ordered patch series, found via the root reference or the tip commit.
|
||||
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> Vec<&'a Event> {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
|
||||
if let Some(root_id) = pr.tags.event_ids().next()
|
||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||
{
|
||||
return Self::forward_series(root, &patches);
|
||||
}
|
||||
|
||||
let Some(tip) = pr.current_commit() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(last) = patches
|
||||
.iter()
|
||||
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||
.max_by_key(|patch| patch.created_at)
|
||||
.copied()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut series = vec![last];
|
||||
loop {
|
||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||
break;
|
||||
};
|
||||
let Some(prev) = patches
|
||||
.iter()
|
||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||
.copied()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
series.push(prev);
|
||||
}
|
||||
series.reverse();
|
||||
series
|
||||
}
|
||||
|
||||
/// Joins the patch series with newlines, falling back to the event content.
|
||||
pub fn patch(&self, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let pr = self.0;
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = self.patches(patches.iter().copied());
|
||||
if series.is_empty() {
|
||||
return pr.content.clone();
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|patch| patch.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Returns the root author's newest update, since only the author may move the tip.
|
||||
pub fn latest_update(
|
||||
events: impl Iterator<Item = &'a Event>,
|
||||
root: &Event,
|
||||
) -> Option<&'a Event> {
|
||||
let root_hex = root.id.to_hex();
|
||||
events
|
||||
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
|
||||
.filter(|e| e.pubkey == root.pubkey)
|
||||
.filter(|e| {
|
||||
e.tags
|
||||
.iter()
|
||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
||||
})
|
||||
.max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
/// Walks reply links outward from the root patch, taking the newest child first.
|
||||
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
let next = patches
|
||||
.iter()
|
||||
.filter(|patch| !series.contains(patch))
|
||||
.filter(|patch| {
|
||||
patch
|
||||
.tags
|
||||
.event_ids()
|
||||
.any(|id| id == series.last().unwrap().id)
|
||||
})
|
||||
.max_by_key(|patch| patch.created_at);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
series.push(next);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
/// Checks the commit against the patch's `c` and `r` tags.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Returns the test signer keys.
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.expect("valid secret key"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds a signed pull request event.
|
||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
||||
EventBuilder::new(Kind::GitPullRequest, content)
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds a signed patch event with a fixed timestamp.
|
||||
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(Kind::GitPatch, content)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Joins the full patch series into one diff.
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patch([&root, &second]),
|
||||
"patch-one\npatch-two"
|
||||
);
|
||||
assert_eq!(
|
||||
PullRequest::new(&pr).patches([&root, &second]),
|
||||
vec![&root, &second]
|
||||
);
|
||||
}
|
||||
|
||||
/// Orders patches by walking the reply chain.
|
||||
#[test]
|
||||
fn pull_request_patches_walks_the_reply_chain_in_order() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
|
||||
let pr = pr_event("description", vec![Tag::event(root.id)]);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two", "patch-three"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds the patch series through the declared tip commit.
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let tip = "1111111111111111111111111111111111111111";
|
||||
let last = patch_event(
|
||||
"patch-two",
|
||||
vec![
|
||||
Tag::event(root.id),
|
||||
Tag::parse(["r", tip]).expect("valid tag"),
|
||||
],
|
||||
200,
|
||||
);
|
||||
let pr = pr_event(
|
||||
"description",
|
||||
vec![Tag::parse(["c", tip]).expect("valid tag")],
|
||||
);
|
||||
|
||||
let series = PullRequest::new(&pr).patches([&root, &last]);
|
||||
assert_eq!(
|
||||
series
|
||||
.iter()
|
||||
.map(|p| p.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["patch-one", "patch-two"]
|
||||
);
|
||||
}
|
||||
|
||||
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
|
||||
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
|
||||
|
||||
/// Builds a signed event with a fixed timestamp.
|
||||
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
|
||||
EventBuilder::new(kind, "")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
/// Builds the pull request root used by the update tests.
|
||||
fn pr_root() -> Event {
|
||||
signed_at(
|
||||
Kind::GitPullRequest,
|
||||
vec![
|
||||
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
|
||||
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
|
||||
],
|
||||
100,
|
||||
)
|
||||
}
|
||||
|
||||
/// Picks the newest update targeting the root.
|
||||
#[test]
|
||||
fn latest_update_picks_newest_revision_of_the_root() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
|
||||
let revision = |created_at: u64| {
|
||||
signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
|
||||
created_at,
|
||||
)
|
||||
};
|
||||
let unrelated = signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||
999,
|
||||
);
|
||||
|
||||
let events = [unrelated, revision(200), root.clone(), revision(300)];
|
||||
let latest = PullRequest::latest_update(events.iter(), &root).expect("an update");
|
||||
|
||||
assert_eq!(latest.created_at.as_secs(), 300);
|
||||
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
|
||||
}
|
||||
|
||||
/// Ignores updates from authors other than the root owner.
|
||||
#[test]
|
||||
fn latest_update_ignores_other_authors() {
|
||||
let root = pr_root();
|
||||
let root_hex = root.id.to_hex();
|
||||
let other = Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
|
||||
.expect("valid secret key"),
|
||||
);
|
||||
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
|
||||
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
|
||||
.custom_created_at(Timestamp::from(999))
|
||||
.finalize(&other)
|
||||
.expect("signed event");
|
||||
|
||||
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoState {
|
||||
pub refs: Vec<(String, String)>,
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoState {
|
||||
// The `d` tag matches the repository id.
|
||||
pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||
for (name, commit) in refs {
|
||||
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||
}
|
||||
if let Some(head) = head {
|
||||
tags.push(
|
||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||
);
|
||||
}
|
||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||
}
|
||||
|
||||
pub fn parse(event: &Event) -> Self {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
||||
}
|
||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Self { refs, head }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca";
|
||||
|
||||
fn keys() -> Keys {
|
||||
Keys::new(
|
||||
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
|
||||
.expect("valid secret key"),
|
||||
)
|
||||
}
|
||||
|
||||
fn state_event(tags: &[&[&str]]) -> Event {
|
||||
let tags: Vec<Tag> = tags
|
||||
.iter()
|
||||
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
|
||||
.collect();
|
||||
|
||||
EventBuilder::new(Kind::RepoState, "")
|
||||
.tags(tags)
|
||||
.finalize(&keys())
|
||||
.expect("signed event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_heads_and_tags() {
|
||||
let event = state_event(&[
|
||||
&["HEAD", "ref: refs/heads/main"],
|
||||
&["refs/heads/main", COMMIT_A],
|
||||
&["refs/heads/dev", COMMIT_B],
|
||||
&["refs/tags/v1.0", COMMIT_A],
|
||||
]);
|
||||
|
||||
let state = RepoState::parse(&event);
|
||||
|
||||
assert_eq!(state.head.as_deref(), Some("main"));
|
||||
assert_eq!(
|
||||
state.refs,
|
||||
vec![
|
||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_round_trips_through_parse() {
|
||||
let refs = [
|
||||
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||
];
|
||||
|
||||
let event = RepoState::build("my-repo", &refs, Some("main"))
|
||||
.finalize(&keys())
|
||||
.expect("signed event");
|
||||
|
||||
assert_eq!(event.kind, Kind::RepoState);
|
||||
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
||||
|
||||
let state = RepoState::parse(&event);
|
||||
assert_eq!(state.refs, refs);
|
||||
assert_eq!(state.head.as_deref(), Some("main"));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ impl RepoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults to Open when no authorized status event exists.
|
||||
pub fn resolve<'a, I>(
|
||||
status_events: I,
|
||||
root_author: &PublicKey,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
pub raw: String,
|
||||
pub addr: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
/// Parses a raw `u` tag value, extracting the coordinate when present.
|
||||
pub(crate) fn parse(raw: &str) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
.parse::<Coordinate>()
|
||||
.ok()
|
||||
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
|
||||
.map(RepoAddr::from);
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the coordinate when parsed, otherwise the raw value.
|
||||
pub fn display(&self) -> String {
|
||||
match &self.addr {
|
||||
Some(addr) => addr.to_string(),
|
||||
None => self.raw.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,10 @@ signed_core = { path = "../signed_core" }
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
anyhow.workspace = true
|
||||
gix = { workspace = true, features = ["revision", "blob-diff"] }
|
||||
gix-worktree = "0.57"
|
||||
gix-worktree-state = "0.35"
|
||||
gix-worktree = "0.56"
|
||||
gix-worktree-state = "0.34"
|
||||
anyhow.workspace = true
|
||||
diffy = "0.5"
|
||||
ignore = "0.4"
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
|
||||
use crate::repo::Repo;
|
||||
@@ -10,80 +8,51 @@ use crate::repo::Repo;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitCache {
|
||||
root: PathBuf,
|
||||
clone_locks: Arc<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>>,
|
||||
}
|
||||
|
||||
impl GitCache {
|
||||
/// Creates a cache rooted at `root`.
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self {
|
||||
root,
|
||||
clone_locks: Arc::default(),
|
||||
}
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// Returns the cache root directory.
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Returns the cache path for a repository address.
|
||||
pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf {
|
||||
self.root
|
||||
.join(addr.public_key().to_hex())
|
||||
.join(Self::sanitize_path_component(addr.identifier()))
|
||||
}
|
||||
|
||||
/// Opens the cached repository, or `None` when it is not cloned yet.
|
||||
pub fn open(&self, addr: &RepoAddr) -> Result<Option<Repo>> {
|
||||
let path = self.repo_path(addr);
|
||||
match gix::open(&path) {
|
||||
Ok(repo) => Ok(Some(Repo::new(repo))),
|
||||
Err(e) if e.is_not_found() => Ok(None),
|
||||
Ok(repo) => Ok(Some(Repo { inner: 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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the clone lock for `path`, creating it on first use.
|
||||
fn clone_lock(&self, path: &Path) -> Arc<Mutex<()>> {
|
||||
let mut locks = self
|
||||
.clone_locks
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
Arc::clone(locks.entry(path.to_path_buf()).or_default())
|
||||
}
|
||||
|
||||
/// Returns the cached repository, cloning from one of `clone_urls` on first use.
|
||||
pub fn ensure_clone<U: AsRef<str>>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result<Repo> {
|
||||
let path = self.repo_path(addr);
|
||||
|
||||
// Lock the clone lock for this path to prevent concurrent clones.
|
||||
let clone_lock = self.clone_lock(&path);
|
||||
let _clone_guard = clone_lock.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
|
||||
// Refreshes the cached repository if it is already cloned, or clones it from one of `clone_urls` otherwise.
|
||||
if let Ok(Some(repo)) = self.open(addr) {
|
||||
if let Err(error) = repo.fetch() {
|
||||
log::warn!("failed to refresh the cached repository: {error:#}");
|
||||
}
|
||||
if let Some(repo) = self.open(addr)? {
|
||||
repo.fetch().ok();
|
||||
return Ok(repo);
|
||||
}
|
||||
|
||||
// Create the parent directory if it does not exist.
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Remove the cached clone if it exists but is unusable.
|
||||
if path.exists() {
|
||||
log::warn!("removing unusable cached clone at {}", path.display());
|
||||
std::fs::remove_dir_all(&path)?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
Repo::clone(clone_urls, &path)
|
||||
}
|
||||
|
||||
/// Maps untrusted relay content onto a safe single path component.
|
||||
// `id` is untrusted relay content: it must never escape the cache root as
|
||||
// a single path component.
|
||||
pub fn sanitize_path_component(id: &str) -> String {
|
||||
let sanitized: String = id
|
||||
.chars()
|
||||
@@ -103,7 +72,6 @@ impl GitCache {
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// Returns the `owner/identifier` namespace used for fork checkouts.
|
||||
pub fn fork_namespace(announcement: &Announcement) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
|
||||
+193
-151
@@ -1,5 +1,8 @@
|
||||
use anyhow::Result;
|
||||
use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader};
|
||||
|
||||
use crate::repo::Repo;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffLineKind {
|
||||
Context,
|
||||
@@ -15,18 +18,6 @@ pub struct DiffLine {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl DiffLine {
|
||||
/// Creates a diff line with its kind, optional line numbers, and text.
|
||||
pub fn new(kind: DiffLineKind, old: Option<u32>, new: Option<u32>, text: String) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
old,
|
||||
new,
|
||||
text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffHunk {
|
||||
pub old_start: u32,
|
||||
@@ -36,25 +27,6 @@ pub struct DiffHunk {
|
||||
pub lines: Vec<DiffLine>,
|
||||
}
|
||||
|
||||
impl DiffHunk {
|
||||
/// Creates a hunk spanning the given old and new ranges.
|
||||
pub fn new(
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
lines: Vec<DiffLine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiffStatus {
|
||||
Added,
|
||||
@@ -66,172 +38,242 @@ pub enum DiffStatus {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDiff {
|
||||
// For renames and copies, the destination path.
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
pub status: DiffStatus,
|
||||
pub insertions: usize,
|
||||
pub deletions: usize,
|
||||
// Binary files have empty `hunks`.
|
||||
pub binary: bool,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
}
|
||||
|
||||
impl FileDiff {
|
||||
/// Creates a file diff; `old_path` is the rename or copy source and `binary` files have no hunks.
|
||||
pub fn new(
|
||||
path: String,
|
||||
old_path: Option<String>,
|
||||
status: DiffStatus,
|
||||
insertions: usize,
|
||||
deletions: usize,
|
||||
binary: bool,
|
||||
hunks: Vec<DiffHunk>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
old_path,
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary,
|
||||
hunks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommitDiff {
|
||||
pub files: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
impl CommitDiff {
|
||||
/// Creates a commit diff from its files.
|
||||
pub fn new(files: Vec<FileDiff>) -> Self {
|
||||
Self { files }
|
||||
}
|
||||
impl Repo {
|
||||
// Compared against the first parent; the empty tree for the root commit.
|
||||
pub fn commit_diff(&self, id: &str) -> Result<CommitDiff> {
|
||||
let commit_id = self.inner.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,
|
||||
};
|
||||
Self::tree_diff(self, old_tree.as_ref(), &new_tree)
|
||||
}
|
||||
|
||||
pub(crate) struct HunkBuilder {
|
||||
old_start: u32,
|
||||
old_lines: u32,
|
||||
new_start: u32,
|
||||
new_lines: u32,
|
||||
old: u32,
|
||||
new: u32,
|
||||
lines: Vec<DiffLine>,
|
||||
insertions: usize,
|
||||
deletions: usize,
|
||||
// Directories and submodules are skipped, files are sorted by path.
|
||||
pub fn range_diff(&self, base: &str, tip: &str) -> Result<CommitDiff> {
|
||||
let base_tree = self
|
||||
.inner
|
||||
.rev_parse_single(base.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
let tip_tree = self
|
||||
.inner
|
||||
.rev_parse_single(tip.as_bytes())?
|
||||
.object()?
|
||||
.into_commit()
|
||||
.tree()?;
|
||||
Self::tree_diff(self, Some(&base_tree), &tip_tree)
|
||||
}
|
||||
|
||||
impl HunkBuilder {
|
||||
/// Starts a hunk spanning the given old and new ranges.
|
||||
pub(crate) fn new(old_start: u32, old_lines: u32, new_start: u32, new_lines: u32) -> Self {
|
||||
Self {
|
||||
old_start,
|
||||
old_lines,
|
||||
new_start,
|
||||
new_lines,
|
||||
old: old_start,
|
||||
new: new_start,
|
||||
lines: Vec::new(),
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
fn tree_diff(
|
||||
repo: &Repo,
|
||||
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;
|
||||
|
||||
/// Appends a line, numbering it and counting insertions and deletions.
|
||||
pub(crate) fn push(&mut self, kind: DiffLineKind, text: impl Into<String>) {
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(self.old), Some(self.new));
|
||||
self.old += 1;
|
||||
self.new += 1;
|
||||
numbers
|
||||
let changes = repo
|
||||
.inner
|
||||
.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
|
||||
|
||||
let mut cache = repo.inner.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.inner, &repo.inner);
|
||||
|
||||
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)
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
self.insertions += 1;
|
||||
let number = Some(self.new);
|
||||
self.new += 1;
|
||||
(None, number)
|
||||
Change::Deletion {
|
||||
location,
|
||||
entry_mode,
|
||||
..
|
||||
} if !matches!(entry_mode.kind(), EntryKind::Tree | EntryKind::Commit) => {
|
||||
(location.to_owned(), None, DiffStatus::Deleted)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
self.deletions += 1;
|
||||
let number = Some(self.old);
|
||||
self.old += 1;
|
||||
(number, None)
|
||||
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,
|
||||
};
|
||||
|
||||
// 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")
|
||||
}
|
||||
};
|
||||
|
||||
self.lines
|
||||
.push(DiffLine::new(kind, old_no, new_no, text.into()));
|
||||
}
|
||||
|
||||
/// Finishes the hunk and returns it with its insertion and deletion counts.
|
||||
pub(crate) fn finish(self) -> (DiffHunk, usize, usize) {
|
||||
let hunk = DiffHunk::new(
|
||||
self.old_start,
|
||||
self.old_lines,
|
||||
self.new_start,
|
||||
self.new_lines,
|
||||
self.lines,
|
||||
);
|
||||
(hunk, self.insertions, self.deletions)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
}
|
||||
|
||||
impl<'a> HunkCollector<'a> {
|
||||
/// Collects hunks into `hunks` while counting `insertions` and `deletions`.
|
||||
pub(crate) fn new(
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
hunks,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
struct HunkCollector<'a> {
|
||||
hunks: &'a mut Vec<DiffHunk>,
|
||||
insertions: &'a mut usize,
|
||||
deletions: &'a mut usize,
|
||||
}
|
||||
|
||||
impl ConsumeHunk for HunkCollector<'_> {
|
||||
type Out = ();
|
||||
|
||||
/// Consumes one hunk, tracking line numbers and insertion/deletion counts.
|
||||
fn consume_hunk(
|
||||
&mut self,
|
||||
header: HunkHeader,
|
||||
lines: &[(GixLineKind, &[u8])],
|
||||
) -> std::io::Result<()> {
|
||||
let mut builder = HunkBuilder::new(
|
||||
header.before_hunk_start,
|
||||
header.before_hunk_len,
|
||||
header.after_hunk_start,
|
||||
header.after_hunk_len,
|
||||
);
|
||||
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 kind = match kind {
|
||||
GixLineKind::Context => DiffLineKind::Context,
|
||||
GixLineKind::Remove => DiffLineKind::Deletion,
|
||||
GixLineKind::Add => DiffLineKind::Addition,
|
||||
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,
|
||||
};
|
||||
builder.push(kind, String::from_utf8_lossy(content).into_owned());
|
||||
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);
|
||||
}
|
||||
|
||||
let (hunk, insertions, deletions) = builder.finish();
|
||||
*self.insertions += insertions;
|
||||
*self.deletions += deletions;
|
||||
self.hunks.push(hunk);
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Marks the end of the unified diff stream.
|
||||
fn finish(self) {}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct CommitList {
|
||||
pub total: usize,
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
use anyhow::Result;
|
||||
|
||||
impl CommitList {
|
||||
/// Creates a list holding `total` commits of which `commits` are materialized.
|
||||
pub fn new(total: usize, commits: Vec<FileCommit>) -> Self {
|
||||
Self { total, commits }
|
||||
}
|
||||
}
|
||||
use crate::repo::Repo;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileCommit {
|
||||
@@ -22,47 +15,27 @@ pub struct FileCommit {
|
||||
}
|
||||
|
||||
impl FileCommit {
|
||||
/// Creates a commit entry from its id, message parts, author, and timestamp.
|
||||
pub fn new(
|
||||
id: String,
|
||||
summary: String,
|
||||
description: Option<String>,
|
||||
author: String,
|
||||
time: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
summary,
|
||||
description,
|
||||
author,
|
||||
time,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a gix commit including its message body.
|
||||
pub(crate) fn from_commit(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||
fn from_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
Self::from_commit_with_description(commit, true)
|
||||
}
|
||||
|
||||
/// Converts a gix commit, skipping the message body since lists never show it.
|
||||
pub(crate) fn from_commit_summary(commit: &gix::Commit<'_>) -> anyhow::Result<Self> {
|
||||
// History lists never display the body,
|
||||
// skipping it saves an allocation per listed commit.
|
||||
fn from_commit_summary(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||
Self::from_commit_with_description(commit, false)
|
||||
}
|
||||
|
||||
/// Converts a gix commit, optionally skipping the message body.
|
||||
fn from_commit_with_description(
|
||||
commit: &gix::Commit<'_>,
|
||||
include_description: bool,
|
||||
) -> anyhow::Result<Self> {
|
||||
use crate::GixResultExt as _;
|
||||
) -> Result<FileCommit> {
|
||||
let author = commit.author()?;
|
||||
let message = commit.message()?;
|
||||
|
||||
let author = commit.author().into_anyhow()?;
|
||||
let message = commit.message().into_anyhow()?;
|
||||
|
||||
Ok(FileCommit::new(
|
||||
commit.id().shorten_or_id().to_string(),
|
||||
String::from_utf8_lossy(message.title).trim().to_string(),
|
||||
if include_description {
|
||||
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())
|
||||
@@ -70,8 +43,154 @@ impl FileCommit {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
author.time().into_anyhow()?.seconds,
|
||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
||||
time: author.time()?.seconds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
// Paths without any commit, like untracked files, are absent from the result.
|
||||
pub fn last_commits(&self, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
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 = self
|
||||
.inner
|
||||
.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.
|
||||
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(), FileCommit::from_commit(&commit)?));
|
||||
pending.swap_remove(ix);
|
||||
} else {
|
||||
ix += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn all_commits(&self) -> Result<CommitList> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(CommitList {
|
||||
total: 0,
|
||||
commits: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let walk = self
|
||||
.inner
|
||||
.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(FileCommit::from_commit_summary(&info.object()?)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CommitList { total, commits })
|
||||
}
|
||||
|
||||
pub fn commit_range(&self, base: &str, tip: &str) -> Result<Vec<FileCommit>> {
|
||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||
|
||||
let base_id = self.inner.rev_parse_single(base.as_bytes())?;
|
||||
let tip_id = self.inner.rev_parse_single(tip.as_bytes())?;
|
||||
let walk = self
|
||||
.inner
|
||||
.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(FileCommit::from_commit_summary(&info.object()?)?);
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
// `Ok(None)` for an unborn HEAD.
|
||||
pub fn head_commit(&self) -> Result<Option<FileCommit>> {
|
||||
let Some(head) = self.inner.head_id().ok() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let commit = head.object()?.into_commit();
|
||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||
}
|
||||
|
||||
// `Ok(None)` when the id cannot be resolved.
|
||||
pub fn commit(&self, id: &str) -> Result<Option<FileCommit>> {
|
||||
match self.inner.rev_parse_single(id.as_bytes()) {
|
||||
Ok(commit_id) => {
|
||||
let commit = commit_id.object()?.into_commit();
|
||||
Ok(Some(FileCommit::from_commit(&commit)?))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
pub struct CommitList {
|
||||
pub total: usize,
|
||||
pub commits: Vec<FileCommit>,
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ mod diff;
|
||||
mod history;
|
||||
mod nip34;
|
||||
mod patch;
|
||||
mod remote;
|
||||
mod repo;
|
||||
mod scan;
|
||||
mod sync;
|
||||
mod worktree;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -18,23 +18,8 @@ pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
|
||||
pub use patch::PatchParser;
|
||||
pub use repo::{Repo, RepoRefState};
|
||||
pub use scan::{LocalRepo, find_git_repos};
|
||||
pub use sync::{RefSync, RepoSyncStatus};
|
||||
pub use worktree::WorktreeSnapshot;
|
||||
|
||||
pub(crate) trait GixResultExt<T> {
|
||||
/// Converts a gix exception result into an `anyhow` result.
|
||||
fn into_anyhow(self) -> anyhow::Result<T>;
|
||||
}
|
||||
|
||||
impl<T, E> GixResultExt<T> for Result<T, gix::Exn<E>>
|
||||
where
|
||||
E: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
fn into_anyhow(self) -> anyhow::Result<T> {
|
||||
self.map_err(|exn| anyhow::Error::from(exn.into_error()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
let output = Repo::run_git(dir, args, "git")?;
|
||||
|
||||
+412
-15
@@ -1,15 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use gix::bstr::ByteSlice;
|
||||
use nostr::prelude::*;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct Nip34Json {
|
||||
pub(crate) identifier: Option<String>,
|
||||
pub(crate) owner: Option<String>,
|
||||
}
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// The kind of NIP-34 relationship a local repository has on disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Nip34Kind {
|
||||
// By `nak`'s `nip34.json` or `ngit`'s `nostr.repo`.
|
||||
Initialized,
|
||||
// Cloned from a `nostr://` remote but never initialized locally.
|
||||
Cloned,
|
||||
// Nostr tooling touched the repository but no binding is recoverable.
|
||||
ToolingOnly,
|
||||
}
|
||||
|
||||
@@ -20,6 +22,7 @@ pub struct GraspSignals {
|
||||
pub nostr_repo_config: bool,
|
||||
pub nostr_remote: bool,
|
||||
pub grasp_remote: bool,
|
||||
// `nak`-specific `nip34/grasp/<host>` remote name.
|
||||
pub nip34_grasp_remote: bool,
|
||||
pub nip34_state_refs: bool,
|
||||
pub nostr_cache: bool,
|
||||
@@ -28,36 +31,430 @@ pub struct GraspSignals {
|
||||
}
|
||||
|
||||
impl GraspSignals {
|
||||
/// Returns whether any Nostr tooling marker was found.
|
||||
pub fn any(&self) -> bool {
|
||||
*self != Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a local repository's on-disk state says about its NIP-34 binding.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Nip34Binding {
|
||||
pub kind: Nip34Kind,
|
||||
pub signals: GraspSignals,
|
||||
// From `nip34.json` or `nostr.repo`.
|
||||
pub owner: Option<PublicKey>,
|
||||
pub identifier: Option<String>,
|
||||
pub grasp_urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl Nip34Binding {
|
||||
/// Creates a binding from its kind, detected signals, and recovered coordinates.
|
||||
pub fn new(
|
||||
kind: Nip34Kind,
|
||||
signals: GraspSignals,
|
||||
owner: Option<PublicKey>,
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Nip34Json {
|
||||
identifier: Option<String>,
|
||||
grasp_urls: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
owner: Option<String>,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
// `None` when no Nostr tooling left a marker.
|
||||
pub fn nip34_binding(&self) -> Option<Nip34Binding> {
|
||||
let common_dir = self.inner.common_dir().to_path_buf();
|
||||
let workdir = self.inner.workdir().map(std::path::Path::to_path_buf);
|
||||
|
||||
let mut signals = GraspSignals::default();
|
||||
let mut owner: Option<PublicKey> = None;
|
||||
let mut identifier: Option<String> = None;
|
||||
let mut grasp_urls: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(workdir) = &workdir {
|
||||
if let Ok(bytes) = std::fs::read(workdir.join("nip34.json"))
|
||||
&& let Ok(config) = serde_json::from_slice::<Nip34Json>(&bytes)
|
||||
{
|
||||
signals.nip34_json = true;
|
||||
identifier = config.identifier.and_then(Self::non_empty);
|
||||
owner = config
|
||||
.owner
|
||||
.as_deref()
|
||||
.and_then(|value| PublicKey::parse(value).ok());
|
||||
}
|
||||
|
||||
if workdir.join("maintainers.yaml").is_file() {
|
||||
signals.maintainers_yaml = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(exclude) = std::fs::read_to_string(common_dir.join("info/exclude"))
|
||||
&& exclude.contains("nip34.json")
|
||||
{
|
||||
signals.nip34_excluded = true;
|
||||
}
|
||||
|
||||
// `ngit` keeps its repository event cache in the Git common directory.
|
||||
if common_dir.join("nostr-cache.lmdb").is_file() {
|
||||
signals.nostr_cache = true;
|
||||
}
|
||||
|
||||
// `ngit` reads and writes `nostr.repo` at repository-local scope only.
|
||||
if let Ok(config) = gix::config::File::from_path_no_includes(
|
||||
common_dir.join("config"),
|
||||
gix::config::Source::Local,
|
||||
) {
|
||||
if let Some(value) = config.string("nostr.repo")
|
||||
&& let Some((key, id)) = Self::coordinate_from_naddr(&value.to_str_lossy())
|
||||
{
|
||||
signals.nostr_repo_config = true;
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
|
||||
for key in ["nostr.repo-relay-only", "nostr.nostate", "nostr.private"] {
|
||||
if config.string(key).is_some() {
|
||||
signals.nostr_aux_config = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sections) = config.sections_by_name("remote") {
|
||||
for section in sections {
|
||||
let Some(name) = section.header().subsection_name() else {
|
||||
continue;
|
||||
};
|
||||
let nak_grasp_remote = name.to_str_lossy().starts_with("nip34/grasp/");
|
||||
|
||||
for url in section.values("url") {
|
||||
let url = url.to_str_lossy();
|
||||
|
||||
if url.starts_with("nostr://") {
|
||||
signals.nostr_remote = true;
|
||||
// Strong markers win; only fill an empty binding.
|
||||
if owner.is_none()
|
||||
&& identifier.is_none()
|
||||
&& let Some((key, id)) = Self::parse_nostr_url(&url)
|
||||
{
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
if Self::is_grasp_url(&url) {
|
||||
signals.grasp_remote = true;
|
||||
signals.nip34_grasp_remote |= nak_grasp_remote;
|
||||
grasp_urls.push(url.to_string());
|
||||
|
||||
if owner.is_none()
|
||||
&& identifier.is_none()
|
||||
&& let Some((key, id)) = Self::grasp_parts(&url)
|
||||
{
|
||||
owner = Some(key);
|
||||
identifier = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `nak` materializes a kind-30618 state as `refs/heads/nip34/state/*`.
|
||||
if let Ok(platform) = self.inner.references()
|
||||
&& let Ok(mut refs) = platform.prefixed(b"refs/heads/nip34/state/")
|
||||
&& refs.next().is_some()
|
||||
{
|
||||
signals.nip34_state_refs = true;
|
||||
}
|
||||
|
||||
if !signals.any() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kind = if signals.nip34_json
|
||||
|| signals.nostr_repo_config
|
||||
|| signals.nip34_grasp_remote
|
||||
|| signals.nip34_state_refs
|
||||
{
|
||||
Nip34Kind::Initialized
|
||||
} else if signals.nostr_remote {
|
||||
Nip34Kind::Cloned
|
||||
} else {
|
||||
Nip34Kind::ToolingOnly
|
||||
};
|
||||
|
||||
Some(Nip34Binding {
|
||||
kind,
|
||||
signals,
|
||||
owner,
|
||||
identifier,
|
||||
grasp_urls,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_nostr_repo(&self, naddr: &str) -> Result<()> {
|
||||
self.edit_local_config(|config| {
|
||||
config.set_raw_value("nostr.repo", naddr)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// Mirrors `nak`'s `IsGraspURL`: two path segments,
|
||||
// a path of at least 65 bytes, and a first segment that decodes as an `npub`.
|
||||
fn is_grasp_url(url: &str) -> bool {
|
||||
let Ok(parsed) = Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !matches!(parsed.scheme(), "http" | "https" | "grasp") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = parsed.path();
|
||||
if path.matches('/').count() != 2 || path.len() < 65 {
|
||||
return false;
|
||||
}
|
||||
|
||||
Self::grasp_parts(url).is_some()
|
||||
}
|
||||
|
||||
fn grasp_parts(url: &str) -> Option<(PublicKey, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let mut segments = parsed.path_segments()?.filter(|part| !part.is_empty());
|
||||
|
||||
let owner = PublicKey::parse(segments.next()?).ok()?;
|
||||
let identifier = Self::non_empty(segments.next()?.trim_end_matches(".git"))?;
|
||||
|
||||
Some((owner, identifier))
|
||||
}
|
||||
|
||||
fn coordinate_from_naddr(value: &str) -> Option<(PublicKey, String)> {
|
||||
let coordinate = Nip19Coordinate::from_bech32(value).ok()?;
|
||||
if coordinate.kind != Kind::GitRepoAnnouncement {
|
||||
return None;
|
||||
}
|
||||
|
||||
let identifier = Self::non_empty(coordinate.identifier.clone())?;
|
||||
Some((coordinate.public_key, identifier))
|
||||
}
|
||||
|
||||
// Handles a bare `naddr`, an `npub`, and the optional `[ssh-key-file@]`,
|
||||
// `[protocol/]` and `[relay/]` components. An `nip05` owner yields no binding.
|
||||
fn parse_nostr_url(url: &str) -> Option<(PublicKey, String)> {
|
||||
let rest = url.strip_prefix("nostr://")?;
|
||||
|
||||
if rest.starts_with("naddr1") {
|
||||
return Self::coordinate_from_naddr(rest);
|
||||
}
|
||||
|
||||
let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after);
|
||||
let mut parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
|
||||
|
||||
if parts
|
||||
.first()
|
||||
.is_some_and(|first| matches!(*first, "ssh" | "https" | "http"))
|
||||
{
|
||||
parts.remove(0);
|
||||
}
|
||||
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let owner = PublicKey::parse(parts[0]).ok()?;
|
||||
let identifier = Self::non_empty(parts.last()?.trim_end_matches(".git"))?;
|
||||
|
||||
Some((owner, identifier))
|
||||
}
|
||||
|
||||
fn non_empty(value: impl Into<String>) -> Option<String> {
|
||||
let value = value.into();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn init_repo() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("repo");
|
||||
std::fs::create_dir_all(&path).expect("mkdir");
|
||||
git(&path, &["init", "-q"]);
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.env("GIT_AUTHOR_NAME", "Test Author")
|
||||
.env("GIT_AUTHOR_EMAIL", "test@example.com")
|
||||
.env("GIT_COMMITTER_NAME", "Test Author")
|
||||
.env("GIT_COMMITTER_EMAIL", "test@example.com")
|
||||
.env("GIT_EDITOR", "true")
|
||||
.args(args)
|
||||
.status()
|
||||
.expect("spawn git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn key() -> PublicKey {
|
||||
Keys::generate().public_key()
|
||||
}
|
||||
|
||||
fn naddr(kind: Kind, owner: PublicKey, identifier: &str) -> String {
|
||||
let coordinate = Coordinate::new(kind, owner).identifier(identifier);
|
||||
Nip19Coordinate::new(coordinate, Vec::<RelayUrl>::new())
|
||||
.to_bech32()
|
||||
.expect("naddr")
|
||||
}
|
||||
|
||||
fn binding_of(path: &Path) -> Option<Nip34Binding> {
|
||||
Repo::open(path).ok()?.nip34_binding()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_repository_has_no_binding() {
|
||||
let (_dir, path) = init_repo();
|
||||
assert!(binding_of(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip34_json_marks_a_repository_initialized() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
std::fs::write(
|
||||
path.join("nip34.json"),
|
||||
format!(r#"{{"identifier":"my-repo","owner":"{npub}"}}"#),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nip34_json);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_nip34_json_is_ignored() {
|
||||
let (_dir, path) = init_repo();
|
||||
std::fs::write(path.join("nip34.json"), b"not json").expect("write");
|
||||
|
||||
assert!(binding_of(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nak_exclude_and_state_refs_are_detected() {
|
||||
let (_dir, path) = init_repo();
|
||||
|
||||
std::fs::create_dir_all(path.join(".git/info")).expect("mkdir");
|
||||
std::fs::write(path.join(".git/info/exclude"), "nip34.json\n").expect("write");
|
||||
|
||||
git(&path, &["commit", "-q", "--allow-empty", "-m", "initial"]);
|
||||
git(
|
||||
&path,
|
||||
&["update-ref", "refs/heads/nip34/state/HEAD", "HEAD"],
|
||||
);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nip34_excluded);
|
||||
assert!(binding.signals.nip34_state_refs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nostr_repo_config_marks_a_repository_initialized() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||
git(&path, &["config", "nostr.repo", &naddr]);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nostr_repo_config);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_nostr_repo_marker_is_detected() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let naddr = naddr(Kind::GitRepoAnnouncement, owner, "my-repo");
|
||||
|
||||
Repo::open(&path)
|
||||
.expect("open")
|
||||
.set_nostr_repo(&naddr)
|
||||
.expect("write marker");
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nostr_repo_config);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nostr_remote_is_a_nip34_clone() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
let url = format!("nostr://{npub}/relay.ngit.dev/my-repo");
|
||||
git(&path, &["remote", "add", "origin", &url]);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Cloned);
|
||||
assert!(binding.signals.nostr_remote);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nak_grasp_remote_marks_a_repository_initialized() {
|
||||
let (_dir, path) = init_repo();
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
let url = format!("https://gitnostr.com/{npub}/my-repo.git");
|
||||
git(
|
||||
&path,
|
||||
&["config", "remote.nip34/grasp/gitnostr.com.url", &url],
|
||||
);
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::Initialized);
|
||||
assert!(binding.signals.nip34_grasp_remote);
|
||||
assert!(binding.signals.grasp_remote);
|
||||
assert_eq!(binding.grasp_urls, vec![url]);
|
||||
assert_eq!(binding.owner, Some(owner));
|
||||
assert_eq!(binding.identifier.as_deref(), Some("my-repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nostr_cache_alone_is_tooling_only() {
|
||||
let (_dir, path) = init_repo();
|
||||
std::fs::write(path.join(".git/nostr-cache.lmdb"), b"cache").expect("write");
|
||||
|
||||
let binding = binding_of(&path).expect("binding");
|
||||
assert_eq!(binding.kind, Nip34Kind::ToolingOnly);
|
||||
assert!(binding.signals.nostr_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grasp_urls_are_recognised_by_shape() {
|
||||
let owner = key();
|
||||
let npub = owner.to_bech32().expect("npub");
|
||||
|
||||
assert!(Repo::is_grasp_url(&format!(
|
||||
"https://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
assert!(Repo::is_grasp_url(&format!(
|
||||
"grasp://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
|
||||
assert!(!Repo::is_grasp_url("https://gitnostr.com/my-repo.git"));
|
||||
assert!(!Repo::is_grasp_url(
|
||||
"https://gitnostr.com/not-a-pubkey/my-repo.git"
|
||||
));
|
||||
assert!(!Repo::is_grasp_url(&format!(
|
||||
"ssh://gitnostr.com/{npub}/my-repo.git"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
+162
-52
@@ -1,14 +1,18 @@
|
||||
use anyhow::Result;
|
||||
use std::io::Write;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||
use diffy::{Hunk, Line};
|
||||
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLineKind, DiffStatus, FileDiff, HunkBuilder};
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
use crate::history::FileCommit;
|
||||
use crate::repo::Repo;
|
||||
|
||||
pub struct PatchParser;
|
||||
|
||||
impl PatchParser {
|
||||
/// Splits an mbox series into the raw text of each message.
|
||||
// A malformed input yields one element covering it.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
@@ -16,49 +20,50 @@ impl PatchParser {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses a git diff into file diffs, returning an empty diff without one.
|
||||
// Backed by `diffy::patch_set`, which implements git's extended diff format:
|
||||
// rename and copy detection, binary detection, quoted/escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff::new(Vec::new()));
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(Self::file_diff(file?));
|
||||
files.push(Self::file_diff(file?)?);
|
||||
}
|
||||
|
||||
Ok(CommitDiff::new(files))
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
/// Lists the commits represented by the mbox messages that carry an id.
|
||||
// Entries appear in patch order, oldest first as `git format-patch`
|
||||
// produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
.filter(|message| !message.id.is_empty())
|
||||
.map(|message| {
|
||||
FileCommit::new(
|
||||
message.id.to_string(),
|
||||
message
|
||||
.map(|message| FileCommit {
|
||||
id: message.id.to_string(),
|
||||
summary: message
|
||||
.header("Subject")
|
||||
.map(Self::strip_patch_prefix)
|
||||
.unwrap_or_default(),
|
||||
None,
|
||||
message
|
||||
description: None,
|
||||
author: message
|
||||
.header("From")
|
||||
.map(Self::name_from_address)
|
||||
.unwrap_or_default(),
|
||||
message
|
||||
time: message
|
||||
.header("Date")
|
||||
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
||||
.map(|time| time.seconds)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Splits `patch` on `From <40-hex> <date>` envelopes, falling back to one message.
|
||||
// The one parser `split_patch_series` and `patch_commits` share.
|
||||
// A malformed input yields one message covering the whole input.
|
||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||
@@ -76,7 +81,11 @@ impl PatchParser {
|
||||
|
||||
if is_envelope {
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||
messages.push(Envelope {
|
||||
text: &patch[start..],
|
||||
id,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
let id = line
|
||||
@@ -102,17 +111,24 @@ impl PatchParser {
|
||||
}
|
||||
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope::new(&patch[start..], id, headers));
|
||||
messages.push(Envelope {
|
||||
text: &patch[start..],
|
||||
id,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
messages.push(Envelope::new(patch, "", Vec::new()));
|
||||
messages.push(Envelope {
|
||||
text: patch,
|
||||
id: "",
|
||||
headers: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
/// Extracts the display name from a `From` header value.
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
@@ -120,7 +136,7 @@ impl PatchParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strips mailing list prefixes like `[PATCH]` or `[RFC PATCH 1/2]`.
|
||||
// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
@@ -136,8 +152,10 @@ impl PatchParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts one parsed file patch, keeping rename and copy paths unstripped.
|
||||
fn file_diff(file: FilePatch<'_, str>) -> FileDiff {
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, 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,
|
||||
@@ -169,35 +187,41 @@ impl PatchParser {
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let (hunk, hunk_insertions, hunk_deletions) = Self::hunk_diff(hunk);
|
||||
insertions += hunk_insertions;
|
||||
deletions += hunk_deletions;
|
||||
let hunk = Self::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);
|
||||
}
|
||||
}
|
||||
|
||||
FileDiff::new(
|
||||
path.to_owned(),
|
||||
old_path.map(str::to_owned),
|
||||
Ok(FileDiff {
|
||||
path: path.to_owned(),
|
||||
old_path: old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
patch.is_binary(),
|
||||
binary: patch.is_binary(),
|
||||
hunks,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a diffy hunk into a `DiffHunk` with its insertion and deletion counts.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> (DiffHunk, usize, usize) {
|
||||
// `diffy` reports only the hunk header ranges; the per-line numbers are
|
||||
// counted from them the way the header encodes them.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
let mut builder = HunkBuilder::new(
|
||||
old_range.start() as u32,
|
||||
old_range.len() as u32,
|
||||
new_range.start() as u32,
|
||||
new_range.len() as u32,
|
||||
);
|
||||
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 {
|
||||
@@ -206,28 +230,56 @@ impl PatchParser {
|
||||
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: Self::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,
|
||||
}
|
||||
}
|
||||
|
||||
fn line_text(text: &str) -> String {
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
let text = text.strip_suffix('\r').unwrap_or(text);
|
||||
builder.push(kind, text.to_owned());
|
||||
}
|
||||
|
||||
builder.finish()
|
||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
|
||||
struct Envelope<'a> {
|
||||
text: &'a str,
|
||||
id: &'a str,
|
||||
headers: Vec<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> Envelope<'a> {
|
||||
/// Creates an envelope from its text, commit id, and header lines.
|
||||
fn new(text: &'a str, id: &'a str, headers: Vec<&'a str>) -> Self {
|
||||
Self { text, id, headers }
|
||||
}
|
||||
|
||||
/// Returns the first header line matching `name: `.
|
||||
impl Envelope<'_> {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
let prefix = format!("{name}: ");
|
||||
self.headers
|
||||
@@ -236,3 +288,61 @@ impl<'a> Envelope<'a> {
|
||||
.map(|line| &line[prefix.len()..])
|
||||
}
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
// The git CLI handles the mbox format natively.
|
||||
// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(&self, patch: &str) -> Result<()> {
|
||||
let workdir = self
|
||||
.inner
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
let mut child = Repo::git_command(&workdir)
|
||||
.args(["am"])
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git am`")?;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.context("git am has no stdin pipe")?
|
||||
.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(())
|
||||
}
|
||||
|
||||
// Fails when the range has no commits. The mbox is returned untrimmed;
|
||||
// trailing newlines are part of the format.
|
||||
pub fn format_patch_between(&self, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Repo::run_git(
|
||||
self.workdir_or_dot(),
|
||||
&["format-patch", "--stdout", &format!("{base}..{tip}")],
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
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;
|
||||
|
||||
use crate::repo::Repo;
|
||||
|
||||
impl Repo {
|
||||
pub fn fetch(&self) -> 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()
|
||||
};
|
||||
self.inner
|
||||
.find_remote("origin")?
|
||||
.connect(gix::remote::Direction::Fetch)?
|
||||
.prepare_fetch(Discard, options)?
|
||||
.receive(Discard, &IS_INTERRUPTED)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_ref(&self, url: &str, commit: &str, reference: &str) -> Result<()> {
|
||||
let output = Self::run_git(
|
||||
self.workdir_or_dot(),
|
||||
&["push", url, &format!("{commit}:{reference}")],
|
||||
"git push",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_main(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/main:refs/heads/main"],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn push_all(&self, base_url: &str, owner: &str, repo_id: &str) -> Result<()> {
|
||||
self.push_refspecs(
|
||||
base_url,
|
||||
owner,
|
||||
repo_id,
|
||||
&["refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_refspecs(
|
||||
&self,
|
||||
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 = Self::run_git(self.workdir_or_dot(), &args, "git push")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git push to {base_url} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// The convergence probe for a push that lost the compare-and-swap race to
|
||||
// the grasp server's own background ref alignment. Extra advertised refs
|
||||
// are ignored: the question is whether the pushed data is already there,
|
||||
// not whether the remote is an exact mirror.
|
||||
pub fn remote_has_refs(&self, url: &str, expected: &[(String, String)]) -> Result<bool> {
|
||||
if expected.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let url = Self::transport_url(url);
|
||||
|
||||
// A URL-created remote has no configured fetch refspecs, and `ref_map`
|
||||
// only keeps refs matching one; match each expected ref by exact name.
|
||||
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, _) = self
|
||||
.inner
|
||||
.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, matching
|
||||
// `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)))
|
||||
}
|
||||
|
||||
pub fn ensure_origin(&self, url: &str) -> Result<()> {
|
||||
if self.inner.find_remote("origin").is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `git remote add` also configures the default fetch refspec.
|
||||
self.edit_local_config(|config| {
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
config.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// A working copy cloned from a local mirror is re-targeted at the
|
||||
// grasp server; a pre-existing fetch refspec is left untouched.
|
||||
pub fn set_origin(&self, url: &str) -> Result<()> {
|
||||
let had_origin = self.inner.find_remote("origin").is_ok();
|
||||
|
||||
self.edit_local_config(|config| {
|
||||
config.set_raw_value("remote.origin.url", url)?;
|
||||
|
||||
if !had_origin {
|
||||
config
|
||||
.set_raw_value("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn origin_url(&self) -> Result<Option<String>> {
|
||||
let Ok(remote) = self.inner.find_remote("origin") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(remote
|
||||
.url(gix::remote::Direction::Fetch)
|
||||
.map(|url| url.to_string()))
|
||||
}
|
||||
|
||||
// Never touches the checked-out refs or the worktree. The last error is
|
||||
// returned when no URL works.
|
||||
pub fn fetch_refs<U: AsRef<str>>(&self, urls: &[U], refspec: &str) -> Result<()> {
|
||||
let refspec = gix::refspec::parse(
|
||||
gix::bstr::BStr::new(refspec),
|
||||
gix::refspec::parse::Operation::Fetch,
|
||||
)
|
||||
.context("invalid fetch refspec")?
|
||||
.to_owned();
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for url in urls {
|
||||
let url = Self::transport_url(url.as_ref());
|
||||
let outcome = (|| -> Result<()> {
|
||||
let options = gix::remote::ref_map::Options {
|
||||
extra_refspecs: vec![refspec.clone()],
|
||||
..Default::default()
|
||||
};
|
||||
self.inner
|
||||
.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(())
|
||||
})();
|
||||
|
||||
match outcome {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
match last_error {
|
||||
Some(error) => Err(error).context("failed to fetch from any mirror"),
|
||||
None => bail!("no fetch URLs provided"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn edit_local_config(
|
||||
&self,
|
||||
edit: impl FnOnce(&mut gix::config::File) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
let config_path = self.inner.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,
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) fn workdir_or_dot(&self) -> &Path {
|
||||
self.inner.workdir().unwrap_or_else(|| Path::new("."))
|
||||
}
|
||||
|
||||
pub(crate) fn run_git(dir: &Path, args: &[&str], what: &str) -> Result<std::process::Output> {
|
||||
Self::git_command(dir)
|
||||
.args(args)
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn `{what}`"))
|
||||
}
|
||||
|
||||
pub(crate) fn git_command(dir: &Path) -> Command {
|
||||
let mut command = Command::new("git");
|
||||
command.arg("-C").arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||
command
|
||||
}
|
||||
}
|
||||
+56
-1450
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
use crate::nip34::Nip34Binding;
|
||||
use crate::repo::Repo;
|
||||
|
||||
// Caps nesting so pathological trees can't stall the scan.
|
||||
const SCAN_MAX_DEPTH: usize = 12;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -14,69 +14,41 @@ pub struct LocalRepo {
|
||||
pub nip34: Option<Nip34Binding>,
|
||||
}
|
||||
|
||||
impl LocalRepo {
|
||||
/// Creates a local repository entry from its path and optional NIP-34 binding.
|
||||
pub fn new(path: PathBuf, nip34: Option<Nip34Binding>) -> Self {
|
||||
Self { path, nip34 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds git repositories under `root` up to `SCAN_MAX_DEPTH` deep, without descending into them.
|
||||
pub fn find_git_repos(root: &Path) -> Vec<LocalRepo> {
|
||||
let Ok(root) = root.canonicalize() else {
|
||||
if !root.is_dir() {
|
||||
return Vec::new();
|
||||
};
|
||||
}
|
||||
|
||||
let found = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
|
||||
|
||||
let walker = WalkBuilder::new(&root)
|
||||
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)
|
||||
.filter_entry({
|
||||
let found = found.clone();
|
||||
move |entry| {
|
||||
let Some(kind) = entry.file_type() else {
|
||||
return true;
|
||||
};
|
||||
if !kind.is_dir() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dir = entry.path();
|
||||
let Ok(repo) = gix::discover(dir) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(workdir) = repo.workdir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if workdir != dir {
|
||||
return true;
|
||||
}
|
||||
|
||||
match found.lock() {
|
||||
Ok(mut repos) => repos.push(dir.to_path_buf()),
|
||||
Err(poisoned) => poisoned.into_inner().push(dir.to_path_buf()),
|
||||
}
|
||||
false
|
||||
}
|
||||
})
|
||||
.build();
|
||||
|
||||
for _ in walker {}
|
||||
let mut repos: 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();
|
||||
|
||||
let mut repos = match found.lock() {
|
||||
Ok(mut repos) => std::mem::take(&mut *repos),
|
||||
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
|
||||
};
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
|
||||
repos
|
||||
// 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
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let nip34 = Repo::open(&path).ok().and_then(|repo| repo.nip34_binding());
|
||||
LocalRepo::new(path, nip34)
|
||||
LocalRepo { path, nip34 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How a single branch compares between local and Nostr.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RefSync {
|
||||
InSync,
|
||||
LocalAhead { ahead: usize },
|
||||
RemoteAhead { behind: usize },
|
||||
Diverged { ahead: usize, behind: usize },
|
||||
LocalOnly,
|
||||
RemoteOnly,
|
||||
}
|
||||
|
||||
/// Branch comparison between a local repository and its Nostr state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoSyncStatus {
|
||||
/// Branch short name and its classification.
|
||||
pub refs: Vec<(String, RefSync)>,
|
||||
pub ahead_total: usize,
|
||||
pub behind_total: usize,
|
||||
}
|
||||
|
||||
impl RepoSyncStatus {
|
||||
/// True when every branch matches the Nostr state.
|
||||
pub fn in_sync(&self) -> bool {
|
||||
self.refs.iter().all(|(_, sync)| *sync == RefSync::InSync)
|
||||
}
|
||||
|
||||
/// Maps branch short name to lowercase commit oid.
|
||||
pub(crate) fn branches(refs: &[(String, String)]) -> BTreeMap<&str, String> {
|
||||
refs.iter()
|
||||
.filter_map(|(name, commit)| {
|
||||
name.strip_prefix("refs/heads/")
|
||||
.map(|branch| (branch, commit.to_ascii_lowercase()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+23
-321
@@ -1,9 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use nostr::prelude::Keys;
|
||||
use signed_core::RepoAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -14,70 +11,6 @@ fn blocks_parent_components() {
|
||||
assert_eq!(GitCache::sanitize_path_component("a/../b"), "a_.._b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_ensure_clone_succeeds_for_every_caller() {
|
||||
let server = tempfile::tempdir().expect("tempdir");
|
||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
||||
|
||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&work_repo, "initial");
|
||||
let work_dir = work_dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(work_dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "repo")
|
||||
.expect("push");
|
||||
|
||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
||||
let clone_url = format!("file://{}", server_repo.display());
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..4 {
|
||||
scope.spawn(|| {
|
||||
cache
|
||||
.ensure_clone(&addr, &[&clone_url])
|
||||
.expect("ensure_clone");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
assert!(cache.open(&addr).expect("open").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_clone_recovers_from_a_leftover_directory() {
|
||||
let server = tempfile::tempdir().expect("tempdir");
|
||||
let server_repo = bare_server(server.path(), "npub1test", "repo");
|
||||
|
||||
let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&work_repo, "initial");
|
||||
let work_dir = work_dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(work_dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "repo")
|
||||
.expect("push");
|
||||
|
||||
let cache_dir = tempfile::tempdir().expect("tempdir");
|
||||
let cache = GitCache::new(cache_dir.path().to_path_buf());
|
||||
let addr = RepoAddr::new(Keys::generate().public_key(), "repo");
|
||||
let clone_url = format!("file://{}", server_repo.display());
|
||||
|
||||
// Simulate the remains of an interrupted clone.
|
||||
let leftover = cache.repo_path(&addr);
|
||||
std::fs::create_dir_all(&leftover).expect("mkdir");
|
||||
|
||||
cache
|
||||
.ensure_clone(&addr, &[&clone_url])
|
||||
.expect("ensure_clone");
|
||||
|
||||
assert!(cache.open(&addr).expect("open").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_commit_reports_the_first_ancestor() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
@@ -123,221 +56,39 @@ fn push_all_mirrors_branches_and_tags() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ls_remote_lists_branches_and_tags_only() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
git_run(dir, &["tag", "-a", "v2", "-m", "annotated"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("push");
|
||||
|
||||
// Refs outside heads and tags, and the HEAD symref line, are not listed.
|
||||
let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid");
|
||||
git_run(&server_repo, &["update-ref", "refs/nostr/abc", &main]);
|
||||
|
||||
let remote = local
|
||||
.ls_remote(&format!("file://{}", server_repo.display()))
|
||||
.expect("ls-remote");
|
||||
let names: Vec<String> = remote.iter().map(|(name, _)| name.clone()).collect();
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"refs/heads/feature".to_owned(),
|
||||
"refs/heads/main".to_owned(),
|
||||
"refs/tags/v1".to_owned(),
|
||||
"refs/tags/v2".to_owned(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remote
|
||||
.iter()
|
||||
.find(|(name, _)| name == "refs/heads/main")
|
||||
.map(|(_, oid)| oid.as_str()),
|
||||
Some(main.as_str())
|
||||
);
|
||||
// The annotated tag lists the tag object, matching the local ref state,
|
||||
// without a peeled `^{}` duplicate.
|
||||
let v2 = git_in(dir, &["rev-parse", "refs/tags/v2"]).expect("v2 oid");
|
||||
assert_eq!(
|
||||
remote
|
||||
.iter()
|
||||
.find(|(name, _)| name == "refs/tags/v2")
|
||||
.map(|(_, oid)| oid.as_str()),
|
||||
Some(v2.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_all_force_updates_diverged_remote_branches() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
std::fs::write(dir.path().join("b.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "second");
|
||||
let dir = dir.path();
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("initial push");
|
||||
|
||||
// Diverge local main from the pushed state.
|
||||
git_run(dir, &["reset", "-q", "--hard", "HEAD~1"]);
|
||||
std::fs::write(dir.join("c.txt"), b"three").expect("write");
|
||||
commit_all(&repo, "diverged");
|
||||
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("force push");
|
||||
|
||||
assert_eq!(
|
||||
git_in(dir, &["rev-parse", "refs/heads/main"]).expect("local main"),
|
||||
git_in(&server_repo, &["rev-parse", "refs/heads/main"]).expect("server main")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_refspecs_deletes_server_refs_missing_locally() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
let server_repo = bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
let local = Repo::open(dir).expect("open");
|
||||
local
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
.expect("initial push");
|
||||
|
||||
git_run(dir, &["branch", "-D", "feature"]);
|
||||
git_run(dir, &["tag", "-d", "v1"]);
|
||||
|
||||
local
|
||||
.push_refspecs(
|
||||
&base_url,
|
||||
"npub1test",
|
||||
"my-repo",
|
||||
&[
|
||||
"+refs/heads/*:refs/heads/*",
|
||||
"+refs/tags/*:refs/tags/*",
|
||||
":refs/heads/feature",
|
||||
":refs/tags/v1",
|
||||
],
|
||||
)
|
||||
.expect("push with deletions");
|
||||
|
||||
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
|
||||
assert!(refs.contains("refs/heads/main"));
|
||||
assert!(!refs.contains("refs/heads/feature"));
|
||||
assert!(!refs.contains("refs/tags/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_refs_brings_server_only_branches_into_local() {
|
||||
fn remote_has_refs_reports_whether_pushed_refs_landed() {
|
||||
let server = tempfile::tempdir().unwrap();
|
||||
bare_server(server.path(), "npub1test", "my-repo");
|
||||
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
git_run(dir, &["branch", "feature"]);
|
||||
let main = git_in(dir, &["rev-parse", "refs/heads/main"]).expect("main oid");
|
||||
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
||||
let expected = vec![("refs/heads/main".to_owned(), main.clone())];
|
||||
let repo = Repo::open(dir).expect("open");
|
||||
|
||||
let base_url = format!("file://{}", server.path().display());
|
||||
Repo::open(dir)
|
||||
.expect("open")
|
||||
.push_all(&base_url, "npub1test", "my-repo")
|
||||
assert!(!repo.remote_has_refs(&url, &expected).expect("probe"));
|
||||
|
||||
repo.push_all(
|
||||
&format!("file://{}", server.path().display()),
|
||||
"npub1test",
|
||||
"my-repo",
|
||||
)
|
||||
.expect("push");
|
||||
let feature = git_in(dir, &["rev-parse", "refs/heads/feature"]).expect("feature oid");
|
||||
|
||||
// A repository that never had the branch fetches it by name.
|
||||
let (other_dir, _other_repo) = fixture(&[("unrelated.txt", b"x")]);
|
||||
let other = Repo::open(other_dir.path()).expect("open other");
|
||||
let server_url = format!("file://{}/npub1test/my-repo.git", server.path().display());
|
||||
other
|
||||
.fetch_refs(&[&server_url], "+refs/heads/feature:refs/heads/feature")
|
||||
.expect("fetch ref");
|
||||
assert!(repo.remote_has_refs(&url, &expected).expect("probe"));
|
||||
|
||||
assert_eq!(
|
||||
git_in(other_dir.path(), &["rev-parse", "refs/heads/feature"]).expect("fetched feature"),
|
||||
feature
|
||||
);
|
||||
}
|
||||
let stale = vec![("refs/heads/main".to_owned(), "0".repeat(40))];
|
||||
assert!(!repo.remote_has_refs(&url, &stale).expect("probe"));
|
||||
|
||||
#[test]
|
||||
fn ref_state_matches_the_git_cli_ref_listing() {
|
||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||
commit_all(&repo, "initial");
|
||||
let dir = dir.path();
|
||||
|
||||
git_run(dir, &["branch", "browse-group"]);
|
||||
git_run(dir, &["checkout", "-q", "-b", "new-search"]);
|
||||
std::fs::write(dir.join("b.txt"), b"two").expect("write");
|
||||
commit_all(&repo, "search work");
|
||||
git_run(dir, &["checkout", "-q", "-"]);
|
||||
git_run(dir, &["tag", "v1"]);
|
||||
|
||||
let listing = |repo: &Repo| {
|
||||
let mut refs = repo.ref_state().expect("ref state").refs;
|
||||
refs.sort();
|
||||
refs
|
||||
};
|
||||
|
||||
// Loose refs.
|
||||
let loose = listing(&Repo::open(dir).expect("open"));
|
||||
|
||||
// Packed refs.
|
||||
git_run(dir, &["pack-refs", "--all"]);
|
||||
let packed = listing(&Repo::open(dir).expect("open"));
|
||||
|
||||
let output = git_run(
|
||||
dir,
|
||||
&[
|
||||
"for-each-ref",
|
||||
"--format=%(refname) %(objectname)",
|
||||
"refs/heads",
|
||||
"refs/tags",
|
||||
],
|
||||
);
|
||||
let mut cli = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let (name, oid) = line.split_once(' ').expect("for-each-ref format");
|
||||
(name.to_owned(), oid.to_owned())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
cli.sort();
|
||||
|
||||
assert_eq!(loose, cli);
|
||||
assert_eq!(packed, cli);
|
||||
assert!(
|
||||
loose
|
||||
.iter()
|
||||
.any(|(name, _)| name == "refs/heads/new-search")
|
||||
);
|
||||
assert!(
|
||||
loose
|
||||
.iter()
|
||||
.any(|(name, _)| name == "refs/heads/browse-group")
|
||||
);
|
||||
git_run(dir, &["tag", "v1.0"]);
|
||||
repo.push_all(
|
||||
&format!("file://{}", server.path().display()),
|
||||
"npub1test",
|
||||
"my-repo",
|
||||
)
|
||||
.expect("push");
|
||||
assert!(repo.remote_has_refs(&url, &expected).expect("probe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -379,7 +130,6 @@ fn repo_ref_state_lists_branches_tags_and_head() {
|
||||
assert_eq!(state.refs.len(), 3);
|
||||
}
|
||||
|
||||
/// Creates a repository fixture with the given files.
|
||||
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
gix::init(&dir).expect("init");
|
||||
@@ -394,7 +144,6 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repo) {
|
||||
(dir, repo)
|
||||
}
|
||||
|
||||
/// Stages and commits every change in the repository.
|
||||
fn commit_all(repo: &Repo, message: &str) {
|
||||
git_run(repo.workdir().expect("workdir"), &["add", "-A"]);
|
||||
git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]);
|
||||
@@ -666,7 +415,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs `git` in `dir`, failing the test on a non-zero exit.
|
||||
fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
||||
let output = Command::new("git")
|
||||
.current_dir(dir)
|
||||
@@ -682,52 +430,6 @@ fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
|
||||
output
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_repos_reports_the_outermost_repository() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dir = root.path().join("project").join("sub");
|
||||
std::fs::create_dir_all(&repo_dir).expect("mkdir");
|
||||
git_run(&repo_dir, &["init", "-q"]);
|
||||
|
||||
let plain_dir = root.path().join("plain").join("deep");
|
||||
std::fs::create_dir_all(&plain_dir).expect("mkdir");
|
||||
|
||||
let repos = find_git_repos(root.path());
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].path, repo_dir.canonicalize().expect("canonical"));
|
||||
assert!(repos[0].nip34.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_repos_skips_repositories_nested_in_repositories() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let outer = root.path().join("outer");
|
||||
std::fs::create_dir_all(&outer).expect("mkdir");
|
||||
git_run(&outer, &["init", "-q"]);
|
||||
|
||||
let nested = outer.join("nested");
|
||||
std::fs::create_dir_all(&nested).expect("mkdir");
|
||||
git_run(&nested, &["init", "-q"]);
|
||||
|
||||
let repos = find_git_repos(root.path());
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].path, outer.canonicalize().expect("canonical"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_repos_ignores_a_repository_above_the_scan_root() {
|
||||
let outer = tempfile::tempdir().expect("tempdir");
|
||||
git_run(outer.path(), &["init", "-q"]);
|
||||
|
||||
let root = outer.path().join("scan");
|
||||
std::fs::create_dir_all(&root).expect("mkdir");
|
||||
|
||||
assert!(find_git_repos(&root).is_empty());
|
||||
}
|
||||
|
||||
/// Creates an empty bare server repository for push tests.
|
||||
fn bare_server(base: &Path, owner: &str, name: &str) -> PathBuf {
|
||||
let repo = base.join(owner).join(format!("{name}.git"));
|
||||
std::fs::create_dir_all(repo.parent().expect("parent")).expect("mkdir");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user