75 Commits
Author SHA1 Message Date
reya a27eca45c8 clean up
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 10:36:53 +07:00
reya 4a871e417a remove workspace, dock and signed_ui crates
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 09:42:45 +07:00
reya e86f0fdbb1 add settings dialog
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 09:03:20 +07:00
reya fd382405fd add inbox view with notifications panel
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 08:55:31 +07:00
reya d6e948474b add pull requests view with PR and patch panels
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 08:36:55 +07:00
reya 530be762ff restructure signed_app into flat modules and bump gpui-native-kit
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 07:53:23 +07:00
reya 78b7cac4c8 add issues view with new issue dialog
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 04:56:18 +07:00
reya 44b530f11d add explore panel with repo browsing and search
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-12 04:45:29 +07:00
reya 4465bbf4eb add repository history and commit diff views
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-11 20:31:41 +07:00
reya 89e718cb6a open repo panels from sidebar rows
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-11 20:12:44 +07:00
reya 1f65a0111f wire up app shell with dock, sidebar, and dialogs
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-11 19:41:38 +07:00
reya c32030cf21 extract workspace crates and switch to gpui-native-kit
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
2026-11-11 19:04:36 +07:00
reya 2124b00590 show owner avatar in repo row
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Rust / build (macos-latest, stable) (pull_request) Canceled after 0s
Rust / build (ubuntu-latest, stable) (pull_request) Canceled after 0s
Rust / build (windows-latest, stable) (pull_request) Canceled after 0s
2026-10-27 09:26:33 +07:00
reya a249174026 chore: release version 0.2.0
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-10-21 14:56:15 +07:00
reya 4f81501668 chore: update changelog for the 0.2.0 release 2026-10-21 14:56:06 +07:00
reya d6850d60ea feat: adopt the native OS theme (#33)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #33
2026-10-21 07:47:44 +00:00
reya 4b4b8b5cf2 chore: and reconcile pushes with server only refs
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-16 20:07:13 +07:00
reya a2e7907193 fix: serialize concurrent clones & recover from leftover dirs
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-16 10:26:40 +07:00
reya 946128433b chore: bind sync status to per-view directories (#32)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #32
2026-10-16 02:40:20 +00:00
reya 30ff255372 chore: bind user metadata to per-view directories (#31)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #31
2026-10-15 07:46:03 +00:00
reya cd61a09320 chore: refactor avatar component
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-15 10:53:28 +07:00
reya e2edb9dd12 chore: remove tracked .DS_Store files
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-15 08:16:42 +07:00
reya 5e061cf860 chore: add milestones
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-14 03:28:02 +00:00
reya 4c252dc589 chore: update changelog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-14 09:15:44 +07:00
reya e72e04c471 feat: multi-steps push progress (#30)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #30
2026-10-14 02:08:20 +00:00
reya b78418d815 fix: repository sync (#29)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #29
2026-10-13 08:58:14 +00:00
reya 7878feb236 chore: expand TODO into a roadmap
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-09 08:38:53 +07:00
reya 4d8aa97e24 feat: add sync detection for repositories (#28)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #28
2026-10-08 02:34:33 +00:00
reya 31256a4870 chore: clean up core crate
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-07 16:46:29 +07:00
reya 5586f67f0e feat: allow choosing the default branch when publishing a repo
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-07 11:52:53 +07:00
reya 5887a14c50 chore: refactor git module (#27)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #27
2026-10-07 02:40:12 +00:00
reya a706fadafa chore: upgrade gpui-component to 0.7 and gix to 0.88
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-07 05:19:15 +07:00
reya 8900d1f3ee feat: add a quit action
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 17:19:21 +07:00
reya 237b6a6996 feat: add user menu and sign-out support
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 15:50:30 +07:00
reya 62f355d70e chore: only refresh checkouts when related settings change
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 14:46:00 +07:00
reya f2a0ed002d chore: load user data via gossip and defer grasp relay connection
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 13:48:35 +07:00
reya cd72b25626 chore: remove trivial single-use helper functions
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-06 10:20:28 +07:00
reya a4df8e5731 chore: update changelog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 12:46:09 +07:00
reya d4ba71a362 feat: add identity import dialog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 10:55:32 +07:00
reya 6ec4b7e813 chore: adjust default theme
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-10-04 08:27:27 +07:00
reya c76e909f7f chore: refactor backend around domain types (#26)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #26
2026-10-03 10:35:48 +00:00
reya 6c4cc9f2ca chore: update deps
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-27 15:48:51 +07:00
reya a87030e929 chore: release version 0.2.0-alpha
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-09-27 14:22:59 +07:00
reya 272ddf2674 chore: connect bootstrap relays on demand (#25)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #25
2026-09-27 07:20:54 +00:00
reya fa50e9f153 feat: add event fetching strategy (#24)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #24
2026-09-27 02:50:11 +00:00
reya da22d26019 fix: date repositories from their state (#23)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #23
2026-09-25 13:13:35 +00:00
reya 0ff6740a9e feat: add an option to enable navigation buttons
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-25 16:49:09 +07:00
reya e2bce4e074 feat: redesign the dock and tabbar (#22)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #22
2026-09-25 08:37:18 +00:00
reya a85b5ee87b fix: snap build
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-09-14 14:38:25 +07:00
reya e61caf8482 chore: update build information
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-09-14 14:10:14 +07:00
reya a02e44c0f8 chore: update script
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Build and Release / Build macos-x64 (push) Canceled after 0s
Build and Release / Build macos-arm64 (push) Canceled after 0s
Build and Release / Build linux-arm64 (push) Canceled after 0s
Build and Release / Build linux-x64 (push) Canceled after 0s
Build and Release / Build windows-arm64 (push) Canceled after 0s
Build and Release / Build windows-x64 (push) Canceled after 0s
Build and Release / Create Release (push) Canceled after 0s
2026-09-14 12:39:04 +07:00
reya b526e13f45 chore: add changelog
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
2026-09-14 12:29:35 +07:00
reya 5e6156066a feat: detect local grasp repositories (#21)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #21
2026-09-14 04:06:10 +00:00
reya a74c166391 chore: remove unnecessary optimization (#20)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #20
2026-09-13 14:48:50 +00:00
reya f6b8a5e133 chore: clean up codebase (#19)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #19
2026-09-13 09:42:08 +00:00
reya 40deb9db66 feat: add inbox panel (#18)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #18
2026-09-12 03:34:51 +00:00
reya 38e8b2b933 chore: refactor the backend (#17)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: #17
2026-09-10 09:43:35 +00:00
reya a8e19e5fcd chore: migrate from git command to gix (#16)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Reviewed-on: https://git.reya.su/reya/signed/pulls/16
2026-09-08 04:01:13 +00:00
reya 1af4c66566 chore: add release infrastructure and packaging support
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Add GitHub Actions workflows for CI and release builds, packaging
scripts for Windows, macOS, Linux, and Flatpak/Snap distribution,
application icons, desktop files, and release metadata resources.
2026-09-07 12:44:35 +07:00
reya 02a0134164 chore: clean up 2026-09-07 09:24:10 +07:00
reya cf1c9e2162 fix: push failing in some cases (#15)
Reviewed-on: https://git.reya.su/reya/signed/pulls/15
2026-09-07 01:13:21 +00:00
reya 00167c6a8d feat: push checkout (#14)
Reviewed-on: https://git.reya.su/reya/signed/pulls/14
2026-09-06 13:14:11 +00:00
reya 33cbe42551 feat: pull request and patch (#13)
Reviewed-on: https://git.reya.su/reya/signed/pulls/13
2026-09-02 10:49:30 +00:00
reya 92afc5941e feat: add fps monitor (#12)
Reviewed-on: https://git.reya.su/reya/signed/pulls/12
2026-09-02 00:35:59 +00:00
reya adbf49b6b7 feat: app settings (#11)
Reviewed-on: https://git.reya.su/reya/signed/pulls/11
2026-09-01 13:12:31 +00:00
reya 5c6ab88710 chore: move some components to custom ui crate (#10)
Reviewed-on: https://git.reya.su/reya/signed/pulls/10
2026-09-01 04:18:23 +00:00
reya 05ab543fa0 chore: improce performance (#9)
Reviewed-on: https://git.reya.su/reya/signed/pulls/9
2026-09-01 02:05:24 +00:00
reya d2468545d6 feat: add local repository scanning (#8)
Reviewed-on: https://git.reya.su/reya/signed/pulls/8
2026-08-31 08:12:39 +00:00
reya 767638eda2 feat: add new first screen design (#7)
Reviewed-on: https://git.reya.su/reya/signed/pulls/7
2026-08-30 13:31:36 +00:00
reya c76ebfb8f6 feat: more options to clone (#6)
Reviewed-on: https://git.reya.su/reya/signed/pulls/6
2026-08-29 03:49:16 +00:00
reya fc796582a8 feat: add default theme 2026-08-28 11:01:03 +07:00
reya 7e421a0f3e feat: add repo about dialog (#5)
Reviewed-on: https://git.reya.su/reya/signed/pulls/5
2026-08-28 03:26:22 +00:00
reya 932679311c feat: clone repository (#4)
Reviewed-on: https://git.reya.su/reya/signed/pulls/4
2026-08-26 21:05:25 +00:00
reya 1b0e4fc582 feat: create repository (#3)
Reviewed-on: https://git.reya.su/reya/signed/pulls/3
2026-08-26 13:51:47 +00:00
reya dacfd49cdf feat: out-of-box experience (#2)
Reviewed-on: https://git.reya.su/reya/signed/pulls/2
2026-08-25 13:23:07 +00:00
207 changed files with 27510 additions and 19062 deletions
+173
View File
@@ -0,0 +1,173 @@
name: Build and Release
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build:
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: windows-x64
os: windows-latest
target: x86_64-pc-windows-msvc
- platform: windows-arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- platform: macos-x64
os: macos-15-intel
target: x86_64-apple-darwin
- platform: macos-arm64
os: macos-latest
target: aarch64-apple-darwin
- platform: linux-x64
os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- platform: linux-arm64
os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# Windows and macOS builds using cargo-packager
- name: Build with cargo-packager (Windows/macOS)
if: runner.os != 'Linux'
working-directory: desktop
run: |
cargo install cargo-packager --locked
cargo packager --release
- name: Upload Windows/macOS artifacts
if: runner.os != 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-artifacts
path: |
dist/*.dmg
dist/*.msi
dist/*.exe
if-no-files-found: error
# Linux builds using custom scripts
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder snapd squashfs-tools jq gettext-base
- name: Install Snapcraft
if: runner.os == 'Linux'
run: sudo snap install snapcraft --classic
- name: Make scripts executable
if: runner.os == 'Linux'
run: |
chmod +x script/get-crate-version
chmod +x script/linux
chmod +x script/bundle-snap
chmod +x script/bundle-linux
chmod +x script/flatpak/deps
chmod +x script/flatpak/bundle-flatpak
- name: Install required dependencies
if: runner.os == 'Linux'
run: ./script/linux
# Build the release tarball on every Linux architecture
- name: Build release tarball
if: runner.os == 'Linux'
run: ./script/bundle-linux
# Only build Flatpak and Snap for x86_64 (most common use case)
- name: Build Flatpak
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-gnu'
run: |
./script/flatpak/deps
./script/flatpak/bundle-flatpak
- name: Build Snap
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-gnu'
run: |
VERSION=$(script/get-crate-version signed)
./script/bundle-snap $VERSION
- name: Collect Linux artifacts
if: runner.os == 'Linux'
working-directory: ${{ github.workspace }}
run: |
mkdir -p linux-artifacts
# Copy the tarball created by bundle-linux
find target/release -name "*.tar.gz" -exec cp {} linux-artifacts/ \;
# Find and copy flatpak files (if they exist)
find . -name "*.flatpak" -exec cp {} linux-artifacts/ \; || true
# Find and copy snap files (if they exist)
find . -name "*.snap" -exec cp {} linux-artifacts/ \; || true
ls -la linux-artifacts/
- name: Upload Linux artifacts
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-artifacts
path: linux-artifacts/**/*
if-no-files-found: error
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Make get-crate-version executable
run: chmod +x script/get-crate-version
- name: Get version
id: version
run: |
VERSION=$(script/get-crate-version signed)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display artifacts structure
run: |
echo "Artifacts structure:"
find artifacts -type f -exec ls -la {} \;
- name: Create draft release
id: create_release
uses: akkuman/gitea-release-action@v1
with:
server_url: "https://git.reya.info/"
repository: "reya/signed"
token: ${{ secrets.GITEA_TOKEN }}
draft: true
prerelease: false
files: |
artifacts/**/*
- name: Output release info
run: |
echo "Created draft release: ${{ steps.create_release.outputs.url }}"
echo "Release ID: ${{ steps.create_release.outputs.id }}"
+32
View File
@@ -0,0 +1,32 @@
name: Rust
on:
push:
branches: ["**"]
pull_request:
branches: ["m**"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
rustup: [stable]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Linux build dependencies
run: chmod +x ./script/linux && ./script/linux
if: matrix.os == 'ubuntu-latest'
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
+20 -1
View File
@@ -1 +1,20 @@
/target
# Generated by Cargo
# will have compiled files and executables
debug/
target/
dist/
# Snap and Flatpak local build output
/snap
/info.reya.signed.json
/linux-artifacts
# Vendored dependencies + cargo config generated by script/prepare-flathub
.cargo/
vendor/
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# macOS
.DS_Store
+162
View File
@@ -0,0 +1,162 @@
# Rust coding guidelines
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
* Do not wrap clients or SDK dependencies in project structs or pass-through helper functions, call their methods directly where needed. Extract a helper only when it holds real logic that is used in more than one place.
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `CheckoutsStore::global(cx)` inline.
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
* Be careful with operations like indexing which may panic if the indexes are out of bounds.
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
- Propagate errors with `?` when the calling function should handle them
- Use `.log_err()` or similar when you need to ignore errors but want visibility
- Use explicit error handling with `match` or `if let Err(...)` when you need custom logic
- Example: avoid `let _ = client.request(...).await?;` - use `client.request(...).await?;` instead
* When implementing async operations that may fail, ensure errors propagate to the UI layer so users get meaningful feedback.
* Avoid creative additions unless explicitly requested
* Use full words for variable names (no abbreviations like "q" for "queue")
* Use variable shadowing to scope clones in async contexts for clarity, minimizing the lifetime of borrowed references.
Example:
```rust
executor.spawn({
let task_ran = task_ran.clone();
async move {
*task_ran.borrow_mut() = true;
}
});
```
# Timers in tests
* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`:
- Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher.
- Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping.
# GPUI
GPUI is a UI framework which also provides primitives for state and concurrency management.
## Context
Context types allow interaction with global state, windows, entities, and system services. They are typically passed to functions as the argument named `cx`. When a function takes callbacks they come after the `cx` parameter.
* `App` is the root context type, providing access to global state and read and update of entities.
* `Context<T>` is provided when updating an `Entity<T>`. This context dereferences into `App`, so functions which take `&App` can also take `&Context<T>`.
* `AsyncApp` and `AsyncWindowContext` are provided by `cx.spawn` and `cx.spawn_in`. These can be held across await points.
## `Window`
`Window` provides access to the state of an application window. It is passed to functions as an argument named `window` and comes before `cx` when present. It is used for managing focus, dispatching actions, directly drawing, getting user input state, etc.
## Entities
An `Entity<T>` is a handle to state of type `T`. With `thing: Entity<T>`:
* `thing.entity_id()` returns `EntityId`
* `thing.downgrade()` returns `WeakEntity<T>`
* `thing.read(cx: &App)` returns `&T`.
* `thing.read_with(cx, |thing: &T, cx: &App| ...)` returns the closure's return value.
* `thing.update(cx, |thing: &mut T, cx: &mut Context<T>| ...)` allows the closure to mutate the state, and provides a `Context<T>` for interacting with the entity. It returns the closure's return value.
* `thing.update_in(cx, |thing: &mut T, window: &mut Window, cx: &mut Context<T>| ...)` takes a `AsyncWindowContext` or `VisualTestContext`. It's the same as `update` while also providing the `Window`.
Within the closures, the inner `cx` provided to the closure must be used instead of the outer `cx` to avoid issues with multiple borrows.
Trying to update an entity while it's already being updated must be avoided as this will cause a panic.
`WeakEntity<T>` is a weak handle. It has `read_with`, `update`, and `update_in` methods that work the same, but always return an `anyhow::Result` so that they can fail if the entity no longer exists. This can be useful to avoid memory leaks - if entities have mutually recursive handles to each other they will never be dropped.
## Concurrency
All use of entities and UI rendering occurs on a single foreground thread.
`cx.spawn(async move |cx| ...)` runs an async closure on the foreground thread. Within the closure, `cx` is `&mut AsyncApp`.
When the outer cx is a `Context<T>`, the use of `spawn` instead looks like `cx.spawn(async move |this, cx| ...)`, where `this: WeakEntity<T>` and `cx: &mut AsyncApp`.
To do work on other threads, `cx.background_spawn(async move { ... })` is used. Often this background task is awaited on by a foreground task which uses the results to update state.
Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future that can be awaited upon. If this task is dropped, then its work is cancelled. To prevent this one of the following must be done:
* Awaiting the task in some other async context.
* Detaching the task via `task.detach()` or `task.detach_and_log_err(cx)`, allowing it to run indefinitely.
* Storing the task in a field, if the work should be halted when the struct is dropped.
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
## Elements
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
Example:
```
struct TextWithBorder(SharedString);
impl Render for TextWithBorder {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().border_1().child(self.0.clone())
}
}
```
Since `impl IntoElement for SharedString` exists, it can be used as an argument to `child`. `SharedString` is used to avoid copying strings, and is either an `&'static str` or `Arc<str>`.
UI components that are constructed just to be turned into elements can instead implement the `RenderOnce` trait, which is similar to `Render`, but its `render` method takes ownership of `self` and receives `&mut App` instead of `&mut Context<Self>`. Types that implement this trait can use `#[derive(IntoElement)]` to use them directly as children.
The style methods on elements are similar to those used by Tailwind CSS.
If some attributes or children of an element tree are conditional, `.when(condition, |this| ...)` can be used to run the closure only when `condition` is true. Similarly, `.when_some(option, |this, value| ...)` runs the closure when the `Option` has a value.
## Input events
Input event handlers can be registered on an element via methods like `.on_click(|event, window, cx: &mut App| ...)`.
Often event handlers will want to update the entity that's in the current `Context<T>`. The `cx.listener` method provides this - its use looks like `.on_click(cx.listener(|this: &mut T, event, window, cx: &mut Context<T>| ...)`.
## Actions
Actions are dispatched via user keyboard interaction or in code via `window.dispatch_action(SomeAction.boxed_clone(), cx)` or `focus_handle.dispatch_action(&SomeAction, window, cx)`.
Actions with no data are defined with the `actions!(some_namespace, [SomeAction, AnotherAction])` macro call. Otherwise the `Action` derive macro is used. Doc comments on actions are displayed to the user.
Action handlers can be registered on an element via the event handler `.on_action(|action, window, cx| ...)`. Like other event handlers, this is often used with `cx.listener`.
## Notify
When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called.
GPUI also tracks the entities a window reads while rendering. When an entity notifies, every view that read that entity during the previous frame is rerendered automatically.
* A view that reads an entity in render (e.g. `state.read(cx).profile(...)`) does not need `cx.observe(&entity, ...)` to pick up its changes - the read itself is the dependency. Reserve `cx.observe` and `cx.subscribe` for when the view must run logic in response to a change (rebuild derived state, re-query, dedupe), not to trigger a re-render.
* Notify the narrowest entity holding the change. Notifying a global store rerenders every view that ever read it; notifying a per-panel entity rerenders only the panels reading it.
* This works with cached panels (`panel.cached(...)`): a cached subtree repaints when the view it belongs to is invalidated.
## Entity events
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
Other entities can then register a callback to handle these events by doing `cx.subscribe(other_entity, |this, other_entity, event, cx| ...)`. This will return a `Subscription` which deregisters the callback when dropped. Typically `cx.subscribe` happens when creating a new entity and the subscriptions are stored in a `_subscriptions: Vec<Subscription>` field.
# Pull request hygiene
When an agent opens or updates a pull request, it must:
- Use a clear, correctly capitalized, imperative PR title (for example, `fix crash in project panel`).
- Avoid conventional commit prefixes in PR titles (`fix:`, `feat:`, `docs:`, etc.).
- Avoid trailing punctuation in PR titles.
- Optionally prefix the title with a crate name when one crate is the clear scope (for example, `workspace: add history view`).
- Include a `Release Notes:` section as the final section in the PR body.
- Use one bullet under `Release Notes:`:
- `- Added ...`, `- Fixed ...`, or `- Improved ...` for user-facing changes, or
- `- N/A` for docs-only and other non-user-facing changes.
- Format release notes exactly with a blank line after the heading, for example:
```
Release Notes:
- N/A
```
+90
View File
@@ -0,0 +1,90 @@
# Changelog
## v0.2.0 - 2026/10/21
### Added
- Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME), applying the OS colors, fonts, radii and accessibility preferences.
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
- Add a dropdown menu on the signed-in user button in the sidebar
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
- Allow choosing the default branch when publishing a repository
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
- Add a step-by-step push checklist while publishing and syncing a repository
- Add a `Reset remote to local` action that republishes the mirror and deletes grasp refs the mirror does not have
### Changed
- Use the built-in light/dark syntax highlighting palette instead of the Signed palette bundled in `signed.json` when the native theme is active
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
- Surface backend errors as error notifications in the workspace instead of silently dropping them
- Break ties in repository activity lists by event id, so same-second events order deterministically
- Restructure the backend around domain types: git operations behind a `Repo` type, the grasp push pipeline behind `GraspPush`, nostr connectivity behind `NostrBackend`, and shared helpers consolidated into `utils`
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives
- Refresh the checkouts store only when the checkouts settings change
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
- Publish nostr events directly through the client with relay acceptance required, removing the grasp push helper and its per-server retry loop so failures surface immediately
- Poll the repository sync drift check every 180 seconds instead of 60
- Track sync status per view instead of globally
- Merge the `PixelAvatar` into the `Avatar` component. Seed user pixel avatars from the public key instead of the display name, so avatars stay stable when a user renames
### Fixed
- Fix the repository view refreshing its mirror from the network while a push is in progress, so the refresh no longer races the push
- Fix opening a repository failing with a `destination already exists` error when two loads raced to clone its mirror, clones are now serialized and later loads reuse the finished clone
- Fix a repository never opening again after an interrupted clone left an unusable mirror directory behind, which is now removed and re-cloned
- Fix signing in with a `bunker://` URI silently generating a new identity instead of erroring when its master key cannot be parsed
- Fix repository sync failing with `state event in purgatory ... doesn't match push` when grasp servers hold branches or tags the local repository lacks
- Fix sync failing with `could not fetch refs/tags/...^{}` on repositories with annotated tags, where the peel entries `git ls-remote` reports for annotated tags were mistaken for missing refs
### Removed
- Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
## v0.2.0-alpha - 2026/09/27
### Added
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
### Changed
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
- Connect to bootstrap relays on demand instead of at startup
- Fetch profile metadata in batches of 100 authors, applying every requested profile in a single database query
- Prefetch the 500 most recent profiles at startup instead of 200
### Fixed
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
- Fix background tasks outliving their view, so a closed repository panel or pull request view stops fetching and publishing on its own
### Removed
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
## v0.1.0-alpha - 2026/09/14
Initial alpha release.
### Added
- Create and unlock a passphrase-protected Nostr identity
- Browse and search NIP-34 repositories with All, Popular and Recent filters
- Scan local Git repositories and detect their NIP-34 and GRASP bindings
- Create, publish, clone and open repositories
- Repository view with files, commit history, branches and tags
- Colour-coded commit diffs
- Issues and pull requests with discussions
- Send patches and push branches
- Inbox for repository activity
- Settings for appearance, theme, GRASP servers and repository scan paths
- Release and packaging infrastructure
Generated
+1597 -3601
View File
File diff suppressed because it is too large Load Diff
+9 -22
View File
@@ -4,38 +4,29 @@ members = ["crates/*", "desktop"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.0"
version = "0.2.0"
edition = "2024"
publish = false
[workspace.dependencies]
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] }
gpui_linux = { git = "https://github.com/zed-industries/zed" }
gpui_windows = { git = "https://github.com/zed-industries/zed" }
gpui_macos = { git = "https://github.com/zed-industries/zed" }
gpui_tokio = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" }
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" }
# `tree-sitter-languages` enables syntax highlighting for the TextView
# code preview (fenced code blocks are highlighted with tree-sitter).
gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] }
gpui-base = { git = "https://github.com/longbridge/gpui-component" }
gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "6667c812597bd2adf415d29a4e50355bb3bf06ee", features = ["layout", "editor"] }
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" }
nostr-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
gix = { version = "0.86", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation"] }
gix = { version = "0.88", default-features = false, features = ["sha1", "blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "worktree-mutation", "status"] }
chrono = { version = "0.4.38", features = ["wasmbind"] }
smol = "2"
futures = "0.3"
flume = { version = "0.11.1", default-features = false, features = ["async", "select"] }
@@ -51,16 +42,12 @@ 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"
lto = true
codegen-units = 1
panic = "abort"
panic = "unwind"
[profile.profiling]
inherits = "release"
+1 -3
View File
@@ -6,9 +6,7 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
gpui-component.workspace = true
anyhow.workspace = true
log.workspace = true
gpui-native-kit.workspace = true
rust-embed.workspace = true
[dev-dependencies]
Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M20 9L13.4142 15.5858C12.6332 16.3668 11.3669 16.3668 10.5858 15.5858L4 9" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M5.75 9.5L12 15.75L18.25 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 244 B

After

Width:  |  Height:  |  Size: 209 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="5.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6.5 12H1.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M22.25 12H17.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M20.255 17.05V10.35C20.255 9.78995 20.255 9.50992 20.146 9.29601C20.0501 9.10785 19.8972 8.95487 19.709 8.85899C19.4951 8.75 19.2151 8.75 18.655 8.75H5.35C4.78995 8.75 4.50992 8.75 4.29601 8.85899C4.10785 8.95487 3.95487 9.10785 3.85899 9.29601C3.75 9.50992 3.75 9.78995 3.75 10.35V17.05C3.75 18.1701 3.75 18.7302 3.96799 19.158C4.15973 19.5343 4.46569 19.8403 4.84202 20.032C5.26984 20.25 5.82989 20.25 6.95 20.25H17.055C18.1751 20.25 18.7352 20.25 19.163 20.032C19.5393 19.8403 19.8453 19.5343 20.037 19.158C20.255 18.7302 20.255 18.1701 20.255 17.05Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.75 8V3.75C5.75 3.19772 6.19772 2.75 6.75 2.75H9.67157C10.202 2.75 10.7107 2.96071 11.0858 3.33579L12.2071 4.45711C12.3946 4.64464 12.649 4.75 12.9142 4.75H17.25C17.8023 4.75 18.25 5.19772 18.25 5.75V8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.75 12.75L11.75 12.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H8.25C9.35457 3.75 10.25 4.64543 10.25 5.75V8.25C10.25 9.35457 9.35457 10.25 8.25 10.25H5.75C4.64543 10.25 3.75 9.35457 3.75 8.25V5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 15.75C3.75 14.6454 4.64543 13.75 5.75 13.75H8.25C9.35457 13.75 10.25 14.6454 10.25 15.75V18.25C10.25 19.3546 9.35457 20.25 8.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V15.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.75 17C13.75 15.2051 15.2051 13.75 17 13.75C18.7949 13.75 20.25 15.2051 20.25 17C20.25 18.7949 18.7949 20.25 17 20.25C15.2051 20.25 13.75 18.7949 13.75 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.75 5.75C13.75 4.64543 14.6454 3.75 15.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V8.25C20.25 9.35457 19.3546 10.25 18.25 10.25H15.75C14.6454 10.25 13.75 9.35457 13.75 8.25V5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M18.5 19.0615C20.6627 18.4544 22.25 16.4502 22.25 14.0714C22.25 11.2114 19.9555 8.89286 17.125 8.89286C16.5661 8.89286 16.0281 8.98326 15.5245 9.15037C14.4289 6.56294 11.8865 4.75 8.925 4.75C4.96236 4.75 1.75 7.99594 1.75 12C1.75 14.7508 3.26609 17.1437 5.5 18.3722M14.5 16.25L12 13.75L9.5 16.25M12 20V14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 488 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.25 6.75C21.25 5.64543 20.3546 4.75 19.25 4.75H4.75C3.64543 4.75 2.75 5.64543 2.75 6.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H19.25C20.3546 19.25 21.25 18.3546 21.25 17.25V6.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M6.75 14.25V9.75L9 12L11.25 9.75V14.25" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.75 9.75V14.25L14 12.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.75 14.25L17.5 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B

@@ -1 +1,3 @@
<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" class="lucide lucide-panel-bottom-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18"/><path d="m9 10 3-3 3 3"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.25 4C20.7688 4 22 5.23122 22 6.75V17.25C22 18.7688 20.7688 20 19.25 20H4.75C3.23122 20 2 18.7688 2 17.25V6.75C2 5.23122 3.23122 4 4.75 4H19.25ZM6.75 14.5C6.33579 14.5 6 14.8358 6 15.25C6 15.6642 6.33579 16 6.75 16H17.25C17.6642 16 18 15.6642 18 15.25C18 14.8358 17.6642 14.5 17.25 14.5H6.75Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 448 B

+3 -1
View File
@@ -1 +1,3 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M21 3C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21ZM4 16V19H20V16H4ZM4 14H20V5H4V14Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 6.75C2.75 5.64543 3.64543 4.75 4.75 4.75H19.25C20.3546 4.75 21.25 5.64543 21.25 6.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V6.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M17.25 15.25L6.75 15.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 482 B

+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="black" stroke-width="1.5" stroke-linecap="round"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 5.25V12M12 12V18.75M12 12H5.25M12 12H18.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 193 B

After

Width:  |  Height:  |  Size: 203 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M13 21C13.5523 21 14 20.5523 14 20C14 19.4477 13.5523 19 13 19C12.4477 19 12 19.4477 12 20C12 20.5523 12.4477 21 13 21Z" fill="currentColor"/><path d="M21 11C21 10.4477 20.5523 9.99999 20 9.99999C19.4477 9.99999 19 10.4477 19 11C19 11.5523 19.4477 12 20 12C20.5523 12 21 11.5523 21 11Z" fill="currentColor"/><path d="M19.9295 14.2679C20.4078 14.5441 20.5716 15.1557 20.2955 15.634C20.0193 16.1123 19.4078 16.2761 18.9295 16C18.4512 15.7238 18.2873 15.1123 18.5634 14.634C18.8396 14.1557 19.4512 13.9918 19.9295 14.2679Z" fill="currentColor"/><path d="M17.3676 19.2942C17.8459 19.0181 18.0098 18.4065 17.7336 17.9282C17.4575 17.4499 16.8459 17.286 16.3676 17.5621C15.8893 17.8383 15.7254 18.4499 16.0016 18.9282C16.2777 19.4065 16.8893 19.5703 17.3676 19.2942Z" fill="currentColor"/><path d="M18.9269 7.99998C18.4487 8.27612 17.8371 8.11225 17.5609 7.63396C17.2848 7.15566 17.4487 6.54407 17.9269 6.26793C18.4052 5.99179 19.0168 6.15566 19.293 6.63396C19.5691 7.11225 19.4052 7.72384 18.9269 7.99998Z" fill="currentColor"/><path d="M9.25 14.75V20.25H3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.2493 4.41452C14.2521 3.98683 13.1537 3.75 12 3.75C7.44365 3.75 3.75 7.44365 3.75 12C3.75 15.498 5.92698 18.4875 9 19.6876" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M19.2479 6.923L12.9979 3.32455C12.3802 2.96888 11.6198 2.96888 11.0021 3.32455L4.75208 6.92297C4.13211 7.27992 3.75 7.94083 3.75 8.65622V15.3439C3.75 16.0593 4.13213 16.7202 4.75213 17.0772L11.0021 20.6754C11.6198 21.031 12.3802 21.031 12.9979 20.6753L19.2479 17.0769C19.8679 16.7199 20.25 16.059 20.25 15.3436V8.65625C20.25 7.94086 19.8679 7.27995 19.2479 6.923Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/><path d="M15.25 12C15.25 13.7949 13.7949 15.25 12 15.25C10.2051 15.25 8.75 13.7949 8.75 12C8.75 10.2051 10.2051 8.75 12 8.75C13.7949 8.75 15.25 10.2051 15.25 12Z" stroke="black" stroke-width="1.5" stroke-linecap="square"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M7.878 5.21415L7.17474 5.05186C6.58003 4.91462 5.95657 5.09343 5.525 5.525C5.09343 5.95657 4.91462 6.58003 5.05186 7.17474L5.21415 7.878C5.40122 8.6886 5.06696 9.53036 4.37477 9.99182L3.51965 10.5619C3.03881 10.8825 2.75 11.4221 2.75 12C2.75 12.5779 3.03881 13.1175 3.51965 13.4381L4.37477 14.0082C5.06696 14.4696 5.40122 15.3114 5.21415 16.122L5.05186 16.8253C4.91462 17.42 5.09343 18.0434 5.525 18.475C5.95657 18.9066 6.58003 19.0854 7.17474 18.9481L7.878 18.7858C8.6886 18.5988 9.53036 18.933 9.99182 19.6252L10.5619 20.4804C10.8825 20.9612 11.4221 21.25 12 21.25C12.5779 21.25 13.1175 20.9612 13.4381 20.4804L14.0082 19.6252C14.4696 18.933 15.3114 18.5988 16.122 18.7858L16.8253 18.9481C17.42 19.0854 18.0434 18.9066 18.475 18.475C18.9066 18.0434 19.0854 17.42 18.9481 16.8253L18.7858 16.122C18.5988 15.3114 18.933 14.4696 19.6252 14.0082L20.4804 13.4381C20.9612 13.1175 21.25 12.5779 21.25 12C21.25 11.4221 20.9612 10.8825 20.4804 10.5619L19.6252 9.99182C18.933 9.53036 18.5988 8.6886 18.7858 7.878L18.9481 7.17473C19.0854 6.58003 18.9066 5.95657 18.475 5.525C18.0434 5.09343 17.42 4.91462 16.8253 5.05186L16.122 5.21415C15.3114 5.40122 14.4696 5.06696 14.0082 4.37477L13.4381 3.51965C13.1175 3.03881 12.5779 2.75 12 2.75C11.4221 2.75 10.8825 3.03881 10.5619 3.51965L9.99182 4.37477C9.53036 5.06696 8.6886 5.40122 7.878 5.21415Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M14.75 12C14.75 13.5188 13.5188 14.75 12 14.75C10.4812 14.75 9.25 13.5188 9.25 12C9.25 10.4812 10.4812 9.25 12 9.25C13.5188 9.25 14.75 10.4812 14.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M9.00003 10.4286L14 7.57141M9.00003 13.5714L14 16.4286M20.25 6C20.25 7.79493 18.7949 9.25 17 9.25C15.2051 9.25 13.75 7.79493 13.75 6C13.75 4.20507 15.2051 2.75 17 2.75C18.7949 2.75 20.25 4.20507 20.25 6ZM20.25 18C20.25 19.7949 18.7949 21.25 17 21.25C15.2051 21.25 13.75 19.7949 13.75 18C13.75 16.2051 15.2051 14.75 17 14.75C18.7949 14.75 20.25 16.2051 20.25 18ZM9.25 12C9.25 13.7949 7.79493 15.25 6 15.25C4.20507 15.25 2.75 13.7949 2.75 12C2.75 10.2051 4.20507 8.75 6 8.75C7.79493 8.75 9.25 10.2051 9.25 12Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 641 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 258 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M15.75 6.75H21.25V12.25M20.7361 7.275L14.4142 13.5878C13.633 14.3679 12.3675 14.3675 11.5868 13.5868L10.4142 12.4142C9.63316 11.6332 8.36684 11.6332 7.58579 12.4142L2.75 17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

-576
View File
@@ -1,576 +0,0 @@
{
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
"name": "Signed",
"author": "Signed",
"url": "https://git.reya.su/reya/signed",
"themes": [
{
"name": "Signed Light",
"mode": "light",
"colors": {
"accent.background": "#F4F4F5",
"accent.foreground": "#18181B",
"accordion.background": "#FFFFFF",
"background": "#FFFFFF",
"border": "#E4E4E7",
"button.background": "#FFFFFF",
"button.active.background": "#E4E4E7",
"button.foreground": "#18181B",
"button.hover.background": "#F4F4F5",
"button.danger.background": "#DC2626",
"button.danger.active.background": "#B91C1C",
"button.danger.foreground": "#FFFFFF",
"button.danger.hover.background": "#EF4444",
"button.info.background": "#0284C7",
"button.info.active.background": "#0369A1",
"button.info.foreground": "#FFFFFF",
"button.info.hover.background": "#0EA5E9",
"button.primary.background": "#C6FF4D",
"button.primary.active.background": "#65A30D",
"button.primary.foreground": "#0B0F0C",
"button.primary.hover.background": "#B2EC3C",
"button.secondary.background": "#F4F4F5",
"button.secondary.active.background": "#D4D4D8",
"button.secondary.foreground": "#18181B",
"button.secondary.hover.background": "#E4E4E7",
"button.success.background": "#2FBF71",
"button.success.active.background": "#1F9A58",
"button.success.foreground": "#0B0F0C",
"button.success.hover.background": "#2AB568",
"button.warning.background": "#D97706",
"button.warning.active.background": "#B45309",
"button.warning.foreground": "#FFFFFF",
"button.warning.hover.background": "#F59E0B",
"group_box.background": "#F4F4F5",
"group_box.foreground": "#18181B",
"group_box.title.foreground": "#18181B",
"caret": "#18181B",
"chart.1": "#65A30D",
"chart.2": "#2FBF71",
"chart.3": "#3F6212",
"chart.4": "#7A8B5F",
"chart.5": "#A1A1AA",
"chart_bullish": "#2FBF71",
"chart_bearish": "#DC2626",
"danger.background": "#DC2626",
"danger.active.background": "#B91C1C",
"danger.foreground": "#FFFFFF",
"danger.hover.background": "#EF4444",
"description_list.label.background": "#F4F4F5",
"description_list.label.foreground": "#71717A",
"drag.border": "#65A30D",
"drop_target.background": "#65A30D33",
"foreground": "#18181B",
"info.background": "#0284C7",
"info.active.background": "#0369A1",
"info.foreground": "#FFFFFF",
"info.hover.background": "#0EA5E9",
"input.border": "#D4D4D8",
"link": "#65A30D",
"link.active": "#3F6212",
"link.hover": "#4A7A0B",
"list.background": "#FFFFFF",
"list.active.background": "#C6FF4D40",
"list.active.border": "#65A30D80",
"list.even.background": "#FAFAFA",
"list.head.background": "#FAFAFA",
"list.hover.background": "#F4F4F5",
"muted.background": "#F4F4F5",
"muted.foreground": "#71717A",
"popover.background": "#FFFFFF",
"popover.foreground": "#18181B",
"primary.background": "#C6FF4D",
"primary.active.background": "#65A30D",
"primary.foreground": "#0B0F0C",
"primary.hover.background": "#B2EC3C",
"progress.bar.background": "#65A30D",
"ring": "#65A30D",
"scrollbar.background": "#FFFFFF00",
"scrollbar.thumb.background": "#A1A1AA99",
"scrollbar.thumb.hover.background": "#A1A1AA",
"secondary.background": "#F4F4F5",
"secondary.active.background": "#D4D4D8",
"secondary.foreground": "#18181B",
"secondary.hover.background": "#E4E4E7",
"selection.background": "#C6FF4D59",
"sidebar.background": "#FAFAFA",
"sidebar.accent.background": "#C6FF4D40",
"sidebar.accent.foreground": "#65A30D",
"sidebar.border": "#E4E4E7",
"sidebar.foreground": "#18181B",
"sidebar.primary.background": "#F4F4F5",
"sidebar.primary.foreground": "#18181B",
"skeleton.background": "#E4E4E7",
"slider.background": "#E4E4E7",
"slider.thumb.background": "#65A30D",
"success.background": "#2FBF71",
"success.active.background": "#1F9A58",
"success.foreground": "#0B0F0C",
"success.hover.background": "#2AB568",
"switch.background": "#D4D4D8",
"switch.thumb.background": "#FFFFFF",
"tab.background": "#F4F4F5",
"tab.active.background": "#EBFFC1",
"tab.active.foreground": "#3F6212",
"tab.foreground": "#71717A",
"tab_bar.background": "#F4F4F5",
"tab_bar.segmented.background": "#E4E4E7",
"table.background": "#FFFFFF",
"table.active.background": "#C6FF4D40",
"table.active.border": "#65A30D80",
"table.even.background": "#FAFAFA",
"table.head.background": "#FAFAFA",
"table.head.foreground": "#71717A",
"table.foot.background": "#FAFAFA",
"table.foot.foreground": "#71717A",
"table.hover.background": "#F4F4F5",
"table.row.border": "#E4E4E7",
"title_bar.background": "#FAFAFA",
"title_bar.border": "#E4E4E7",
"status_bar.background": "#FAFAFA",
"status_bar.border": "#E4E4E7",
"tiles.background": "#FFFFFF",
"warning.background": "#D97706",
"warning.active.background": "#B45309",
"warning.foreground": "#FFFFFF",
"warning.hover.background": "#F59E0B",
"overlay": "#0000004D",
"window.border": "#E4E4E7",
"base.red": "#DC2626",
"base.green": "#16A34A",
"base.yellow": "#CA8A04",
"base.blue": "#2563EB",
"base.magenta": "#9333EA",
"base.cyan": "#0891B2"
},
"highlight": {
"editor.background": "#FFFFFF",
"editor.foreground": "#383A42",
"editor.active_line.background": "#F2F3F4",
"editor.line_number": "#A0A1A7",
"editor.active_line_number": "#383A42",
"editor.invisible": "#A0A1A766",
"conflict": "#DC2626",
"created": "#16A34A",
"deleted": "#DC2626",
"error": "#DC2626",
"error.background": "#FEF2F2",
"error.border": "#F87171",
"hidden": "#A0A1A7",
"hint": "#A626A4",
"hint.background": "#FAF5FF",
"hint.border": "#C4B5FD",
"ignored": "#A0A1A7",
"info": "#4078F2",
"info.background": "#EFF6FF",
"info.border": "#93C5FD",
"modified": "#C18401",
"modified.background": "#FFFBEB",
"predictive": "#A0A1A7",
"renamed": "#A626A4",
"success": "#16A34A",
"success.background": "#F0FDF4",
"unreachable": "#A0A1A7",
"warning": "#C18401",
"warning.background": "#FFFBEB",
"warning.border": "#FCD34D",
"syntax": {
"attribute": {
"color": "#986801"
},
"boolean": {
"color": "#986801"
},
"comment": {
"color": "#A0A1A7",
"font_style": "italic"
},
"comment.doc": {
"color": "#A0A1A7",
"font_style": "italic"
},
"constant": {
"color": "#986801"
},
"constructor": {
"color": "#4078F2"
},
"embedded": {
"color": "#C18401"
},
"function": {
"color": "#4078F2"
},
"keyword": {
"color": "#A626A4"
},
"label": {
"color": "#4078F2"
},
"link_text": {
"color": "#4078F2",
"font_style": "underline"
},
"link_uri": {
"color": "#4078F2",
"font_style": "italic"
},
"number": {
"color": "#986801"
},
"operator": {
"color": "#383A42"
},
"preproc": {
"color": "#C18401"
},
"property": {
"color": "#E45649"
},
"punctuation": {
"color": "#383A42"
},
"punctuation.bracket": {
"color": "#383A42"
},
"punctuation.delimiter": {
"color": "#383A42"
},
"punctuation.list_marker": {
"color": "#986801"
},
"punctuation.special": {
"color": "#A626A4"
},
"string": {
"color": "#50A14F"
},
"string.escape": {
"color": "#A626A4"
},
"string.regex": {
"color": "#50A14F"
},
"string.special": {
"color": "#50A14F"
},
"string.special.symbol": {
"color": "#986801"
},
"tag": {
"color": "#E45649"
},
"tag.doctype": {
"color": "#A0A1A7"
},
"text.code.span": {
"color": "#383A42"
},
"text.literal": {
"color": "#50A14F"
},
"title": {
"color": "#E45649",
"font_weight": 700
},
"type": {
"color": "#0184BC"
},
"variable": {
"color": "#383A42"
},
"variable.special": {
"color": "#E45649"
},
"variant": {
"color": "#0184BC"
}
}
}
},
{
"name": "Signed Dark",
"mode": "dark",
"colors": {
"accent.background": "#18181B",
"accent.foreground": "#FAFAFA",
"accordion.background": "#0A0A0A",
"background": "#0A0A0A",
"border": "#27272A",
"button.background": "#18181B",
"button.active.background": "#3F3F46",
"button.foreground": "#FAFAFA",
"button.hover.background": "#27272A",
"button.danger.background": "#EF4444",
"button.danger.active.background": "#DC2626",
"button.danger.foreground": "#FFFFFF",
"button.danger.hover.background": "#F87171",
"button.info.background": "#0EA5E9",
"button.info.active.background": "#0284C7",
"button.info.foreground": "#FFFFFF",
"button.info.hover.background": "#38BDF8",
"button.primary.background": "#C6FF4D",
"button.primary.active.background": "#65A30D",
"button.primary.foreground": "#0B0F0C",
"button.primary.hover.background": "#B2EC3C",
"button.secondary.background": "#18181B",
"button.secondary.active.background": "#3F3F46",
"button.secondary.foreground": "#FAFAFA",
"button.secondary.hover.background": "#27272A",
"button.success.background": "#2FBF71",
"button.success.active.background": "#24A35D",
"button.success.foreground": "#0B0F0C",
"button.success.hover.background": "#2AB568",
"button.warning.background": "#F59E0B",
"button.warning.active.background": "#D97706",
"button.warning.foreground": "#18181B",
"button.warning.hover.background": "#FBBF24",
"group_box.background": "#18181B",
"group_box.foreground": "#FAFAFA",
"group_box.title.foreground": "#FAFAFA",
"caret": "#FAFAFA",
"chart.1": "#C6FF4D",
"chart.2": "#A6E22E",
"chart.3": "#2FBF71",
"chart.4": "#7A8B5F",
"chart.5": "#A1A1AA",
"chart_bullish": "#2FBF71",
"chart_bearish": "#EF4444",
"danger.background": "#EF4444",
"danger.active.background": "#DC2626",
"danger.foreground": "#FFFFFF",
"danger.hover.background": "#F87171",
"description_list.label.background": "#18181B",
"description_list.label.foreground": "#A1A1AA",
"drag.border": "#C6FF4D",
"drop_target.background": "#C6FF4D33",
"foreground": "#FAFAFA",
"info.background": "#0EA5E9",
"info.active.background": "#0284C7",
"info.foreground": "#FFFFFF",
"info.hover.background": "#38BDF8",
"input.border": "#3F3F46",
"link": "#C6FF4D",
"link.active": "#A6E22E",
"link.hover": "#B2EC3C",
"list.background": "#0A0A0A",
"list.active.background": "#C6FF4D40",
"list.active.border": "#C6FF4D80",
"list.even.background": "#18181B",
"list.head.background": "#18181B",
"list.hover.background": "#27272A",
"muted.background": "#18181B",
"muted.foreground": "#A1A1AA",
"popover.background": "#18181B",
"popover.foreground": "#FAFAFA",
"primary.background": "#C6FF4D",
"primary.active.background": "#65A30D",
"primary.foreground": "#0B0F0C",
"primary.hover.background": "#B2EC3C",
"progress.bar.background": "#C6FF4D",
"ring": "#C6FF4D",
"scrollbar.background": "#0A0A0A00",
"scrollbar.thumb.background": "#3F3F46",
"scrollbar.thumb.hover.background": "#52525B",
"secondary.background": "#18181B",
"secondary.active.background": "#3F3F46",
"secondary.foreground": "#FAFAFA",
"secondary.hover.background": "#27272A",
"selection.background": "#C6FF4D59",
"sidebar.background": "#0A0A0A",
"sidebar.accent.background": "#C6FF4D40",
"sidebar.accent.foreground": "#C6FF4D",
"sidebar.border": "#27272A",
"sidebar.foreground": "#FAFAFA",
"sidebar.primary.background": "#18181B",
"sidebar.primary.foreground": "#FAFAFA",
"skeleton.background": "#27272A",
"slider.background": "#27272A",
"slider.thumb.background": "#C6FF4D",
"success.background": "#2FBF71",
"success.active.background": "#24A35D",
"success.foreground": "#0B0F0C",
"success.hover.background": "#2AB568",
"switch.background": "#3F3F46",
"switch.thumb.background": "#FAFAFA",
"tab.background": "#18181B",
"tab.active.background": "#19200A",
"tab.active.foreground": "#C6FF4D",
"tab.foreground": "#A1A1AA",
"tab_bar.background": "#18181B",
"tab_bar.segmented.background": "#27272A",
"table.background": "#0A0A0A",
"table.active.background": "#C6FF4D40",
"table.active.border": "#C6FF4D80",
"table.even.background": "#18181B",
"table.head.background": "#18181B",
"table.head.foreground": "#A1A1AA",
"table.foot.background": "#18181B",
"table.foot.foreground": "#A1A1AA",
"table.hover.background": "#27272A",
"table.row.border": "#27272A",
"title_bar.background": "#18181B",
"title_bar.border": "#27272A",
"status_bar.background": "#18181B",
"status_bar.border": "#27272A",
"tiles.background": "#0A0A0A",
"warning.background": "#F59E0B",
"warning.active.background": "#D97706",
"warning.foreground": "#18181B",
"warning.hover.background": "#FBBF24",
"overlay": "#00000080",
"window.border": "#27272A",
"base.red": "#EF4444",
"base.green": "#22C55E",
"base.yellow": "#EAB308",
"base.blue": "#3B82F6",
"base.magenta": "#A855F7",
"base.cyan": "#06B6D4"
},
"highlight": {
"editor.background": "#0A0A0A",
"editor.foreground": "#ABB2BF",
"editor.active_line.background": "#18181B",
"editor.line_number": "#71717A",
"editor.active_line_number": "#FAFAFA",
"editor.invisible": "#71717A66",
"conflict": "#E06C75",
"created": "#98C379",
"deleted": "#E06C75",
"error": "#E06C75",
"error.background": "#3A1D1D",
"error.border": "#E06C75",
"hidden": "#5C6370",
"hint": "#C678DD",
"hint.background": "#2E2740",
"hint.border": "#C678DD",
"ignored": "#5C6370",
"info": "#61AFEF",
"info.background": "#1D2E3A",
"info.border": "#61AFEF",
"modified": "#E5C07B",
"modified.background": "#3A320E",
"predictive": "#5C6370",
"renamed": "#C678DD",
"success": "#98C379",
"success.background": "#1D3A2E",
"unreachable": "#5C6370",
"warning": "#E5C07B",
"warning.background": "#3A320E",
"warning.border": "#E5C07B",
"syntax": {
"attribute": {
"color": "#D19A66"
},
"boolean": {
"color": "#D19A66"
},
"comment": {
"color": "#5C6370",
"font_style": "italic"
},
"comment.doc": {
"color": "#5C6370",
"font_style": "italic"
},
"constant": {
"color": "#D19A66"
},
"constructor": {
"color": "#E5C07B"
},
"embedded": {
"color": "#98C379"
},
"function": {
"color": "#61AFEF"
},
"keyword": {
"color": "#C678DD"
},
"label": {
"color": "#61AFEF"
},
"link_text": {
"color": "#61AFEF",
"font_style": "underline"
},
"link_uri": {
"color": "#61AFEF",
"font_style": "italic"
},
"number": {
"color": "#D19A66"
},
"operator": {
"color": "#56B6C2"
},
"preproc": {
"color": "#E5C07B"
},
"property": {
"color": "#E06C75"
},
"punctuation": {
"color": "#ABB2BF"
},
"punctuation.bracket": {
"color": "#ABB2BF"
},
"punctuation.delimiter": {
"color": "#ABB2BF"
},
"punctuation.list_marker": {
"color": "#98C379"
},
"punctuation.special": {
"color": "#ABB2BF"
},
"string": {
"color": "#98C379"
},
"string.escape": {
"color": "#56B6C2"
},
"string.regex": {
"color": "#E06C75"
},
"string.special": {
"color": "#98C379"
},
"string.special.symbol": {
"color": "#D19A66"
},
"tag": {
"color": "#E06C75"
},
"tag.doctype": {
"color": "#5C6370"
},
"text.code.span": {
"color": "#ABB2BF"
},
"text.literal": {
"color": "#98C379"
},
"title": {
"color": "#61AFEF",
"font_weight": 700
},
"type": {
"color": "#E5C07B"
},
"variable": {
"color": "#E06C75"
},
"variable.special": {
"color": "#D19A66"
},
"variant": {
"color": "#E5C07B"
}
}
}
}
]
}
+42 -114
View File
@@ -1,72 +1,30 @@
use anyhow::Context;
use gpui::{App, AssetSource, Result, SharedString};
use gpui_component::IconNamed;
use gpui::{AssetSource, Result, SharedString};
use gpui_native_kit::icons::IconNamed;
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "assets"]
#[include = "icons/**/*.svg"]
#[include = "themes/**/*.json"]
#[include = "backgrounds/**/*.jpg"]
#[include = "backgrounds/**/*.png"]
#[exclude = "*.DS_Store"]
pub struct Assets;
impl AssetSource for Assets {
fn load(&self, path: &str) -> Result<Option<std::borrow::Cow<'static, [u8]>>> {
Self::get(path)
.map(|f| Some(f.data))
.with_context(|| format!("loading asset at path {path:?}"))
if let Some(file) = Self::get(path) {
return Ok(Some(file.data));
}
gpui_native_kit::icons::IconAssets.load(path)
}
fn list(&self, path: &str) -> Result<Vec<SharedString>> {
Ok(Self::iter()
.filter_map(|p| {
if p.starts_with(path) {
Some(p.into())
} else {
None
}
})
.collect())
}
}
impl Assets {
/// Returns the embedded theme files as `(file name, JSON content)` pairs,
/// e.g. `("signed.json", ...)`. The content is a `ThemeSet` that can be
/// loaded into the [`ThemeRegistry`](gpui_component::ThemeRegistry).
pub fn themes(&self) -> Vec<(String, String)> {
Self::iter()
.filter(|path| path.starts_with("themes/"))
.filter_map(|path| {
let data = Self::get(path.as_ref())?;
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
// Debug builds read files from disk (owned), release builds
// embed them in the binary (borrowed).
let content = match data.data {
std::borrow::Cow::Borrowed(bytes) => {
std::str::from_utf8(bytes).ok()?.to_owned()
}
std::borrow::Cow::Owned(bytes) => String::from_utf8(bytes).ok()?,
};
Some((name.to_owned(), content))
})
.collect()
}
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
let font_paths = self.list("fonts")?;
let mut embedded_fonts = Vec::new();
for font_path in font_paths {
if font_path.ends_with(".ttf") {
let font_bytes = cx
.asset_source()
.load(&font_path)?
.expect("Assets should never return None");
embedded_fonts.push(font_bytes);
}
}
cx.text_system().add_fonts(embedded_fonts)
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)
}
}
@@ -76,6 +34,8 @@ pub enum CustomIconName {
Filter,
GlobalOn,
GlobalOff,
GitFile,
GitCommit,
GitIssueDone,
GitIssueOpen,
GitIssueClosed,
@@ -87,6 +47,18 @@ pub enum CustomIconName {
GitClone,
GitBranch,
Tag,
Markdown,
Share,
Trending,
Recent,
Refresh,
Grid,
Init,
EllipsisVertical,
ArrowUp,
Trash,
Globe,
Inbox,
}
impl IconNamed for CustomIconName {
@@ -97,6 +69,8 @@ impl IconNamed for CustomIconName {
CustomIconName::Filter => "icons/filter.svg",
CustomIconName::GlobalOn => "icons/global-on.svg",
CustomIconName::GlobalOff => "icons/global-off.svg",
CustomIconName::GitCommit => "icons/git-commit.svg",
CustomIconName::GitFile => "icons/git-file.svg",
CustomIconName::GitIssueDone => "icons/git-issue-done.svg",
CustomIconName::GitIssueOpen => "icons/git-issue-open.svg",
CustomIconName::GitIssueClosed => "icons/git-issue-close.svg",
@@ -108,65 +82,19 @@ impl IconNamed for CustomIconName {
CustomIconName::GitClone => "icons/git-clone.svg",
CustomIconName::GitBranch => "icons/git-branch.svg",
CustomIconName::Tag => "icons/tag.svg",
CustomIconName::Markdown => "icons/markdown.svg",
CustomIconName::Share => "icons/share.svg",
CustomIconName::Trending => "icons/trending.svg",
CustomIconName::Refresh => "icons/refresh.svg",
CustomIconName::Recent => "icons/recent.svg",
CustomIconName::Grid => "icons/grid.svg",
CustomIconName::Init => "icons/init.svg",
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn signed_theme_set() -> gpui_component::ThemeSet {
let themes = Assets.themes();
assert_eq!(themes.len(), 1, "expected exactly one embedded theme file");
let (name, content) = &themes[0];
assert_eq!(name, "signed.json");
serde_json::from_str(content).expect("theme file must be a valid ThemeSet")
}
#[test]
fn signed_theme_set_parses() {
let set = signed_theme_set();
let names: Vec<&str> = set.themes.iter().map(|t| t.name.as_ref()).collect();
assert_eq!(names, vec!["Signed Light", "Signed Dark"]);
}
#[test]
fn signed_theme_palette_applies() {
let set = signed_theme_set();
let parse = |hex: &str| gpui_component::try_parse_color(hex).unwrap();
for config in &set.themes {
let mut theme = gpui_component::Theme::default();
theme.apply_config(&std::rc::Rc::new(config.clone()));
assert_eq!(theme.mode, config.mode);
// The resolved colors must match the brand palette.
assert_eq!(theme.primary, parse("#C6FF4D")); // nostr-lime
assert_eq!(theme.success, parse("#2FBF71")); // merge
assert_eq!(theme.primary_active, parse("#65A30D")); // lime-600
if config.mode.is_dark() {
// Dark theme chrome is neutral, mirroring the light theme.
assert_eq!(theme.background, parse("#0A0A0A")); // neutral-950
assert_eq!(theme.border, parse("#27272A")); // neutral-800
assert_eq!(theme.green, parse("#22C55E")); // green-500
} else {
// Light theme chrome is neutral; lime is a brand accent only.
assert_eq!(theme.background, parse("#FFFFFF"));
assert_eq!(theme.foreground, parse("#18181B"));
assert_eq!(theme.border, parse("#E4E4E7"));
assert_eq!(theme.green, parse("#16A34A"));
}
// Active tab: a paler lime on light, a dim moss on dark — each
// paired with readable, contrasting text.
if config.mode.is_dark() {
assert_eq!(theme.tab_active, parse("#19200A")); // dim lime
assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime
} else {
assert_eq!(theme.tab_active, parse("#EBFFC1")); // pale nostr-lime
assert_eq!(theme.tab_active_foreground, parse("#3F6212")); // deep-lime
}
}
}
}
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "dock"
description = "Dock (DockArea / Dock / Panel) components vendored from gpui-component."
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
gpui.workspace = true
gpui-component.workspace = true
gpui-base.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
itertools = "0.13.0"
smallvec = "1"
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
-429
View File
@@ -1,429 +0,0 @@
use std::ops::Deref;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, AppContext, Axis, Context, Element, Empty, Entity, IntoElement, MouseMoveEvent,
MouseUpEvent, ParentElement as _, Pixels, Point, Render, Style, StyleRefinement, Styled as _,
WeakEntity, Window, div, px,
};
use gpui_component::{Side, StyledExt};
use serde::{Deserialize, Serialize};
use super::{DockArea, DockEvent, DockItem, PanelView, TabPanel};
use crate::resize_handle::{PANEL_MIN_SIZE, resize_handle};
#[derive(Clone)]
struct ResizePanel;
impl Render for ResizePanel {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// Where to place a panel.
///
/// The [`DockArea`] has a fixed left dock and a center area. `Left` targets
/// the left dock; `Center` adds a tab to the center; `Right` and `Bottom`
/// split the center so the new panel lands on the given side of the existing
/// center content.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum DockPlacement {
#[serde(rename = "center")]
Center,
#[serde(rename = "left")]
Left,
#[serde(rename = "bottom")]
Bottom,
#[serde(rename = "right")]
Right,
}
impl DockPlacement {
/// The split axis used when the placement splits the center area.
pub(crate) fn axis(&self) -> Axis {
match self {
Self::Left | Self::Right => Axis::Horizontal,
Self::Bottom => Axis::Vertical,
Self::Center => unreachable!(),
}
}
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
}
/// The Dock is a fixed container that places at the left side of the window.
///
/// This is unlike Panel, it can't be move or add any other panel.
pub struct Dock {
pub(super) placement: DockPlacement,
dock_area: WeakEntity<DockArea>,
pub(crate) panel: DockItem,
/// The width of the dock.
pub(super) size: Pixels,
pub(super) open: bool,
/// Whether the Dock is collapsible, default: true
pub(super) collapsible: bool,
// Runtime state
/// Whether the Dock is resizing
resizing: bool,
}
impl Dock {
pub(crate) fn new(
dock_area: WeakEntity<DockArea>,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let panel = cx.new(|cx| {
let mut tab = TabPanel::new(None, dock_area.clone(), window, cx);
tab.closable = false;
tab
});
let panel = DockItem::Tabs {
size: None,
items: Vec::new(),
active_ix: 0,
view: panel.clone(),
};
Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx);
Self {
placement,
dock_area,
panel,
open: true,
collapsible: true,
size: px(200.0),
resizing: false,
}
}
pub fn left(
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new(dock_area, DockPlacement::Left, window, cx)
}
/// Update the Dock to be collapsible or not.
///
/// And if the Dock is not collapsible, it will be open.
pub fn set_collapsible(&mut self, collapsible: bool, _: &mut Window, cx: &mut Context<Self>) {
self.collapsible = collapsible;
if !collapsible {
self.open = true
}
cx.notify();
}
pub(super) fn from_state(
dock_area: WeakEntity<DockArea>,
placement: DockPlacement,
size: Pixels,
panel: DockItem,
open: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::subscribe_panel_events(dock_area.clone(), &panel, window, cx);
if !open {
match panel.clone() {
DockItem::Tabs { view, .. } => {
view.update(cx, |panel, cx| {
panel.set_collapsed(true, window, cx);
});
}
DockItem::Split { items, .. } => {
for item in items {
item.set_collapsed(true, window, cx);
}
}
_ => {}
}
}
Self {
placement,
dock_area,
panel,
open,
size,
collapsible: true,
resizing: false,
}
}
fn subscribe_panel_events(
dock_area: WeakEntity<DockArea>,
panel: &DockItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
match panel {
DockItem::Tabs { view, .. } => {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Split { items, view, .. } => {
for item in items {
Self::subscribe_panel_events(dock_area.clone(), item, window, cx);
}
window.defer(cx, {
let view = view.clone();
move |window, cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, window, cx);
});
}
});
}
DockItem::Panel { .. } => {
// Not supported
}
}
}
pub fn set_panel(&mut self, panel: DockItem, _: &mut Window, cx: &mut Context<Self>) {
self.panel = panel;
cx.notify();
}
pub fn panel(&self) -> &DockItem {
&self.panel
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_open(!self.open, window, cx);
}
/// Returns the size of the Dock, the size is means the width or height of
/// the Dock, if the placement is left or right, the size is width,
/// otherwise the size is height.
pub fn size(&self) -> Pixels {
self.size
}
/// Set the size of the Dock.
pub fn set_size(&mut self, size: Pixels, _: &mut Window, cx: &mut Context<Self>) {
self.size = size.max(PANEL_MIN_SIZE);
cx.notify();
}
/// Set the open state of the Dock.
pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
self.open = open;
let item = self.panel.clone();
cx.defer_in(window, move |_, window, cx| {
item.set_collapsed(!open, window, cx);
});
cx.notify();
}
/// Add item to the Dock.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.panel.add_panel(panel, &self.dock_area, window, cx);
cx.notify();
}
/// Remove item from the Dock.
pub fn remove_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.panel.remove_panel(panel, window, cx);
cx.notify();
}
fn render_resize_handle(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let axis = self.placement.axis();
let view = cx.entity().clone();
resize_handle("resize-handle", axis)
.when(self.placement == DockPlacement::Left, |this| {
this.placement(Side::Left)
})
.on_drag(ResizePanel {}, move |info, _, _, cx| {
cx.stop_propagation();
view.update(cx, |view, _| {
view.resizing = true;
});
cx.new(|_| info.deref().clone())
})
}
fn resize(
&mut self,
mouse_position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.resizing {
return;
}
if !self.open {
self.set_open(true, window, cx);
}
let dock_area = self
.dock_area
.upgrade()
.expect("DockArea is missing")
.read(cx);
let area_bounds = dock_area.bounds;
let size = mouse_position.x - area_bounds.left();
let max_size = (area_bounds.size.width - PANEL_MIN_SIZE).max(PANEL_MIN_SIZE);
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
cx.notify();
}
fn done_resizing(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if !self.resizing {
return;
}
self.resizing = false;
// Dragging the dock's resize handle finished, bubble a layout change
// so subscribers can persist the new dock size.
_ = self.dock_area.update(cx, |_, cx| {
cx.emit(DockEvent::LayoutChanged);
});
}
}
impl Render for Dock {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
if !self.open {
return div();
}
let cache_style = StyleRefinement::default().absolute().size_full();
div()
.relative()
.overflow_hidden()
.h_flex()
.h_full()
.w(self.size)
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)),
})
.child(self.render_resize_handle(window, cx))
.child(DockElement {
view: cx.entity().clone(),
})
}
}
struct DockElement {
view: Entity<Dock>,
}
impl IntoElement for DockElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DockElement {
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 gpui::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,
_window: &mut gpui::Window,
_cx: &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 gpui::Window,
cx: &mut App,
) {
window.on_mouse_event({
let view = self.view.clone();
let resizing = view.read(cx).resizing;
move |e: &MouseMoveEvent, phase, window, cx| {
if !resizing {
return;
}
if !phase.bubble() {
return;
}
view.update(cx, |view, cx| view.resize(e.position, window, cx))
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let view = self.view.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(window, cx));
}
}
})
}
}
-205
View File
@@ -1,205 +0,0 @@
{
"center": {
"panel_name": "StackPanel",
"children": [
{
"panel_name": "TabPanel",
"children": [
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "ButtonStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "InputStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "TextStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "SelectStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "DialogStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "SwitchStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "ProgressStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "DataTableStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "ImageStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "IconStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "TooltipStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "ProgressStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "CalendarStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "ResizableStory"
}
}
},
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "ScrollbarStory"
}
}
}
],
"info": {
"tabs": {
"active_index": 0
}
}
},
{
"panel_name": "TabPanel",
"children": [
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "PopupStory"
}
}
}
],
"info": {
"tabs": {
"active_index": 0
}
}
}
],
"info": {
"stack": {
"sizes": [
704.0,
263.0
],
"axis": 1
}
}
},
"left_dock": {
"panel": {
"panel_name": "TabPanel",
"children": [
{
"panel_name": "StoryContainer",
"children": [],
"info": {
"panel": {
"story_klass": "ListStory"
}
}
}
],
"info": {
"tabs": {
"active_index": 0
}
}
},
"placement": "left",
"size": 350.0,
"open": true,
"resizeable": true
}
}
-58
View File
@@ -1,58 +0,0 @@
use gpui::{
App, EventEmitter, FocusHandle, Focusable, ParentElement as _, Render, SharedString,
Styled as _, Window,
};
use gpui_component::ActiveTheme as _;
use super::{Panel, PanelEvent, PanelState};
pub(crate) struct InvalidPanel {
name: SharedString,
focus_handle: FocusHandle,
old_state: PanelState,
}
impl InvalidPanel {
pub(crate) fn new(name: &str, state: PanelState, _: &mut Window, cx: &mut App) -> Self {
Self {
focus_handle: cx.focus_handle(),
name: SharedString::from(name.to_owned()),
old_state: state,
}
}
}
impl Panel for InvalidPanel {
fn panel_name(&self) -> &'static str {
"InvalidPanel"
}
fn dump(&self, _cx: &App) -> super::PanelState {
self.old_state.clone()
}
}
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 gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
gpui::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()
))
}
}
File diff suppressed because it is too large Load Diff
-351
View File
@@ -1,351 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use gpui::{
AnyElement, AnyView, App, AppContext as _, Context, Entity, EntityId, EventEmitter,
FocusHandle, Focusable, Global, IntoElement, Render, SharedString, WeakEntity, Window,
};
use gpui_component::button::Button;
use gpui_component::menu::PopupMenu;
use super::{DockArea, PanelInfo, PanelState, TabPanel};
use crate::invalid_panel::InvalidPanel;
use crate::t;
pub enum PanelEvent {
ZoomIn,
ZoomOut,
LayoutChanged,
}
#[derive(Clone, Copy, Default)]
pub enum PanelControl {
Both,
#[default]
Menu,
Toolbar,
}
impl PanelControl {
#[inline]
pub fn toolbar_visible(&self) -> bool {
matches!(self, PanelControl::Both | PanelControl::Toolbar)
}
#[inline]
pub fn menu_visible(&self) -> bool {
matches!(self, PanelControl::Both | PanelControl::Menu)
}
}
/// The Panel trait used to define the panel.
#[allow(unused_variables)]
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
///
/// This is used to identify the panel when deserializing the panel.
/// Once you have defined a panel name, this must not be changed.
fn panel_name(&self) -> &'static str;
/// The name of the tab of the panel, default is `None`.
///
/// Used to display in the already collapsed tab panel.
fn tab_name(&self, cx: &App) -> Option<SharedString> {
None
}
/// The title of the panel
fn title(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
t("Dock.Unnamed")
}
/// The suffix of the panel title, default is `None`.
///
/// This is used to add a suffix element to the panel title.
fn title_suffix(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
None::<gpui::Div>
}
/// Whether the panel can be closed, default is `true`.
///
/// This method called in Panel render, we should make sure it is fast.
fn closable(&self, cx: &App) -> bool {
true
}
/// Return `PanelControl` if the panel is zoomable, default is `PanelControl::Menu`.
///
/// This method called in Panel render, we should make sure it is fast.
fn zoomable(&self, cx: &App) -> Option<PanelControl> {
Some(PanelControl::Menu)
}
/// Return false to hide panel, true to show panel, default is `true`.
///
/// This method called in Panel render, we should make sure it is fast.
fn visible(&self, cx: &App) -> bool {
true
}
/// Set active state of the panel.
///
/// Called with the frame-end net state when this panel becomes (or stops
/// being) the displayed tab of its tab group: exactly one notification
/// per edge, delivered on the next tick after the change — never
/// same-value repeats nor false→true flips within one frame.
///
/// A panel removed from its group is NOT told `false`; [`Panel::on_removed`]
/// is the deactivation signal. A hidden panel occupying `active_ix` still
/// receives `true` even though rendering falls back to the first visible
/// panel, and panels inside a bare `DockItem::Panel` (no tab group) are
/// outside this contract.
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {}
/// Set zoomed state of the panel.
///
/// This method will be called when the panel is zoomed or unzoomed.
///
/// Only current Panel will touch this method.
fn set_zoomed(&mut self, zoomed: bool, window: &mut Window, cx: &mut Context<Self>) {}
/// When this Panel is added to a TabPanel, this will be called.
fn on_added_to(
&mut self,
tab_panel: WeakEntity<TabPanel>,
window: &mut Window,
cx: &mut Context<Self>,
) {
}
/// When this Panel is removed from a TabPanel, this will be called.
fn on_removed(&mut self, window: &mut Window, cx: &mut Context<Self>) {}
/// The addition dropdown menu of the panel, default is `None`.
fn dropdown_menu(
&mut self,
this: PopupMenu,
window: &mut Window,
cx: &mut Context<Self>,
) -> PopupMenu {
this
}
/// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`.
fn toolbar_buttons(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Vec<Button>> {
None
}
/// Dump the panel, used to serialize the panel.
fn dump(&self, cx: &App) -> PanelState {
PanelState::new(self)
}
}
/// The PanelView trait used to define the panel view.
#[allow(unused_variables)]
pub trait PanelView: 'static + Send + Sync {
fn panel_name(&self, cx: &App) -> &'static str;
fn panel_id(&self, cx: &App) -> EntityId;
fn tab_name(&self, cx: &App) -> Option<SharedString>;
fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement;
fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement>;
fn closable(&self, cx: &App) -> bool;
fn zoomable(&self, cx: &App) -> Option<PanelControl>;
fn visible(&self, cx: &App) -> bool;
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App);
fn on_added_to(&self, tab_panel: WeakEntity<TabPanel>, window: &mut Window, cx: &mut App);
fn on_removed(&self, window: &mut Window, cx: &mut App);
fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu;
fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option<Vec<Button>>;
fn view(&self) -> AnyView;
fn focus_handle(&self, cx: &App) -> FocusHandle;
fn dump(&self, cx: &App) -> PanelState;
}
impl<T: Panel> PanelView for Entity<T> {
fn panel_name(&self, cx: &App) -> &'static str {
self.read(cx).panel_name()
}
fn panel_id(&self, _: &App) -> EntityId {
self.entity_id()
}
fn tab_name(&self, cx: &App) -> Option<SharedString> {
self.read(cx).tab_name(cx)
}
fn title(&self, window: &mut Window, cx: &mut App) -> AnyElement {
self.update(cx, |this, cx| this.title(window, cx).into_any_element())
}
fn title_suffix(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
self.update(cx, |this, cx| {
this.title_suffix(window, cx)
.map(|el| el.into_any_element())
})
}
fn closable(&self, cx: &App) -> bool {
self.read(cx).closable(cx)
}
fn zoomable(&self, cx: &App) -> Option<PanelControl> {
self.read(cx).zoomable(cx)
}
fn visible(&self, cx: &App) -> bool {
self.read(cx).visible(cx)
}
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_active(active, window, cx);
})
}
fn set_zoomed(&self, zoomed: bool, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_zoomed(zoomed, window, cx);
})
}
fn on_added_to(&self, tab_panel: WeakEntity<TabPanel>, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| this.on_added_to(tab_panel, window, cx));
}
fn on_removed(&self, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| this.on_removed(window, cx));
}
fn dropdown_menu(&self, menu: PopupMenu, window: &mut Window, cx: &mut App) -> PopupMenu {
self.update(cx, |this, cx| this.dropdown_menu(menu, window, cx))
}
fn toolbar_buttons(&self, window: &mut Window, cx: &mut App) -> Option<Vec<Button>> {
self.update(cx, |this, cx| this.toolbar_buttons(window, cx))
}
fn view(&self) -> AnyView {
self.clone().into()
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.read(cx).focus_handle(cx)
}
fn dump(&self, cx: &App) -> PanelState {
self.read(cx).dump(cx)
}
}
impl From<&dyn PanelView> for AnyView {
fn from(handle: &dyn PanelView) -> Self {
handle.view()
}
}
impl<T: Panel> From<&dyn PanelView> for Entity<T> {
fn from(value: &dyn PanelView) -> Self {
value.view().downcast::<T>().unwrap()
}
}
impl PartialEq for dyn PanelView {
fn eq(&self, other: &Self) -> bool {
self.view() == other.view()
}
}
/// The deserializer used by [`PanelRegistry`] to rebuild a panel from its
/// persisted [`PanelState`].
type PanelBuilder = dyn Fn(
WeakEntity<DockArea>,
&PanelState,
&PanelInfo,
&mut Window,
&mut App,
) -> Box<dyn PanelView>;
pub struct PanelRegistry {
pub(super) items: HashMap<String, Arc<PanelBuilder>>,
}
impl PanelRegistry {
/// Initialize the panel registry.
pub(crate) fn init(cx: &mut App) {
if cx.try_global::<PanelRegistry>().is_none() {
cx.set_global(PanelRegistry::new());
}
}
pub fn new() -> Self {
Self {
items: HashMap::new(),
}
}
pub fn global(cx: &App) -> &Self {
cx.global::<PanelRegistry>()
}
pub fn global_mut(cx: &mut App) -> &mut Self {
cx.global_mut::<PanelRegistry>()
}
/// Build a panel by name.
///
/// If not registered, return InvalidPanel.
pub fn build_panel(
panel_name: &str,
dock_area: WeakEntity<DockArea>,
panel_state: &PanelState,
panel_info: &PanelInfo,
window: &mut Window,
cx: &mut App,
) -> Box<dyn PanelView> {
if let Some(view) = Self::global(cx)
.items
.get(panel_name)
.cloned()
.map(|f| f(dock_area, panel_state, panel_info, window, cx))
{
view
} else {
// Show an invalid panel if the panel is not registered.
Box::new(cx.new(|cx| InvalidPanel::new(panel_name, panel_state.clone(), window, cx)))
}
}
}
impl Default for PanelRegistry {
fn default() -> Self {
Self::new()
}
}
impl Global for PanelRegistry {}
/// Register the Panel init by panel_name to global registry.
pub fn register_panel<F>(cx: &mut App, panel_name: &str, deserialize: F)
where
F: Fn(
WeakEntity<DockArea>,
&PanelState,
&PanelInfo,
&mut Window,
&mut App,
) -> Box<dyn PanelView>
+ 'static,
{
PanelRegistry::init(cx);
PanelRegistry::global_mut(cx)
.items
.insert(panel_name.to_string(), Arc::new(deserialize));
}
-232
View File
@@ -1,232 +0,0 @@
//! Vendored from `gpui-base`'s private `resizable::resize_handle` module
//! (v0.5.2, rev 9e3a29dcbdebc318632bf68203f26c33e9f0e902). gpui-component
//! keeps this and [`PANEL_MIN_SIZE`] crate-private, so the dock crate carries
//! its own copy.
use std::cell::Cell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, px,
};
use gpui_component::{ActiveTheme as _, AxisExt as _, Side};
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
/// Create a resize handle for a resizable panel.
#[doc(hidden)]
pub fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
type DragHandler<E> = dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>;
#[doc(hidden)]
pub struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<Side>,
on_drag: Option<Rc<DragHandler<E>>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
on_drag: None,
drag_value: None,
placement: None,
axis,
}
}
pub fn on_drag(
mut self,
value: T,
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
pub fn placement(mut self, placement: Side) -> Self {
self.placement = Some(placement);
self
}
}
#[derive(Default, Debug, Clone)]
struct ResizeHandleState {
active: Cell<bool>,
}
impl ResizeHandleState {
fn set_active(&self, active: bool) {
self.active.set(active);
}
fn is_active(&self) -> bool {
self.active.get()
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type PrepaintState = ();
type RequestLayoutState = AnyElement;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
let bg_color = if state.is_active() {
cx.theme().drag_border
} else {
cx.theme().border
};
let mut el = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&position, window, cx),
)
})
.map(|this| match self.placement {
Some(Side::Left) => {
// Special for Left Dock
// FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING)
this.cursor_col_resize()
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING)
}
_ => this
.when(axis.is_horizontal(), |this| {
this.cursor_col_resize()
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(axis.is_vertical(), |this| {
this.cursor_row_resize()
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
}),
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
((layout_id, el), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if bounds.contains(&ev.position) && phase.bubble() {
state.set_active(true);
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
move |_: &MouseUpEvent, _, window, _| {
if state.is_active() {
state.set_active(false);
window.refresh();
}
}
});
((), state)
});
}
}
-397
View File
@@ -1,397 +0,0 @@
use std::sync::Arc;
use gpui::{
App, AppContext as _, Axis, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, WeakEntity,
Window,
};
use gpui_component::{
ActiveTheme, Placement, ResizablePanelEvent, ResizablePanelGroup, ResizableState, h_flex,
resizable_panel,
};
use smallvec::SmallVec;
use super::{DockArea, Panel, PanelEvent, PanelState, PanelView, TabPanel};
use crate::PanelInfo;
use crate::resize_handle::PANEL_MIN_SIZE;
pub struct StackPanel {
pub(super) parent: Option<WeakEntity<StackPanel>>,
pub(super) axis: Axis,
focus_handle: FocusHandle,
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
state: Entity<ResizableState>,
_subscriptions: Vec<Subscription>,
}
impl Panel for StackPanel {
fn panel_name(&self) -> &'static str {
"StackPanel"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
"StackPanel"
}
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
for panel in &self.panels {
panel.set_active(active, window, cx);
}
}
fn dump(&self, cx: &App) -> PanelState {
let sizes = self.state.read(cx).sizes().clone();
let mut state = PanelState::new(self);
state.info = PanelInfo::stack(sizes, self.axis);
for panel in &self.panels {
state.add_child(panel.dump(cx));
}
state
}
}
impl StackPanel {
pub fn new(axis: Axis, _: &mut Window, cx: &mut Context<Self>) -> Self {
let state = cx.new(|_| ResizableState::default());
let _subscriptions = vec![
// Bubble up the resize event.
cx.subscribe(&state, |_, _, _: &ResizablePanelEvent, cx| {
cx.emit(PanelEvent::LayoutChanged)
}),
];
Self {
axis,
parent: None,
focus_handle: cx.focus_handle(),
panels: SmallVec::new(),
state,
_subscriptions,
}
}
/// The first level of the stack panel is root, will not have a parent.
fn is_root(&self) -> bool {
self.parent.is_none()
}
/// Return true if self or parent only have last panel.
pub(super) fn is_last_panel(&self, cx: &App) -> bool {
if self.panels.len() > 1 {
return false;
}
if let Some(parent) = &self.parent
&& let Some(parent) = parent.upgrade()
{
return parent.read(cx).is_last_panel(cx);
}
true
}
pub(super) fn panels_len(&self) -> usize {
self.panels.len()
}
/// Return the index of the panel.
pub(crate) fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
self.panels.iter().position(|p| p == &panel)
}
fn assert_panel_is_valid(&self, panel: &Arc<dyn PanelView>) {
assert!(
panel.view().downcast::<TabPanel>().is_ok()
|| panel.view().downcast::<StackPanel>().is_ok(),
"Panel must be a `TabPanel` or `StackPanel`"
);
}
/// Add a panel at the end of the stack.
///
/// If `size` is `None`, the panel will be given the average size of all panels in the stack.
///
/// The `panel` must be a [`TabPanel`] or [`StackPanel`].
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, self.panels.len(), size, dock_area, window, cx);
}
/// Add a panel at the [`Placement`].
///
/// The `panel` must be a [`TabPanel`] or [`StackPanel`].
pub fn add_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel_at(
panel,
self.panels_len(),
placement,
size,
dock_area,
window,
cx,
);
}
/// Insert a panel at the index.
///
/// The `panel` must be a [`TabPanel`] or [`StackPanel`].
#[allow(clippy::too_many_arguments)]
pub fn insert_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
match placement {
Placement::Top | Placement::Left => {
self.insert_panel_before(panel, ix, size, dock_area, window, cx)
}
Placement::Right | Placement::Bottom => {
self.insert_panel_after(panel, ix, size, dock_area, window, cx)
}
}
}
/// Insert a panel at the index.
///
/// The `panel` must be a [`TabPanel`] or [`StackPanel`].
pub fn insert_panel_before(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix, size, dock_area, window, cx);
}
/// Insert a panel after the index.
///
/// The `panel` must be a [`TabPanel`] or [`StackPanel`].
pub fn insert_panel_after(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.insert_panel(panel, ix + 1, size, dock_area, window, cx);
}
fn insert_panel(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.assert_panel_is_valid(&panel);
// If the panel is already in the stack, return.
if self.index_of_panel(panel.clone()).is_some() {
return;
}
let view = cx.entity().clone();
window.defer(cx, {
let panel = panel.clone();
move |window, cx| {
// If the panel is a TabPanel, set its parent to this.
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade()));
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
stack_panel.update(cx, |stack_panel, _| {
stack_panel.parent = Some(view.downgrade())
});
}
// Subscribe to the panel's layout change event.
_ = dock_area.update(cx, |this, cx| {
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
this.subscribe_panel(&tab_panel, window, cx);
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
this.subscribe_panel(&stack_panel, window, cx);
}
});
}
});
let ix = if ix > self.panels.len() {
self.panels.len()
} else {
ix
};
// Get avg size of all panels to insert new panel, if size is None.
let size = match size {
Some(size) => size,
None => {
let state = self.state.read(cx);
(state.container_size() / (state.sizes().len() + 1) as f32).max(PANEL_MIN_SIZE)
}
};
self.panels.insert(ix, panel.clone());
self.state.update(cx, |state, cx| {
state.insert_panel(Some(size), Some(ix), cx);
});
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Remove panel from the stack.
///
/// If `ix` is not found, do nothing.
pub fn remove_panel(
&mut self,
panel: Arc<dyn PanelView>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.index_of_panel(panel.clone()) else {
return;
};
self.panels.remove(ix);
self.state.update(cx, |state, cx| {
state.remove_panel(ix, cx);
});
cx.emit(PanelEvent::LayoutChanged);
self.remove_self_if_empty(window, cx);
}
/// Replace the old panel with the new panel at same index.
pub(super) fn replace_panel(
&mut self,
old_panel: Arc<dyn PanelView>,
new_panel: Entity<StackPanel>,
_: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
self.panels[ix] = Arc::new(new_panel.clone());
self.state.update(cx, |state, cx| {
state.reset_panel(ix, cx);
});
cx.emit(PanelEvent::LayoutChanged);
}
}
/// If children is empty, remove self from parent view.
pub(crate) fn remove_self_if_empty(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_root() {
return;
}
if !self.panels.is_empty() {
return;
}
let view = cx.entity().clone();
if let Some(parent) = self.parent.as_ref() {
_ = parent.update(cx, |parent, cx| {
parent.remove_panel(Arc::new(view.clone()), window, cx);
});
}
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Find the first top left in the stack.
pub(super) fn left_top_tab_panel(
&self,
check_parent: bool,
cx: &App,
) -> Option<Entity<TabPanel>> {
if check_parent
&& let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade())
&& let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx)
{
return Some(panel);
}
let first_panel = self.panels.first();
if let Some(view) = first_panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).left_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Remove all panels from the stack.
pub(super) fn remove_all_panels(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.panels.clear();
self.state.update(cx, |state, cx| {
state.clear();
cx.notify();
});
}
/// Change the axis of the stack panel.
pub(super) fn set_axis(&mut self, axis: Axis, _: &mut Window, cx: &mut Context<Self>) {
self.axis = axis;
cx.notify();
}
}
impl Focusable for StackPanel {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.overflow_hidden()
.bg(cx.theme().tokens.tab_bar)
.child(
ResizablePanelGroup::new("stack-panel-group")
.with_state(&self.state)
.axis(self.axis)
.children(self.panels.clone().into_iter().map(|panel| {
resizable_panel()
.child(panel.view())
.visible(panel.visible(cx))
})),
)
}
}
-233
View File
@@ -1,233 +0,0 @@
use gpui::{App, AppContext, Axis, Entity, Pixels, WeakEntity, Window};
use itertools::Itertools as _;
use serde::{Deserialize, Serialize};
use super::{Dock, DockArea, DockItem, DockPlacement, Panel, PanelRegistry};
/// Used to serialize and deserialize the DockArea
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct DockAreaState {
/// The version is used to mark this persisted state is compatible with the current version
/// For example, some times we many totally changed the structure of the Panel,
/// then we can compare the version to decide whether we can use the state or ignore.
#[serde(default)]
pub version: Option<usize>,
pub center: PanelState,
#[serde(skip_serializing_if = "Option::is_none")]
pub left_dock: Option<DockState>,
}
/// Used to serialize and deserialize the Dock
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DockState {
panel: PanelState,
placement: DockPlacement,
size: Pixels,
open: bool,
}
impl DockState {
pub fn new(dock: Entity<Dock>, cx: &App) -> Self {
let dock = dock.read(cx);
Self {
placement: dock.placement,
size: dock.size,
open: dock.open,
panel: dock.panel.view().dump(cx),
}
}
/// Convert the DockState to Dock
pub fn to_dock(
&self,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) -> Entity<Dock> {
let item = self.panel.to_item(dock_area.clone(), window, cx);
cx.new(|cx| {
Dock::from_state(
dock_area.clone(),
self.placement,
self.size,
item,
self.open,
window,
cx,
)
})
}
}
/// Used to serialize and deserialize the DockerItem
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PanelState {
pub panel_name: String,
pub children: Vec<PanelState>,
pub info: PanelInfo,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PanelInfo {
#[serde(rename = "stack")]
Stack {
sizes: Vec<Pixels>,
axis: usize, // 0 for horizontal, 1 for vertical
},
#[serde(rename = "tabs")]
Tabs { active_index: usize },
#[serde(rename = "panel")]
Panel(serde_json::Value),
}
impl PanelInfo {
pub fn stack(sizes: Vec<Pixels>, axis: Axis) -> Self {
Self::Stack {
sizes,
axis: if axis == Axis::Horizontal { 0 } else { 1 },
}
}
pub fn tabs(active_index: usize) -> Self {
Self::Tabs { active_index }
}
pub fn panel(info: serde_json::Value) -> Self {
Self::Panel(info)
}
pub fn axis(&self) -> Option<Axis> {
match self {
Self::Stack { axis, .. } => Some(if *axis == 0 {
Axis::Horizontal
} else {
Axis::Vertical
}),
_ => None,
}
}
pub fn sizes(&self) -> Option<&Vec<Pixels>> {
match self {
Self::Stack { sizes, .. } => Some(sizes),
_ => None,
}
}
pub fn active_index(&self) -> Option<usize> {
match self {
Self::Tabs { active_index } => Some(*active_index),
_ => None,
}
}
}
impl Default for PanelState {
fn default() -> Self {
Self {
panel_name: "".to_string(),
children: Vec::new(),
info: PanelInfo::Panel(serde_json::Value::Null),
}
}
}
impl PanelState {
pub fn new<P: Panel>(panel: &P) -> Self {
Self {
panel_name: panel.panel_name().to_string(),
..Default::default()
}
}
pub fn add_child(&mut self, panel: PanelState) {
self.children.push(panel);
}
pub fn to_item(
&self,
dock_area: WeakEntity<DockArea>,
window: &mut Window,
cx: &mut App,
) -> DockItem {
let info = self.info.clone();
let items: Vec<DockItem> = self
.children
.iter()
.map(|child| child.to_item(dock_area.clone(), window, cx))
.collect();
match info {
PanelInfo::Stack { sizes, axis } => {
let axis = if axis == 0 {
Axis::Horizontal
} else {
Axis::Vertical
};
let sizes = sizes.iter().map(|s| Some(*s)).collect_vec();
DockItem::split_with_sizes(axis, items, sizes, &dock_area, window, cx)
}
PanelInfo::Tabs { active_index } => {
if items.len() == 1 {
return items[0].clone();
}
let items = items
.iter()
.flat_map(|item| match item {
DockItem::Tabs { items, .. } => items.clone(),
_ => {
// ignore invalid panels in tabs
vec![]
}
})
.collect_vec();
DockItem::tabs(items, &dock_area, window, cx).active_index(active_index, cx)
}
PanelInfo::Panel(_) => {
let view = PanelRegistry::build_panel(
&self.panel_name,
dock_area.clone(),
self,
&info,
window,
cx,
);
DockItem::tabs(vec![view.into()], &dock_area, window, cx)
}
}
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::*;
#[test]
fn test_deserialize_item_state() {
let json = include_str!("fixtures/layout.json");
let state: DockAreaState = serde_json::from_str(json).unwrap();
assert_eq!(state.version, None);
assert_eq!(state.center.panel_name, "StackPanel");
assert_eq!(state.center.children.len(), 2);
assert_eq!(state.center.children[0].panel_name, "TabPanel");
assert_eq!(state.center.children[1].children.len(), 1);
assert_eq!(
state.center.children[1].children[0].panel_name,
"StoryContainer"
);
assert_eq!(state.center.children[1].panel_name, "TabPanel");
let left_dock = state.left_dock.unwrap();
assert!(left_dock.open);
assert_eq!(left_dock.size, px(350.0));
assert_eq!(left_dock.placement, DockPlacement::Left);
assert_eq!(left_dock.panel.panel_name, "TabPanel");
assert_eq!(left_dock.panel.children.len(), 1);
assert_eq!(left_dock.panel.children[0].panel_name, "StoryContainer");
}
}
-469
View File
@@ -1,469 +0,0 @@
use std::sync::Arc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ElementId, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, RenderOnce, ScrollHandle, StatefulInteractiveElement, Styled as _, Window,
div, px,
};
use gpui_base::{InteractiveElementExt, Tab, Tabs};
use gpui_component::{ActiveTheme, ElementExt, h_flex};
use super::{AnyDrag, DragPanel, PanelView, TabPanel};
use crate::TAB_BAR_HEIGHT;
/// The dock's custom tab bar, built on gpui-base's unstyled [`Tab`]/[`Tabs`].
///
/// The dock owns the pill presentation; the layout mirrors gpui-component's
/// `TabBar`: a prefix (dock toggle / tab navigation), a scrollable tab strip
/// with a trailing drop target, then a suffix (panel toolbar).
///
/// Tabs are draggable to move panels between groups and double as drop
/// targets, so panel-level events are forwarded to the owning [`TabPanel`].
#[derive(IntoElement)]
pub(crate) struct TabBar {
id: ElementId,
panels: Vec<Arc<dyn PanelView>>,
active_panel: Option<Arc<dyn PanelView>>,
collapsed: bool,
draggable: bool,
droppable: bool,
tab_panel: Entity<TabPanel>,
scroll_handle: ScrollHandle,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
empty_space: Option<AnyElement>,
}
impl TabBar {
pub(crate) fn new(id: impl Into<ElementId>, tab_panel: Entity<TabPanel>) -> Self {
Self {
id: id.into(),
panels: Vec::new(),
active_panel: None,
collapsed: false,
draggable: false,
droppable: false,
tab_panel,
scroll_handle: ScrollHandle::new(),
prefix: None,
suffix: None,
empty_space: None,
}
}
/// The panels to show as tabs, in strip order.
pub(crate) fn panels(mut self, panels: Vec<Arc<dyn PanelView>>) -> Self {
self.panels = panels;
self
}
/// The currently active panel; its tab is rendered as the filled pill.
pub(crate) fn active_panel(mut self, active_panel: Option<Arc<dyn PanelView>>) -> Self {
self.active_panel = active_panel;
self
}
/// Collapsed tab panels render no suffix or trailing drop target, and
/// their tabs lose the active style and all interactions.
pub(crate) fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
/// Whether the tabs can start a panel drag.
pub(crate) fn draggable(mut self, draggable: bool) -> Self {
self.draggable = draggable;
self
}
/// Whether the tabs and trailing space accept drops.
pub(crate) fn droppable(mut self, droppable: bool) -> Self {
self.droppable = droppable;
self
}
/// Track the strip's scroll state with the given handle, so callers can
/// scroll a tab into view with [`ScrollHandle::scroll_to_item`].
pub(crate) fn scroll_handle(mut self, scroll_handle: &ScrollHandle) -> Self {
self.scroll_handle = scroll_handle.clone();
self
}
/// Element shown before the tab strip.
pub(crate) fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Element shown after the tab strip.
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Replace the trailing empty space (the drop target after the last tab).
#[cfg(test)]
pub(crate) fn empty_space(mut self, empty_space: impl IntoElement) -> Self {
self.empty_space = Some(empty_space.into_any_element());
self
}
fn render_tab(
&self,
ix: usize,
panel: Arc<dyn PanelView>,
active: bool,
window: &mut Window,
cx: &mut App,
) -> Tab {
// While collapsed, tabs lose the active style and all interactions.
let droppable = self.collapsed;
let tab_panel = self.tab_panel.clone();
// The `ix` element id keeps each tab's identity stable across renders.
Tab::new(ix)
.h_6()
.px_3()
.text_sm()
.whitespace_nowrap()
.flex()
.items_center()
.justify_center()
.gap_1()
.flex_shrink_0()
.overflow_hidden()
.rounded(cx.theme().radius)
.text_color(cx.theme().foreground)
.map(|this| {
if let Some(tab_name) = panel.tab_name(cx) {
this.child(tab_name)
} else {
this.child(panel.title(window, cx))
}
})
// Pill presentation: the selected tab is the filled pill, the
// rest are transparent until hovered.
.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().secondary_foreground)
.bg(cx.theme().secondary_hover)
}
})
.selected(active)
.on_click(move |_, window, cx| {
tab_panel.update(cx, |view, cx| view.set_active_ix(ix, window, cx));
})
.when(!droppable, |this| {
this.when(self.draggable, |this| {
this.on_drag(
DragPanel::new(panel.clone(), self.tab_panel.clone()),
|drag, offset, _, cx| {
cx.stop_propagation();
drag.drag_offset.set(offset);
cx.new(|_| drag.clone())
},
)
})
.when(self.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 tab_panel = self.tab_panel.clone();
move |drag: &DragPanel, window, cx| {
tab_panel.update(cx, |view, cx| {
view.will_split_placement = None;
view.on_drop(drag, 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 tab_panel = self.tab_panel.clone();
move |item: &AnyDrag, _, cx| {
tab_panel.update(cx, |view, cx| {
view.will_split_placement = None;
view.emit_drag_drop(item, None, cx);
});
}
})
})
})
}
fn render_empty_space(&self) -> AnyElement {
let tabs_count = self.panels.len();
// The strip after the last tab is a drop target for panels and
// host-owned drag items. Its left edge (right after the last tab)
// marks the start of the title-bar drag overlay.
let mut empty = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.on_prepaint({
let view = self.tab_panel.clone();
move |bounds, _, cx| {
view.update(cx, |this, cx| {
if this.title_bar_strip_bounds != Some(bounds) {
this.title_bar_strip_bounds = Some(bounds);
cx.notify();
}
});
}
});
if self.droppable {
empty = empty
.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let view = self.tab_panel.clone();
move |drag: &DragPanel, window, cx| {
view.update(cx, |this, cx| {
this.will_split_placement = None;
// Dropping a panel from this same tab group onto
// the strip moves it after the last tab.
let ix = if drag.tab_panel == cx.entity() {
Some(tabs_count - 1)
} else {
None
};
this.on_drop(drag, ix, false, window, cx);
});
}
})
.drag_over::<AnyDrag>(|this, _, _, cx| this.bg(cx.theme().tokens.drop_target))
.on_drop({
let view = self.tab_panel.clone();
move |item: &AnyDrag, _, cx| {
view.update(cx, |this, cx| {
this.will_split_placement = None;
this.emit_drag_drop(item, None, cx);
});
}
});
}
empty.into_any_element()
}
}
impl RenderOnce for TabBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let tabs: Vec<_> = self
.panels
.iter()
.enumerate()
.filter_map(|(ix, panel)| {
let mut active = self.active_panel.as_ref() == Some(panel);
if !panel.visible(cx) {
return None;
}
// Always not show active tab style, if the panel is collapsed
if self.collapsed {
active = false;
}
Some(self.render_tab(ix, panel.clone(), active, window, cx))
})
.collect();
let empty_space = match self.empty_space {
Some(empty_space) => empty_space,
None => self.render_empty_space(),
};
Tabs::new(self.id)
.px(px(-1.))
.h(TAB_BAR_HEIGHT)
.flex()
.items_center()
.text_color(cx.theme().tab_foreground)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.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(!self.collapsed, |this| this.child(empty_space)),
),
)
.when_some(self.suffix, |this, suffix| {
this.when(!self.collapsed, |this| this.child(suffix))
})
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use gpui::{
Context, Entity, MouseButton, Render, TestAppContext, VisualTestContext, WindowOptions,
div, px, size,
};
use gpui_component::{Root, Theme, v_flex};
use super::*;
use crate::DockArea;
use crate::tab_panel::title_bar_drag_handlers;
#[derive(Default)]
struct ProbeFlags {
empty_down: AtomicBool,
empty_click: AtomicBool,
control_down: AtomicBool,
control_click: AtomicBool,
}
struct ProbeView {
flags: Entity<ProbeFlags>,
tab_panel: Entity<TabPanel>,
}
impl Render for ProbeView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let flags = self.flags.clone();
let empty = title_bar_drag_handlers(
div()
.id("empty-space")
.h(TAB_BAR_HEIGHT)
.flex_grow_1()
.min_w_16(),
window,
cx,
)
.debug_selector(|| "empty-space".into())
.on_mouse_down(MouseButton::Left, {
let flags = flags.clone();
move |_, _, cx| flags.update(cx, |f, _| f.empty_down.store(true, Ordering::SeqCst))
})
.on_click({
let flags = flags.clone();
move |_, _, cx| flags.update(cx, |f, _| f.empty_click.store(true, Ordering::SeqCst))
});
let control =
title_bar_drag_handlers(div().id("control-space").h_8().flex_grow_1(), window, cx)
.debug_selector(|| "control-space".into())
.on_mouse_down(MouseButton::Left, {
let flags = flags.clone();
move |_, _, cx| {
flags.update(cx, |f, _| f.control_down.store(true, Ordering::SeqCst))
}
})
.on_click({
let flags = flags.clone();
move |_, _, cx| {
flags.update(cx, |f, _| f.control_click.store(true, Ordering::SeqCst))
}
});
v_flex()
.size_full()
.child(TabBar::new("probe-bar", self.tab_panel.clone()).empty_space(empty))
.child(control)
}
}
/// Diagnostic: verify that the tab bar's trailing empty space receives
/// mouse events when wrapped by `title_bar_drag_handlers`, i.e. the
/// strip's scroll containers do not swallow them.
#[gpui::test]
fn tab_bar_empty_space_receives_events(cx: &mut TestAppContext) {
let (flags, handle) = cx.update(|cx| {
cx.set_global(Theme::default());
let flags = cx.new(|_| ProbeFlags::default());
let handle = cx.open_window(
WindowOptions {
window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds {
origin: gpui::Point::default(),
size: size(px(800.), px(100.)),
})),
..Default::default()
},
|window, cx| {
let flags = flags.clone();
let dock_area = cx.new(|cx| DockArea::new("probe-dock", None, window, cx));
let tab_panel =
cx.new(|cx| TabPanel::new(None, dock_area.downgrade(), window, cx));
let content = cx.new(|_| ProbeView { flags, tab_panel });
cx.new(|cx| Root::new(content, window, cx))
},
);
(flags, handle.unwrap())
});
let mut cx = VisualTestContext::from_window(handle.into(), cx);
cx.run_until_parked();
cx.update(|window, cx| {
_ = window.draw(cx);
});
let empty_bounds = cx
.debug_bounds("empty-space")
.expect("empty-space must be laid out");
let control_bounds = cx
.debug_bounds("control-space")
.expect("control-space must be laid out");
let empty_center = empty_bounds.center();
let control_center = control_bounds.center();
// Control: a plain div with the same handlers, outside the tab bar.
cx.simulate_click(control_center, Default::default());
// Target: the tab bar's empty-space strip.
cx.simulate_click(empty_center, Default::default());
let (empty_down, empty_click, control_down, control_click) = cx.read(|cx| {
let flags = flags.read(cx);
(
flags.empty_down.load(Ordering::SeqCst),
flags.empty_click.load(Ordering::SeqCst),
flags.control_down.load(Ordering::SeqCst),
flags.control_click.load(Ordering::SeqCst),
)
});
assert!(
control_down,
"control mouse_down must fire at {control_center:?}"
);
assert!(
control_click,
"control click must fire at {control_center:?}"
);
assert!(
empty_down,
"empty-space mouse_down must fire at {empty_center:?}"
);
assert!(
empty_click,
"empty-space click must fire at {empty_center:?}"
);
}
}
File diff suppressed because it is too large Load Diff
-170
View File
@@ -1,170 +0,0 @@
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;
/// The standard width of a window control button.
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()
// Like native windows apps, 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())
}
+16
View File
@@ -0,0 +1,16 @@
[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
+9
View File
@@ -0,0 +1,9 @@
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);
}
+374
View File
@@ -0,0 +1,374 @@
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)
}
}
+27 -88
View File
@@ -1,23 +1,16 @@
//! Paths to locations used by Signed.
//!
//! Follows the same pattern as Zed's `paths` crate: platform-correct base
//! directories, resolved once and cached, with an optional custom data dir
//! override for portable/dev installs.
use std::path::PathBuf;
use std::sync::OnceLock;
/// The application name, used to derive platform-specific data, config and
/// cache directory paths.
/// The application name.
///
/// It derives the platform-specific data, config and cache directory paths.
pub const APP_NAME: &str = "Signed";
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on
/// Linux/FreeBSD and the macOS `~/.config` fallback.
/// Lowercased form of [`APP_NAME`].
///
/// Used in XDG-style paths on Linux and FreeBSD, and the macOS `~/.config` fallback.
pub const APP_NAME_LOWERCASE: &str = "signed";
/// A custom data directory override, set only by [`set_custom_data_dir`].
static CUSTOM_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// The resolved data directory.
/// On macOS, this is `~/Library/Application Support/Signed`.
/// On Linux/FreeBSD, this is `$XDG_DATA_HOME/signed`.
@@ -30,38 +23,27 @@ static CURRENT_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// On Windows, this is `%APPDATA%\Signed`.
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Returns the current user's home directory.
pub fn home_dir() -> PathBuf {
fn home_dir() -> PathBuf {
dirs::home_dir().expect("failed to determine home directory")
}
/// Sets a custom directory for all user data, overriding the default data
/// directory. Must be called before any other path operation. The directory
/// is created if it doesn't exist and canonicalized to an absolute path.
/// Returns the current user's Desktop folder.
///
/// # Panics
///
/// Panics if called after [`data_dir`] or [`config_dir`] was initialized, or
/// if the directory cannot be created/canonicalized.
pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() {
panic!("set_custom_data_dir called after data_dir or config_dir was initialized");
/// Falls back to the home directory or an empty path when it cannot be determined.
pub fn desktop_dir() -> PathBuf {
dirs::desktop_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
}
CUSTOM_DATA_DIR.get_or_init(|| {
let path = PathBuf::from(dir);
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
path.canonicalize()
.expect("failed to canonicalize custom data directory")
})
/// Returns the current user's Documents folder.
///
/// Falls back to the home directory or an empty path when it cannot be determined.
pub fn documents_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
}
/// Returns the path to the configuration directory.
pub fn config_dir() -> &'static PathBuf {
fn config_dir() -> &'static PathBuf {
CONFIG_DIR.get_or_init(|| {
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
custom_dir.join("config")
} else if cfg!(target_os = "windows") {
if cfg!(target_os = "windows") {
dirs::config_dir()
.expect("failed to determine RoamingAppData directory")
.join(APP_NAME)
@@ -78,12 +60,9 @@ pub fn config_dir() -> &'static PathBuf {
})
}
/// Returns the path to the data directory.
pub fn data_dir() -> &'static PathBuf {
fn data_dir() -> &'static PathBuf {
CURRENT_DATA_DIR.get_or_init(|| {
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
custom_dir.clone()
} else if cfg!(target_os = "macos") {
if cfg!(target_os = "macos") {
home_dir()
.join("Library/Application Support")
.join(APP_NAME)
@@ -104,63 +83,23 @@ pub fn data_dir() -> &'static PathBuf {
})
}
/// Returns the path to the cache directory.
pub fn cache_dir() -> &'static PathBuf {
static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
CACHE_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
dirs::cache_dir()
.expect("failed to determine caches directory")
.join(APP_NAME)
} else if cfg!(target_os = "windows") {
dirs::cache_dir()
.expect("failed to determine LocalAppData directory")
.join(APP_NAME)
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if let Ok(flatpak_xdg_cache) = std::env::var("FLATPAK_XDG_CACHE_HOME") {
flatpak_xdg_cache.into()
} else {
dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory")
}
.join(APP_NAME_LOWERCASE)
} else {
home_dir().join(".cache").join(APP_NAME_LOWERCASE)
}
})
}
/// Returns the path to the logs directory.
pub fn logs_dir() -> &'static PathBuf {
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
LOGS_DIR.get_or_init(|| {
if cfg!(target_os = "macos") {
home_dir().join("Library/Logs").join(APP_NAME)
} else {
data_dir().join("logs")
}
})
}
/// Returns the path to the nostr database directory (LMDB).
/// Returns the path to the nostr database directory, LMDB.
pub fn nostr_dir() -> &'static PathBuf {
static NOSTR_DIR: OnceLock<PathBuf> = OnceLock::new();
NOSTR_DIR.get_or_init(|| data_dir().join("nostr"))
}
/// Returns the path to the local git clone cache (grasp mirrors).
/// Returns the path to the local git clone cache, the grasp mirrors.
///
/// The mirrors are disposable and re-cloned from their grasp server on
/// demand, so the cache lives in the OS temp directory for the system to
/// reclaim.
pub fn repos_dir() -> &'static PathBuf {
static REPOS_DIR: OnceLock<PathBuf> = OnceLock::new();
REPOS_DIR.get_or_init(|| data_dir().join("repos"))
REPOS_DIR.get_or_init(|| std::env::temp_dir().join(APP_NAME_LOWERCASE).join("repos"))
}
/// Returns the path to the `settings.json` file.
pub fn settings_file() -> &'static PathBuf {
static SETTINGS_FILE: OnceLock<PathBuf> = OnceLock::new();
SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json"))
}
/// Returns the path to the `keymap.json` file.
pub fn keymap_file() -> &'static PathBuf {
static KEYMAP_FILE: OnceLock<PathBuf> = OnceLock::new();
KEYMAP_FILE.get_or_init(|| config_dir().join("keymap.json"))
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "settings"
description = "Persisted application settings for Signed."
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
paths = { path = "../paths" }
gpui.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
log.workspace = true
+5
View File
@@ -0,0 +1,5 @@
mod settings;
mod store;
pub use settings::*;
pub use store::*;
+165
View File
@@ -0,0 +1,165 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// The default grasp servers, offered while the user has not published a grasp list.
pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [
"wss://relay.ngit.dev",
"wss://gitnostr.com",
"wss://git.shakespeare.diy",
];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AppearanceMode {
#[default]
System,
Light,
Dark,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventFetchingStrategy {
/// Only the relays declared in the repository announcement.
Curated,
/// Repository relays plus every maintainer's NIP-65 relays.
#[default]
Uncensored,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GraspServersSettings {
/// Servers offered while the user has not published a grasp list, kind `10317`.
pub default_servers: Vec<String>,
}
impl Default for GraspServersSettings {
fn default() -> Self {
Self {
default_servers: DEFAULT_GRASP_SERVERS
.iter()
.map(|server| (*server).to_owned())
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LocalReposSettings {
/// The directories scanned for local git repositories,
/// Defaults:
///
/// - `~/Desktop`
/// - `~/Documents`
pub scan_paths: Vec<PathBuf>,
}
impl Default for LocalReposSettings {
fn default() -> Self {
Self {
scan_paths: vec![paths::desktop_dir(), paths::documents_dir()],
}
}
}
/// A remembered association between a local checkout folder and an announced repository,
/// recorded when the user clones a repository or picks a folder in the New PR panel.
///
/// The panel can then prefill the folder later without asking again.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct CheckoutRecord {
pub path: PathBuf,
/// Repository address as a string, `30617:<pubkey>:<id>`.
pub addr: String,
/// Unix seconds of the last use, for freshest-first ordering.
pub last_used: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CheckoutsSettings {
/// The latest use of a path and repo pair replaces the older record.
pub records: Vec<CheckoutRecord>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CreateRepositorySettings {
/// The folder the create-repository dialog defaults to, the user's Desktop when unset.
pub default_folder: Option<PathBuf>,
}
/// The complete set of persisted application settings.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub appearance: AppearanceMode,
pub event_fetching: EventFetchingStrategy,
pub grasp_servers: GraspServersSettings,
pub local_repos: LocalReposSettings,
pub checkouts: CheckoutsSettings,
pub create_repository: CreateRepositorySettings,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_roundtrip_preserves_everything() {
let settings = Settings {
appearance: AppearanceMode::Dark,
event_fetching: EventFetchingStrategy::Curated,
create_repository: CreateRepositorySettings {
default_folder: Some(PathBuf::from("/tmp/repos")),
},
..Default::default()
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, settings);
}
#[test]
fn partial_json_merges_with_defaults() {
let settings: Settings = serde_json::from_str(
r#"{"appearance": "dark", "grasp_servers": {"default_servers": []}}"#,
)
.unwrap();
assert_eq!(settings.appearance, AppearanceMode::Dark);
assert_eq!(settings.grasp_servers.default_servers, Vec::<String>::new());
// 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());
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();
assert_eq!(json, r#""curated""#);
let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap();
assert_eq!(parsed, EventFetchingStrategy::Uncensored);
}
}
+156
View File
@@ -0,0 +1,156 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use gpui::{App, Context, Entity, Global};
use crate::Settings;
struct GlobalSettingsStore(Entity<SettingsStore>);
impl Global for GlobalSettingsStore {}
/// The application settings,
/// loaded from disk at startup and saved whenever they change.
///
/// Installed as a global by the app so any part of the UI can read and edit them.
pub struct SettingsStore {
path: PathBuf,
settings: Settings,
}
impl SettingsStore {
/// Retrieve the global settings store, created at startup by the app.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalSettingsStore>().0.clone()
}
/// Retrieve the global settings store if one has been installed.
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalSettingsStore>()
.map(|store| store.0.clone())
}
pub fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalSettingsStore(entity));
}
/// Load the settings from `path`,
/// falls back to defaults when the file is missing or unreadable.
///
/// Missing keys merge with the defaults,
/// older settings files keep working as new settings are added.
pub fn new(path: impl AsRef<Path>, _cx: &mut Context<Self>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
settings: Self::load(path.as_ref()),
}
}
/// A snapshot of the current settings.
pub fn settings(&self) -> &Settings {
&self.settings
}
/// Mutate the settings, persist them to disk, and notify observers.
pub fn edit(&mut self, f: impl FnOnce(&mut Settings), cx: &mut Context<Self>) {
f(&mut self.settings);
if let Err(err) = self.save() {
log::error!(
"failed to save settings to {}: {err:#}",
self.path.display()
);
}
cx.notify();
}
/// Read the settings file, merging any missing fields with the defaults.
fn load(path: &Path) -> Settings {
match std::fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str::<Settings>(&contents) {
Ok(settings) => settings,
Err(err) => {
log::error!(
"failed to parse settings file {}: {err}; using defaults",
path.display()
);
Settings::default()
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(),
Err(err) => {
log::error!(
"failed to read settings file {}: {err}; using defaults",
path.display()
);
Settings::default()
}
}
}
/// Write the settings to disk, replacing the file atomically.
fn save(&self) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&self.settings)?;
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
// `rename` cannot replace an existing file on Windows.
if cfg!(target_os = "windows") && self.path.exists() {
std::fs::remove_file(&self.path)?;
}
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
/// A unique, temporary settings path for one test.
fn temp_settings_path() -> PathBuf {
let n = TEST_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!(
"signed-settings-test-{}-{n}.json",
std::process::id()
))
}
fn cleanup(path: &Path) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(path.with_extension("json.tmp"));
}
#[test]
fn missing_file_loads_defaults() {
let path = temp_settings_path();
cleanup(&path);
let settings = SettingsStore::load(&path);
assert_eq!(settings, Settings::default());
cleanup(&path);
}
#[test]
fn save_and_load_roundtrip() {
let path = temp_settings_path();
cleanup(&path);
let mut expected = Settings::default();
expected.create_repository.default_folder = Some(PathBuf::from("/tmp/repos"));
let store = SettingsStore {
path: path.clone(),
settings: expected.clone(),
};
store.save().unwrap();
assert_eq!(SettingsStore::load(&path), expected);
cleanup(&path);
}
}
@@ -1,23 +1,23 @@
[package]
name = "workspace"
name = "signed_app"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
assets = { path = "../assets" }
dock = { workspace = true }
metadata = { path = "../metadata" }
paths = { path = "../paths" }
settings = { path = "../settings" }
signed_core = { path = "../signed_core" }
signed_git = { path = "../signed_git" }
signed_nostr = { path = "../signed_nostr" }
signed_state = { path = "../signed_state" }
utils = { path = "../utils" }
gpui.workspace = true
gpui-component.workspace = true
gpui-base.workspace = true
gix.workspace = true
gpui-native-kit.workspace = true
nostr.workspace = true
anyhow.workspace = true
futures.workspace = true
log.workspace = true
+17
View File
@@ -0,0 +1,17 @@
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),
}
}
+111
View File
@@ -0,0 +1,111 @@
pub mod helpers;
mod placeholder;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
Subscription, Window, div,
};
use gpui_native_kit::icons::IconRole;
use gpui_native_kit::layout::dock::{Dock, DockState};
use gpui_native_kit::layout::shell::{LayoutState, Shell, SidebarToggles};
use gpui_native_kit::theme::ActiveTheme as _;
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);
}));
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();
div()
.size_full()
.font_family(".SystemUIFont")
.text_color(theme.colors.foreground)
.child(
Shell::new("signed-shell", self.layout.clone())
.left_sidebar(self.sidebar.read(cx).build(cx))
.center(
Dock::new("signed-dock", self.dock.clone())
.window_controls(true)
.traffic_light_pad(!self.layout.read(cx).is_left_sidebar_visible())
.sidebar_toggles(self.layout.clone()),
),
)
.child(self.dialogs.clone())
}
}
+30
View File
@@ -0,0 +1,30 @@
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()))
}
}
+191
View File
@@ -0,0 +1,191 @@
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 _;
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()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.whitespace_nowrap()
.text_ellipsis()
.child(name),
)
.when_some(fork_label, |this, label| {
this.child(
h_flex()
.gap_1()
.text_xs()
.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()
.text_xs()
.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()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.whitespace_nowrap()
.child(owner_name),
),
)
.child(
h_flex()
.flex_1()
.justify_end()
.text_xs()
.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()
}
+255
View File
@@ -0,0 +1,255 @@
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 _;
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()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_xs()
.child("Repositories"),
)
.child(
div()
.text_size(px(10.))
.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).size(px(14.)).color(muted)),
),
)
.child(div().flex_1())
.child(
Segmented::new("explore-filter", self.filter)
.options(vec![
SegmentedOption::new(RepoFilter::All, "All")
.icon(Icon::named(CustomIconName::Grid).size(px(14.)).color(muted)),
SegmentedOption::new(RepoFilter::Popular, "Popular").icon(
Icon::named(CustomIconName::Trending)
.size(px(14.))
.color(muted),
),
SegmentedOption::new(RepoFilter::Recent, "Recent").icon(
Icon::named(CustomIconName::Recent)
.size(px(14.))
.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)
.size(px(14.))
.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(),
)),
)
}
}
+56
View File
@@ -0,0 +1,56 @@
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 _;
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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(
"You will be signed out on this device and will need to sign in again on the next session.",
),
)
.into_any_element()
})
.actions(|_, _| {
let cancel: AnyElement = Button::new("cancel-exit")
.label("Cancel")
.variant(ButtonVariant::Secondary)
.on_click(|_, _, cx| close_global(cx))
.into_any_element();
let exit: AnyElement = Button::new("confirm-exit")
.label("Exit")
.variant(ButtonVariant::Destructive)
.on_click(|_, _, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.sign_out(cx));
close_global(cx);
})
.into_any_element();
vec![cancel, exit]
})
}
}
+23
View File
@@ -0,0 +1,23 @@
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);
}
+342
View File
@@ -0,0 +1,342 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Subscription, Task, Window, div, px,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::input::{Input, InputState, InputStateEvent};
use gpui_native_kit::theme::ActiveTheme as _;
use signed_state::Backend;
use 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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(description),
)
.child(form)
.when_some(error_row(&state.progress.error, cx), |el, error| {
el.child(error)
})
.into_any_element()
})
.actions(move |_, cx| {
let state = action_state.read(cx);
let busy = state.progress.busy;
let label = match state.stage {
Stage::Credential => "Continue",
Stage::Encrypt => "Import identity",
Stage::Decrypt => "Unlock",
};
let secret = action_secret.clone();
let pass = action_pass.clone();
let repass = action_repass.clone();
let state = action_state.clone();
let import: AnyElement = Button::new("import-identity")
.label(label)
.loading(busy)
.disabled(busy)
.on_click(move |_, _, cx| {
submit(&secret, &pass, &repass, &state, cx);
})
.into_any_element();
vec![import]
})
}
}
fn submit(
secret_input: &Entity<InputState>,
pass_input: &Entity<InputState>,
repass_input: &Entity<InputState>,
state: &Entity<ImportState>,
cx: &mut App,
) {
if state.read(cx).progress.busy {
return;
}
match state.read(cx).stage {
Stage::Credential => start_import(secret_input, state, cx),
Stage::Encrypt => import_nsec(pass_input, repass_input, state, cx),
Stage::Decrypt => import_ncryptsec(pass_input, state, cx),
}
}
/// Parses the entered credential and either moves to the passphrase step or connects to a bunker.
fn start_import(secret_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
let credential = match Credential::parse(secret_input.read(cx).value()) {
Some(credential) => credential,
None => {
state.update(cx, |state, cx| {
state
.progress
.fail("Enter an nsec, ncryptsec or bunker:// URI");
cx.notify();
});
return;
}
};
match credential {
Credential::Nsec(secret) => {
state.update(cx, |state, cx| {
state.credential = secret;
state.stage = Stage::Encrypt;
state.progress.error = None;
cx.notify();
});
}
Credential::Ncryptsec(secret) => {
state.update(cx, |state, cx| {
state.credential = secret;
state.stage = Stage::Decrypt;
state.progress.error = None;
cx.notify();
});
}
Credential::Bunker(uri) => {
state.update(cx, |state, cx| {
state.progress.begin();
cx.notify();
});
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx));
finish_import(task, state, cx);
}
}
}
/// Encrypts the entered `nsec` with the passphrase and signs in with it.
fn import_nsec(
pass_input: &Entity<InputState>,
repass_input: &Entity<InputState>,
state: &Entity<ImportState>,
cx: &mut App,
) {
let backend = Backend::global(cx);
let nsec = state.read(cx).credential.clone();
let pass = pass_input.read(cx).value().to_string();
let repass = repass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, cx| {
state.progress.fail("Passphrase must not be empty");
cx.notify();
});
return;
}
if pass != repass {
state.update(cx, |state, cx| {
state.progress.fail("Passphrases do not match");
cx.notify();
});
return;
}
state.update(cx, |state, cx| {
state.progress.begin();
cx.notify();
});
let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx));
finish_import(task, state, cx);
}
/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it.
fn import_ncryptsec(pass_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
let backend = Backend::global(cx);
let ncryptsec = state.read(cx).credential.clone();
let pass = pass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, cx| {
state.progress.fail("Passphrase must not be empty");
cx.notify();
});
return;
}
state.update(cx, |state, cx| {
state.progress.begin();
cx.notify();
});
let task = backend.update(cx, |backend, cx| {
backend.import_ncryptsec(&ncryptsec, &pass, cx)
});
finish_import(task, state, cx);
}
/// Runs the import task to completion, closing the dialog on success.
fn finish_import<T: 'static>(
task: Task<Result<T, anyhow::Error>>,
state: &Entity<ImportState>,
cx: &mut App,
) {
let state = state.clone();
cx.spawn(async move |cx| match task.await {
Ok(_) => cx.update(close_global),
Err(error) => {
state.update(cx, |state, cx| {
state.progress.fail(error.to_string());
cx.notify();
});
}
})
.detach();
}
+5
View File
@@ -0,0 +1,5 @@
pub mod exit;
pub mod helpers;
pub mod import;
pub mod onboarding;
pub mod passphrase;
@@ -0,0 +1,140 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window, div, px,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::theme::ActiveTheme as _;
use signed_state::Backend;
use 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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("Set up your Signed identity to get started."),
)
.child(
Field::new("name-field", "Name")
.description("Max 255 characters")
.child(Input::new("name-input", name_input.clone())),
)
.child(
Field::new("passphrase-field", "Passphrase")
.child(Input::new("passphrase-input", pass_input.clone())),
)
.child(
Field::new("repeat-field", "Repeat passphrase")
.child(Input::new("repeat-input", repass_input.clone())),
)
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
.into_any_element()
})
.actions(move |_, cx| {
let busy = action_state.read(cx).busy;
let this = this.clone();
let create: AnyElement = Button::new("create-identity")
.label("Create new identity")
.loading(busy)
.disabled(busy)
.on_click(move |_, _, cx| {
crate::shared::log_dropped(
this.update(cx, |this, cx| this.create_identity(cx)),
);
})
.into_any_element();
vec![create]
})
}
}
@@ -0,0 +1,133 @@
use assets::CustomIconName;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Subscription, Window, div,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::icons::Icon;
use gpui_native_kit::input::{Input, InputState, InputStateEvent};
use gpui_native_kit::theme::ActiveTheme as _;
use signed_state::Backend;
use 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, _, _| {})
.title("Unlock your identity")
.content(move |_window, cx| {
let progress = state.read(cx);
v_flex()
.gap_3()
.child(
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("Enter the passphrase used to encrypt this identity."),
)
.child(
Field::new("passphrase-field", "Passphrase")
.child(Input::new("passphrase-input", pass_input.clone())),
)
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
el.child(steps)
})
.into_any_element()
})
.actions(move |_, cx| {
let busy = action_state.read(cx).busy;
let this = this.clone();
let unlock: AnyElement = Button::new("unlock")
.label("Unlock")
.icon(Icon::named(CustomIconName::Unlock))
.loading(busy)
.disabled(busy)
.on_click(move |_, _, cx| {
crate::shared::log_dropped(this.update(cx, |this, cx| this.unlock(cx)));
})
.into_any_element();
vec![unlock]
})
}
}
+193
View File
@@ -0,0 +1,193 @@
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 _;
use metadata::ProfileDirectory;
use nostr::prelude::{Event, Kind, PublicKey};
use utils::relative_time;
pub fn repo_header(id: SharedString, name: SharedString, unread: usize) -> AnyElement {
h_flex()
.h_12()
.w_full()
.gap_2()
.child(
div()
.min_w_0()
.text_sm()
.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()
.text_xs()
.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()
.text_sm()
.child(
h_flex()
.size_6()
.flex_shrink_0()
.items_center()
.justify_center()
.child(
Icon::named(CustomIconName::Inbox)
.size(px(14.))
.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()
.text_xs()
.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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(SharedString::from(message)),
)
.into_any_element()
}
+476
View File
@@ -0,0 +1,476 @@
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Error;
use assets::CustomIconName;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, WeakEntity,
Window, div,
};
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::layout::dock::{DockState, Panel};
use gpui_native_kit::scroll_area::ScrollArea;
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::ProfileDirectory;
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
use signed_core::{InboxItem, InboxReadState, RepoAddr};
use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore};
mod helpers;
use helpers::{empty_section_row, empty_state, repo_header, thread};
use crate::repo::actions::{RepoItem, open_repo_item};
const MAX_SUB_ACTIVITIES: usize = 5;
#[derive(Clone, Copy)]
enum InboxRow {
Repo(usize),
Entry(usize, usize),
Empty,
}
struct InboxSection {
address: Option<RepoAddr>,
unread: usize,
entries: Vec<usize>,
latest: Timestamp,
}
pub struct InboxView {
dock: WeakEntity<DockState>,
threads: Arc<Vec<InboxItem>>,
sections: Arc<Vec<InboxSection>>,
rows: Arc<Vec<InboxRow>>,
state: InboxReadState,
state_loaded: bool,
refresh: RefreshGate,
tasks: Vec<Task<Result<(), Error>>>,
directory: Entity<ProfileDirectory>,
_subscriptions: Vec<Subscription>,
}
impl InboxView {
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
let repos = RepoListStore::global(cx);
let weak = cx.entity().downgrade();
let mut subscriptions = vec![];
subscriptions.push(cx.observe(&inbox, |this, _inbox, cx| {
this.sync_state(cx);
}));
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
this.handle_backend_event(event, cx);
}));
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
this.rebuild(cx);
cx.notify();
}));
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.sync_state(cx)) {
log::warn!("inbox dropped before bootstrap could run: {error}");
}
});
Self {
dock,
threads: Arc::new(Vec::new()),
sections: Arc::new(Vec::new()),
rows: Arc::new(Vec::new()),
state: InboxReadState::default(),
state_loaded: false,
refresh: RefreshGate::default(),
tasks: vec![],
directory: ProfileDirectory::new(cx),
_subscriptions: subscriptions,
}
}
pub fn mark_all_read(&mut self, cx: &mut Context<Self>) {
let Some(me) = Backend::global(cx).read(cx).current_user() else {
return;
};
let all: Vec<Event> = self
.threads
.iter()
.flat_map(|item| item.events.iter().cloned())
.collect();
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx));
}
pub fn sync_state(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let inbox = backend.read(cx).inbox();
let (loaded, state) = {
let inbox = inbox.read(cx);
(inbox.is_loaded(), inbox.state().clone())
};
if !loaded {
let was_present =
self.state_loaded || !self.threads.is_empty() || !self.sections.is_empty();
self.clear();
if was_present {
cx.notify();
}
return;
}
if !self.state_loaded {
self.state_loaded = true;
self.state = state;
self.refresh_initial(cx);
return;
}
if self.state != state {
self.state = state;
self.regroup(cx);
cx.notify();
}
}
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
match event {
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => self.refresh(cx),
_ => {}
}
}
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
fn refresh(&mut self, cx: &mut Context<Self>) {
if !self.state_loaded || self.refresh.request() != RefreshRequest::Schedule {
return;
}
self.run_refresh(cx);
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
let backend = Backend::global(cx);
let (me, client) = {
let backend = backend.read(cx);
(backend.current_user(), backend.client())
};
let Some(me) = me else {
self.refresh.abort();
return;
};
let state = self.state.clone();
let work = cx.background_spawn(async move { Inbox::query(&client, me, &state).await });
self.tasks.push(cx.spawn(async move |this, cx| {
let (threads, _unread_count) = match work.await {
Ok(results) => results,
Err(error) => {
log::warn!("inbox refresh failed: {error}");
return this.update(cx, |this, _cx| this.refresh.abort());
}
};
let again = this.update(cx, |this, cx| {
if backend.read(cx).current_user() != Some(me) {
this.refresh.abort();
return false;
}
this.threads = Arc::new(threads);
this.rebuild(cx);
cx.notify();
this.refresh.finish()
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok(())
}));
}
fn regroup(&mut self, cx: &mut Context<Self>) {
let mut items = (*self.threads).clone();
for item in items.iter_mut() {
item.apply_state(&self.state);
}
self.threads = Arc::new(items);
self.rebuild(cx);
}
fn rebuild(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let repo_list = RepoListStore::global(cx);
let mut sections = self.group_sections();
if let Some(me) = backend.read(cx).current_user() {
for announcement in repo_list.read(cx).announcements_of(&me) {
let address = announcement.addr();
let known = sections
.iter()
.any(|section| section.address.as_ref() == Some(&address));
if !known {
sections.push(InboxSection {
address: Some(address),
unread: 0,
entries: Vec::new(),
latest: Timestamp::default(),
});
}
}
}
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
let rows = self.flatten_rows(&sections);
self.sections = Arc::new(sections);
self.rows = Arc::new(rows);
let mut keys: Vec<PublicKey> = self
.threads
.iter()
.flat_map(|item| item.events.iter().map(|event| event.pubkey))
.collect();
keys.sort_by_key(PublicKey::to_hex);
keys.dedup();
self.directory
.update(cx, |directory, cx| directory.track(keys, cx));
}
fn group_sections(&self) -> Vec<InboxSection> {
let mut by_repo: HashMap<Option<RepoAddr>, InboxSection> = HashMap::new();
for (ix, item) in self.threads.iter().enumerate() {
if item.archived {
continue;
}
let address = item.address.clone();
let section = by_repo
.entry(address.clone())
.or_insert_with(move || InboxSection {
address,
unread: 0,
entries: Vec::new(),
latest: Timestamp::default(),
});
if item.is_unread() {
section.unread += 1;
}
section.latest = section.latest.max(item.latest_activity());
section.entries.push(ix);
}
let mut sections: Vec<InboxSection> = by_repo.into_values().collect();
for section in &mut sections {
section.entries.sort_by(|a, b| {
self.threads[*b]
.latest_activity()
.cmp(&self.threads[*a].latest_activity())
});
}
sections.sort_by_key(|section| std::cmp::Reverse(section.latest));
sections
}
fn flatten_rows(&self, sections: &[InboxSection]) -> Vec<InboxRow> {
let mut rows = Vec::new();
for (section_ix, section) in sections.iter().enumerate() {
rows.push(InboxRow::Repo(section_ix));
if section.entries.is_empty() {
rows.push(InboxRow::Empty);
continue;
}
rows.extend(
(0..section.entries.len()).map(|entry_ix| InboxRow::Entry(section_ix, entry_ix)),
);
}
rows
}
fn clear(&mut self) {
self.threads = Arc::new(Vec::new());
self.sections = Arc::new(Vec::new());
self.rows = Arc::new(Vec::new());
self.state = InboxReadState::default();
self.state_loaded = false;
self.refresh = RefreshGate::default();
}
fn open(
&self,
root: EventId,
kind: Option<Kind>,
address: Option<RepoAddr>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(address) = address else {
return;
};
let item = match kind {
Some(Kind::GitIssue) => RepoItem::Issue(root),
Some(Kind::GitPullRequest) => RepoItem::PullRequest(root),
Some(Kind::GitPatch) => RepoItem::Patch,
_ => return,
};
open_repo_item(&self.dock, &address, None, item, window, cx);
}
fn render_entry(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
let Some(row) = self.rows.get(ix) else {
return div().into_any_element();
};
match *row {
InboxRow::Empty => empty_section_row(cx),
InboxRow::Repo(section_ix) => {
let Some(section) = self.sections.get(section_ix) else {
return div().into_any_element();
};
let name = repo_name(section.address.as_ref(), cx);
let id = SharedString::from(format!(
"inbox-unread:{}",
section
.address
.as_ref()
.map(ToString::to_string)
.unwrap_or_default()
));
repo_header(id, name, section.unread)
}
InboxRow::Entry(section_ix, entry_ix) => {
let Some(section) = self.sections.get(section_ix) else {
return div().into_any_element();
};
let Some(&thread_ix) = section.entries.get(entry_ix) else {
return div().into_any_element();
};
let Some(item) = self.threads.get(thread_ix) else {
return div().into_any_element();
};
let root = item.root;
let kind = item.root_event.as_ref().map(|event| event.kind);
let address = section.address.clone();
let first = entry_ix == 0;
let last = entry_ix + 1 == section.entries.len();
let me = Backend::global(cx).read(cx).current_user();
thread(
"inbox-row",
ix,
SharedString::from(item.title()),
item.is_unread(),
first,
last,
item.timeline(MAX_SUB_ACTIVITIES),
me,
&self.directory,
cx,
)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open(root, kind, address.clone(), window, cx)
}))
.into_any_element()
}
}
}
}
impl Panel for InboxView {
fn title(&self, _cx: &App) -> Option<SharedString> {
Some(SharedString::from("Inbox"))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
Some(
Icon::named(CustomIconName::Inbox)
.size(gpui::px(14.))
.color(cx.theme().colors.muted_foreground)
.into_any_element(),
)
}
}
impl Render for InboxView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rows = self.rows.clone();
let mut children = Vec::with_capacity(rows.len());
for ix in 0..rows.len() {
children.push(self.render_entry(ix, cx));
}
v_flex()
.size_full()
.child(
h_flex()
.px_4()
.h_12()
.w_full()
.gap_2()
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(SharedString::from("Inbox")),
)
.child(div().flex_1())
.child(
Button::new("mark-all")
.icon(Icon::named(IconRole::Check))
.label("Mark all read")
.on_click(cx.listener(|this, _, _, cx| this.mark_all_read(cx))),
),
)
.child(
div()
.relative()
.flex_1()
.min_h_0()
.px_4()
.child(if rows.is_empty() {
empty_state("You're all caught up.", cx)
} else {
ScrollArea::new("inbox-scroll")
.flex_1()
.child(v_flex().gap_2().py_2().children(children))
.into_any_element()
}),
)
.into_any_element()
}
}
fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> SharedString {
let Some(addr) = addr else {
return SharedString::from("Untitled");
};
RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == *addr)
.map(|announcement| {
announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
})
.unwrap_or_else(|| SharedString::from("Untitled"))
}
+229
View File
@@ -0,0 +1,229 @@
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 _;
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()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(title),
),
)
.child(
v_flex()
.gap_8()
.child(
v_flex()
.gap_4()
.child(
h_flex()
.gap_2()
.text_sm()
.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().text_sm().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()
}
}
+280
View File
@@ -0,0 +1,280 @@
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 _;
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().text_sm().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()
.text_sm()
.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()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.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()
.text_sm()
.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().text_sm().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()
.text_xs()
.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()
}
+325
View File
@@ -0,0 +1,325 @@
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 _;
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()
.text_sm()
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.child(
h_flex()
.gap_2()
.text_xs()
.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()
}
}
+97
View File
@@ -0,0 +1,97 @@
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 _;
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()
.text_sm()
.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);
}
+19
View File
@@ -0,0 +1,19 @@
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))
}
@@ -0,0 +1,623 @@
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 _;
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()
.text_sm()
.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().text_sm().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()
.font_weight(gpui::FontWeight::SEMIBOLD)
.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)
.text_xs()
.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()
}
@@ -0,0 +1 @@
pub mod update;
@@ -0,0 +1,85 @@
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 _;
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()
.text_sm()
.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);
}
@@ -0,0 +1,151 @@
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
}
+455
View File
@@ -0,0 +1,455 @@
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 _;
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()
.text_sm()
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.child(
h_flex()
.gap_2()
.text_xs()
.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()
}
}
+529
View File
@@ -0,0 +1,529 @@
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 _;
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"))
.child(div().w(px(160.)).child(self.base_select.clone())),
)
.child(
v_flex()
.gap_1()
.child(label("Pull From"))
.child(div().w(px(160.)).child(self.compare_select.clone())),
)
.child(
v_flex().gap_1().child(label("Source")).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(CustomIconName::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()
.text_xs()
.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) -> AnyElement {
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(text)
.into_any_element()
}
@@ -0,0 +1,397 @@
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);
}
}
@@ -0,0 +1,188 @@
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 _;
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()
.text_xs()
.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()
.text_xs()
.text_color(cx.theme().ramps.destructive.base)
.child(error),
)
}),
)
.child(self.render_footer(cx))
}
}
+93
View File
@@ -0,0 +1,93 @@
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 => {}
}
}
+54
View File
@@ -0,0 +1,54 @@
use std::collections::HashSet;
use std::path::PathBuf;
use signed_state::{CheckoutStatus, CheckoutSyncStatus};
#[derive(Default)]
pub(super) struct Banners {
dismissed: HashSet<(PathBuf, String)>,
sync_dismissed: HashSet<PathBuf>,
ready_requested: bool,
ready_head: Option<String>,
ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>,
}
impl Banners {
pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool {
self.dismissed
.contains(&(status.path.clone(), status.branch.clone()))
}
pub(super) fn dismiss(&mut self, status: &CheckoutStatus) {
self.dismissed
.insert((status.path.clone(), status.branch.clone()));
}
pub(super) fn sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
self.sync_dismissed.contains(&checkout.path)
}
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
self.sync_dismissed.insert(checkout.path.clone());
}
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
(self.ready_requested, &self.ready_head)
}
pub(super) fn mark_ready_requested(&mut self, head: Option<String>) {
self.ready_requested = true;
self.ready_head = head;
}
pub(super) fn set_statuses(
&mut self,
ready: Vec<CheckoutStatus>,
push: Vec<CheckoutStatus>,
) -> bool {
let changed = ready != self.ready_statuses || push != self.push_statuses;
self.ready_statuses = ready;
self.push_statuses = push;
changed
}
}
+553
View File
@@ -0,0 +1,553 @@
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 _;
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
}
})
.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()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(status_color)
.child(status_label),
)
.child(
div()
.flex_1()
.min_w_0()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.when(!file.binary, |this| {
this.child(
h_flex()
.gap_2()
.text_xs()
.child(
div()
.text_color(cx.theme().ramps.success.base)
.child(format!("+{}", file.insertions)),
)
.child(
div()
.text_color(cx.theme().ramps.destructive.base)
.child(format!("-{}", file.deletions)),
),
)
}),
)
.child(div().flex_1().min_h_0().child(body))
.into_any_element()
}
}
impl Render for DiffPane {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tree = self.render_tree_column(cx);
let detail = self.render_detail_column(cx);
h_flex()
.size_full()
.min_h_0()
.bg(cx.theme().colors.background)
.child(
Resizable::new("commit-diff-split", self.split.clone())
.axis(Axis::Horizontal)
.pane_with_min(tree, px(120.))
.pane_with_min(detail, px(240.)),
)
}
}
pub struct CommitDiffView {
store: Entity<RepoStore>,
worktree: PathBuf,
repo_name: SharedString,
commit: FileCommit,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
pane: Entity<DiffPane>,
/// Profile for the repository owner in the tab title.
directory: Entity<ProfileDirectory>,
tasks: Vec<Task<Result<(), anyhow::Error>>>,
}
impl CommitDiffView {
pub fn new(
store: Entity<RepoStore>,
worktree: PathBuf,
repo_name: SharedString,
commit_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let pane = cx.new(DiffPane::new);
let directory = ProfileDirectory::new(cx);
if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) {
directory.update(cx, |directory, cx| {
directory.track([owner], cx);
});
}
let weak = cx.entity().downgrade();
window.defer(cx, move |window, cx| {
if let Err(error) = weak.update(cx, |this, cx| this.load(window, cx)) {
log::warn!("failed to load commit diff: {error}");
}
});
Self {
store,
worktree,
repo_name,
commit: FileCommit {
id: commit_id,
summary: String::new(),
description: None,
author: String::new(),
time: 0,
},
loading: true,
error: None,
pane,
directory,
tasks: Vec::new(),
}
}
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
let worktree = self.worktree.clone();
let id = self.commit.id.clone();
let task: Task<Result<(), anyhow::Error>> = cx.spawn_in(window, async move |this, cx| {
let commit = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { Repo::open(&worktree).and_then(|repo| repo.commit(&id)) }
})
.await;
let diff = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { Repo::open(&worktree).and_then(|repo| repo.commit_diff(&id)) }
})
.await;
this.update_in(cx, |this, _window, cx| {
this.loading = false;
if let Ok(Some(commit)) = commit {
this.commit = commit;
}
match diff {
Ok(diff) => {
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit;
let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
(
diff.files.len(),
diff.files.iter().map(|file| file.insertions).sum(),
diff.files.iter().map(|file| file.deletions).sum(),
)
});
v_flex()
.flex_none()
.px_4()
.py_3()
.w_full()
.gap_2()
.border_b_1()
.border_color(cx.theme().colors.border)
.child(
v_flex()
.child(
div()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
)
.child(
h_flex()
.gap_2()
.text_sm()
.child(SharedString::from(format!("{} committed", commit.author)))
.child(
h_flex()
.gap_1()
.text_color(cx.theme().colors.muted_foreground)
.child(SharedString::from(&commit.id))
.child(ClipboardButton::new("commit-id", &commit.id)),
)
.child(
div()
.text_color(cx.theme().colors.muted_foreground)
.child(relative_time_secs(commit.time)),
),
),
)
.when_some(commit.description.as_ref(), |this, description| {
this.child(div().text_sm().child(SharedString::from(description)))
})
.child(
h_flex()
.gap_2()
.text_xs()
.child(Badge::new("commit-files", format!("{files} files changed")))
.when(insertions > 0, |this| {
this.child(
Badge::new("commit-insertions", format!("+ {insertions}"))
.variant(BadgeVariant::Success),
)
})
.when(deletions > 0, |this| {
this.child(
Badge::new("commit-deletions", format!("- {deletions}"))
.variant(BadgeVariant::Danger),
)
}),
)
.into_any_element()
}
}
impl Panel for CommitDiffView {
fn title(&self, _cx: &App) -> Option<SharedString> {
Some(SharedString::from(format!(
"{}/{}",
self.repo_name, self.commit.id
)))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
Some(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))
}
}
+265
View File
@@ -0,0 +1,265 @@
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div, px,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::clipboard::ClipboardButton;
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::ProfileDirectory;
use nostr::prelude::PublicKey;
use signed_core::Announcement;
use utils::middle_truncate;
use crate::shared::dialogs::{close_global, open_global};
pub struct AboutDialog {
announcement: Announcement,
directory: Entity<ProfileDirectory>,
}
impl AboutDialog {
pub fn new(
announcement: Announcement,
directory: Entity<ProfileDirectory>,
cx: &mut App,
) -> Entity<Self> {
cx.new(|_| Self {
announcement,
directory,
})
}
}
impl Render for AboutDialog {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let announcement = self.announcement.clone();
let directory = self.directory.clone();
Dialog::new("about-dialog")
.open(true)
.min_width(px(500.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
}
})
.title("About")
.content(move |_, cx| announcement_rows(&announcement, &directory, cx))
.into_any_element()
}
}
fn announcement_rows(
announcement: &Announcement,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new();
rows.push(row(
"Name",
text(
announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from("-")),
),
cx,
));
rows.push(row(
"Description",
text(
announcement
.description
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from("-")),
),
cx,
));
if !announcement.web.is_empty() {
rows.push(row(
"Web",
list(
"about-web",
announcement.web.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if let Some(euc) = &announcement.euc {
rows.push(row(
"Earliest Commit",
copy_value("about-euc", euc.clone(), cx),
cx,
));
}
if let Some(upstream) = &announcement.upstream {
rows.push(row("Upstream", text(upstream.display()), cx));
}
if !announcement.hashtags.is_empty() {
rows.push(row(
"Hashtags",
text(SharedString::from(announcement.hashtags.join(", "))),
cx,
));
}
if !announcement.clone.is_empty() {
rows.push(row(
"Clone URLs",
list(
"about-clone",
announcement.clone.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if !announcement.relays.is_empty() {
rows.push(row(
"Grasp Relays",
list(
"about-relays",
announcement.relays.iter().map(|url| url.to_string()),
cx,
),
cx,
));
}
if !announcement.maintainers.is_empty() {
rows.push(row(
"Maintainers",
maintainers(&announcement.maintainers, directory, cx),
cx,
));
}
v_flex().gap_3().w_full().children(rows).into_any_element()
}
fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement {
v_flex()
.gap_1()
.min_w_0()
.child(
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(cx.theme().colors.muted_foreground)
.child(label),
)
.child(value)
.into_any_element()
}
fn text<T>(value: T) -> AnyElement
where
T: Into<SharedString>,
{
div()
.text_sm()
.w_full()
.min_w_0()
.child(value.into())
.into_any_element()
}
fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
h_flex()
.gap_2()
.items_center()
.min_w_0()
.child(
div()
.font_family(cx.theme().mono_font)
.text_xs()
.min_w_0()
.child(SharedString::from(value.clone())),
)
.child(ClipboardButton::new(id, value))
.into_any_element()
}
fn maintainers(
maintainers: &[PublicKey],
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
v_flex()
.gap_2()
.min_w_0()
.children(maintainers.iter().map(|pubkey| {
let profile = directory.read(cx).profile(pubkey, cx);
let name = profile.name();
h_flex()
.gap_2()
.items_center()
.min_w_0()
.child(
Avatar::new(*pubkey)
.name(name.clone())
.picture(profile.picture())
.small(),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.text_sm()
.child(name),
)
.into_any_element()
}))
.into_any_element()
}
fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) -> AnyElement {
v_flex()
.gap_2()
.min_w_0()
.children(items.into_iter().enumerate().map(|(ix, item)| {
h_flex()
.h_8()
.px_2()
.gap_2()
.min_w_0()
.bg(cx.theme().colors.secondary)
.rounded(cx.theme().radius.control)
.text_color(cx.theme().colors.secondary_foreground)
.child(
div()
.flex_1()
.min_w_0()
.whitespace_nowrap()
.text_ellipsis()
.text_sm()
.child(SharedString::from(middle_truncate(&item, 28, 16))),
)
.child(ClipboardButton::new(
SharedString::from(format!("{id}-{ix}")),
item,
))
.into_any_element()
}))
.into_any_element()
}
/// Opens the read-only repository metadata dialog.
pub fn open(announcement: Announcement, directory: Entity<ProfileDirectory>, cx: &mut App) {
open_global(AboutDialog::new(announcement, directory, cx), cx);
}
@@ -0,0 +1,272 @@
use std::path::PathBuf;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _,
PathPromptOptions, Render, Styled as _, WeakEntity, Window, div, px,
};
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::layout::dock::DockState;
use gpui_native_kit::theme::ActiveTheme as _;
use gpui_native_kit::tooltip::Tooltip;
use settings::SettingsStore;
use signed_state::{Backend, CheckoutsStore};
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
use 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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("Publish a new repository to your grasp servers."),
)
.child(
Field::new("repo-name", "Repository name")
.description("Max 100 characters")
.child(Input::new("repo-name-input", name_input.clone())),
)
.child(
Field::new("repo-description", "Description").child(
Input::new("repo-description-input", desc_input.clone()).rows(3),
),
)
.child(
Field::new("repo-folder", "Folder")
.description("Where the repository's working copy is created.")
.child(
h_flex()
.gap_1()
.items_center()
.child(div().flex_1().child(Input::new(
"repo-folder-input",
folder_input.clone(),
)))
.child(
Button::new("choose-folder")
.icon(Icon::named(IconRole::Folder))
.variant(ButtonVariant::Ghost)
.tooltip(Tooltip::text("Choose folder"))
.on_click({
let folder_input = folder_input.clone();
move |_, window, cx| {
choose_folder(&folder_input, window, cx);
}
}),
),
),
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
el.child(steps)
})
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
.into_any_element()
})
.actions(move |_, cx| {
let busy = action_state.read(cx).busy;
let this = this.clone();
let create: AnyElement = Button::new("create-repository")
.label("Create repository")
.icon(Icon::named(IconRole::Plus))
.loading(busy)
.disabled(busy)
.on_click(move |_, window, cx| {
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);
}
@@ -0,0 +1,271 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Entity, IntoElement, ParentElement as _, SharedString,
Styled as _, Window, div,
};
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::switch::Switch;
use gpui_native_kit::theme::ActiveTheme as _;
use gpui_native_kit::tooltip::Tooltip;
use nostr::prelude::RelayUrl;
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_state::Backend;
/// State of the grasp-server section of a publish dialog, so async results can be rendered.
#[derive(Default)]
pub struct GraspServersState {
/// Set while the user's kind `10317` grasp list loads.
pub loading_servers: bool,
pub grasp_servers: Vec<RelayUrl>,
pub servers_enabled: bool,
/// Error from the last grasp-server edit, such as an invalid relay URL.
pub error: Option<SharedString>,
}
impl GraspServersState {
/// Defaults used until the user's grasp list loads and replaces them.
pub fn new_default(settings: &GraspServersSettings) -> Self {
let urls: Vec<String> = if settings.default_servers.is_empty() {
DEFAULT_GRASP_SERVERS
.iter()
.map(|url| (*url).to_owned())
.collect()
} else {
settings.default_servers.clone()
};
Self {
loading_servers: true,
servers_enabled: false,
grasp_servers: urls
.iter()
.filter_map(|url| RelayUrl::parse(url).ok())
.collect(),
..Default::default()
}
}
}
/// The Grasp servers section shared by the publish dialogs.
pub fn grasp_servers_field(
state: &Entity<GraspServersState>,
relay_input: &Entity<InputState>,
cx: &App,
) -> AnyElement {
let state = state.clone();
let relay_input = relay_input.clone();
let servers = state.read(cx).grasp_servers.clone();
let loading_servers = state.read(cx).loading_servers;
let servers_enabled = state.read(cx).servers_enabled;
let error = state.read(cx).error.clone();
let add_state = state.clone();
let add_input = relay_input.clone();
let toggle_state = state.clone();
v_flex()
.gap_2()
.child(
h_flex()
.justify_between()
.items_center()
.child(div().text_sm().child("Grasp servers"))
.child(
Switch::new("grasp-servers-toggle")
.checked(servers_enabled)
.on_change(move |checked, _, cx| {
toggle_state.update(cx, |state, cx| {
state.servers_enabled = checked;
cx.notify();
});
}),
),
)
.when(servers_enabled, |section| {
section
.child(
div()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child("Where the repository is hosted"),
)
.child(
v_flex().gap_1().children(
servers
.iter()
.enumerate()
.map(|(ix, relay)| render_server_row(ix, relay, state.clone(), cx)),
),
)
.child(
h_flex()
.gap_1()
.items_center()
.child(
div()
.flex_1()
.child(Input::new("grasp-relay-input", relay_input.clone())),
)
.child(
Button::new("add-relay")
.icon(Icon::named(IconRole::Plus))
.variant(ButtonVariant::Ghost)
.tooltip(Tooltip::text("Add grasp server"))
.on_click(move |_, window, cx| {
add_relay(&add_state, &add_input, window, cx);
}),
),
)
.when(loading_servers, |section| {
section.child(
div()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child("Loading your grasp servers..."),
)
})
.when_some(error, |section, error| {
section.child(
div()
.text_xs()
.text_color(cx.theme().ramps.destructive.base)
.child(error),
)
})
})
.into_any_element()
}
fn render_server_row(
ix: usize,
relay: &RelayUrl,
state: Entity<GraspServersState>,
cx: &App,
) -> AnyElement {
h_flex()
.w_full()
.gap_1()
.items_center()
.child(
div()
.flex_1()
.h_8()
.px_2()
.bg(cx.theme().colors.muted)
.text_color(cx.theme().colors.muted_foreground)
.text_sm()
.rounded(cx.theme().radius.control)
.child(server_host(relay)),
)
.child(
Button::new(SharedString::from(format!("remove-relay:{ix}")))
.icon(Icon::named(IconRole::Close))
.variant(ButtonVariant::Ghost)
.tooltip(Tooltip::text("Remove"))
.on_click(move |_, _, cx| {
state.update(cx, |state, cx| {
if ix < state.grasp_servers.len() {
state.grasp_servers.remove(ix);
cx.notify();
}
});
}),
)
.into_any_element()
}
/// Accepts a bare host as well as a full URL.
fn add_relay(
state: &Entity<GraspServersState>,
input: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) {
let value = input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
match normalize_server(&value) {
Some((_, relay)) => {
state.update(cx, |state, cx| {
state.error = None;
if !state.grasp_servers.contains(&relay) {
state.grasp_servers.push(relay);
}
cx.notify();
});
input.update(cx, |input, cx| input.set_value("", window, cx));
}
None => {
state.update(cx, |state, cx| {
state.error = Some(format!("Invalid grasp server URL: {value}").into());
cx.notify();
});
}
}
}
/// Loads the user's kind `10317` grasp list from the local database.
pub fn load_user_grasp_servers(
state: Entity<GraspServersState>,
window: &mut Window,
cx: &mut App,
) {
let backend = Backend::global(cx);
let (user, client) = {
let backend = backend.read(cx);
(backend.current_user(), backend.client())
};
let Some(user) = user else {
state.update(cx, |state, _| state.loading_servers = false);
return;
};
let task =
cx.background_spawn(
async move { signed_state::user_grasp_list_servers(&client, user).await },
);
let handle = window.window_handle();
cx.spawn(async move |cx| {
let result = task.await;
if let Err(error) = cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, cx| {
state.loading_servers = false;
if let Ok(servers) = result
&& !servers.is_empty()
{
state.grasp_servers = servers;
}
cx.notify();
});
}) {
log::warn!("failed to apply grasp servers: {error}");
}
})
.detach();
}
/// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given.
pub fn normalize_server(input: &str) -> Option<(String, RelayUrl)> {
let text = if input.contains("://") {
input.to_owned()
} else {
format!("wss://{input}")
};
RelayUrl::parse(&text).ok().map(|relay| (text, relay))
}
/// The host of a relay URL, which is what the server lists show; the scheme is implied.
pub fn server_host(relay: &RelayUrl) -> SharedString {
relay
.domain()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(relay.to_string()))
}
+291
View File
@@ -0,0 +1,291 @@
use std::path::PathBuf;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, WeakEntity, Window, div, px,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::select::{Select, SelectOption};
use gpui_native_kit::theme::ActiveTheme as _;
use settings::SettingsStore;
use signed_core::RepoAddr;
use signed_git::Repo;
use signed_state::Backend;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
use 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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("Publish this local repository to Nostr."),
)
.child(
Field::new("init-name", "Repository name")
.description("Max 100 characters")
.child(Input::new("init-name-input", name_input.clone())),
)
.child(
Field::new("init-description", "Description").child(
Input::new("init-description-input", desc_input.clone()).rows(3),
),
)
.child(
Field::new("init-folder", "Folder")
.description("The local repository being published")
.child(
div()
.h_8()
.w_full()
.px_2()
.bg(cx.theme().colors.muted)
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.rounded(cx.theme().radius.control)
.child(folder.clone()),
),
)
.child(
Field::new("init-branch", "Default branch")
.description("The local branch to announce as the default")
.child(
Select::new("init-branch-select")
.options(
branches
.iter()
.cloned()
.map(|branch| SelectOption::new(branch.clone(), branch))
.collect::<Vec<_>>(),
)
.selected(selected_branch.clone())
.placeholder("Select branch"),
),
)
.child(grasp_servers_field(&grasp_state, &relay_input, cx))
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
el.child(steps)
})
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
.into_any_element()
})
.actions(move |_, cx| {
let busy = action_state.read(cx).busy;
let this = this.clone();
let init: AnyElement = Button::new("initialize-repository")
.label("Initialize")
.icon(Icon::named(IconRole::Plus))
.loading(busy)
.disabled(busy)
.on_click(move |_, window, cx| {
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);
}
@@ -0,0 +1,5 @@
pub mod about;
pub mod create;
pub mod grasp_servers;
pub mod init;
pub mod reset;
@@ -0,0 +1,99 @@
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window, div, px,
};
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::theme::ActiveTheme as _;
use signed_state::RepoStore;
use 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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(
"Grasp servers are overwritten to match the local mirror. Refs that do not exist locally are deleted from the servers. This cannot be undone.",
),
)
.child(if deleted.is_empty() {
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("No server refs will be deleted.")
.into_any_element()
} else {
v_flex()
.gap_1()
.children(deleted.iter().map(|name| {
div()
.text_sm()
.font_family(cx.theme().mono_font)
.child(name.clone())
}))
.into_any_element()
})
.into_any_element()
})
.actions(move |_, _| {
let store = store.clone();
let cancel: AnyElement = Button::new("cancel-reset-remote")
.label("Cancel")
.variant(ButtonVariant::Secondary)
.on_click(|_, _, cx| close_global(cx))
.into_any_element();
let confirm: AnyElement = Button::new("confirm-reset-remote")
.label("Reset remote")
.variant(ButtonVariant::Destructive)
.on_click(move |_, _, cx| {
let task = store.update(cx, |store, cx| store.reset_repository(cx));
cx.spawn(async move |_cx| {
if let Err(error) = task.await {
log::error!("failed to reset remote: {error:#}");
}
})
.detach();
close_global(cx);
})
.into_any_element();
vec![cancel, confirm]
})
}
}
/// Opens the reset confirmation listing the refs the reset deletes.
pub fn open(store: Entity<RepoStore>, deleted: Vec<String>, cx: &mut App) {
open_global(ResetRemoteDialog::new(store, deleted, cx), cx);
}
+728
View File
@@ -0,0 +1,728 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use anyhow::Error;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Task, WeakEntity, Window, div, px,
};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::data::tree::{Tree, TreeItem};
use gpui_native_kit::editor::code::CodeViewState;
use gpui_native_kit::editor::markdown::MarkdownViewState;
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::theme::ActiveTheme as _;
use signed_git::{FileCommit, Repo, WorktreeSnapshot};
use super::helpers::{code_language, is_markdown_path, preview_spinner, source_hash};
use crate::shared::tree::{TreeItemSeed, TreeRow, collect_expanded, flatten};
use crate::shared::widgets::empty_state;
const TREE_WIDTH: f32 = 240.;
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
const MAX_PREVIEWED_FILES: usize = 32;
const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
enum FileContent {
Text(String),
Binary,
TooLarge,
Failed(String),
}
struct MarkdownView {
/// `None` means the repository README.
path: Option<SharedString>,
state: Entity<MarkdownViewState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
struct CodeView {
/// Source path, relative to the worktree root.
path: SharedString,
state: Entity<CodeViewState>,
/// Hash of the source, so the same document is not re-parsed on a refresh.
source_hash: u64,
}
pub struct RepoFilesView {
seeds: Vec<TreeItemSeed>,
expanded: HashSet<String>,
rows: Vec<TreeRow>,
selected_index: Option<usize>,
worktree: Option<PathBuf>,
worktree_paths: Vec<String>,
md: Option<MarkdownView>,
code: Option<CodeView>,
readme_name: Option<SharedString>,
selected_file: Option<SharedString>,
files: HashMap<String, FileContent>,
file_order: VecDeque<String>,
preview_bytes: usize,
loading_files: HashSet<String>,
commits: HashMap<String, FileCommit>,
pending_commits: Vec<String>,
loading_commits: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
impl RepoFilesView {
pub fn new(_cx: &mut Context<Self>) -> Self {
Self {
seeds: Vec::new(),
expanded: HashSet::new(),
rows: Vec::new(),
selected_index: None,
worktree: None,
worktree_paths: Vec::new(),
md: None,
code: None,
readme_name: None,
selected_file: None,
files: HashMap::new(),
file_order: VecDeque::new(),
preview_bytes: 0,
loading_files: HashSet::new(),
commits: HashMap::new(),
pending_commits: Vec::new(),
loading_commits: false,
tasks: Vec::new(),
}
}
pub fn set_worktree(&mut self, path: PathBuf) {
self.worktree = Some(path);
}
pub fn apply_entries(
&mut self,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) {
self.worktree_paths = paths;
self.seeds = tree;
let folder_ids = collect_expanded(&self.seeds);
self.expanded.retain(|id| folder_ids.contains(id));
self.rebuild_rows();
cx.notify();
}
fn rebuild_rows(&mut self) {
self.rows = flatten(&self.seeds, &self.expanded);
self.selected_index = self
.selected_file
.as_ref()
.and_then(|path| self.rows.iter().position(|row| row.id == path.as_ref()));
}
fn toggle_folder(&mut self, id: &str) {
if !self.expanded.remove(id) {
self.expanded.insert(id.to_owned());
}
self.rebuild_rows();
}
/// Point the README pane at `path`/`bytes`, or clear it when absent.
pub fn set_readme(
&mut self,
path: Option<PathBuf>,
bytes: Option<Vec<u8>>,
cx: &mut Context<Self>,
) -> bool {
let Some((path, bytes)) = path.zip(bytes) else {
let changed = self.readme_name.is_some() || self.md.is_some();
self.readme_name = None;
self.md = None;
return changed;
};
let name: SharedString = path.to_string_lossy().into();
let mut changed = self.readme_name.as_ref() != Some(&name);
self.readme_name = Some(name);
self.load_commit(&path.to_string_lossy(), cx);
if let Ok(text) = String::from_utf8(bytes) {
changed |= self.set_markdown(None, &text, cx);
}
changed
}
/// Drop every cached preview and the README, e.g. on a branch switch.
pub fn clear_previews(&mut self) {
self.selected_file = None;
self.selected_index = None;
self.files.clear();
self.file_order.clear();
self.preview_bytes = 0;
self.loading_files.clear();
self.commits.clear();
self.pending_commits.clear();
self.loading_commits = false;
self.md = None;
self.code = None;
self.readme_name = None;
}
/// Refresh after the mirror caught up with the remote.
pub fn catch_up(
&mut self,
snapshot: &WorktreeSnapshot,
tree: Vec<TreeItemSeed>,
paths: Vec<String>,
cx: &mut Context<Self>,
) -> bool {
let mut changed = false;
if paths != self.worktree_paths {
self.apply_entries(tree, paths, cx);
changed = true;
}
let present: HashSet<String> = snapshot
.entries
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
let mut previewed: Vec<String> = Vec::new();
previewed.extend(self.files.keys().cloned());
previewed.extend(self.selected_file.clone().map(|path| path.to_string()));
if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) {
previewed.push(path.to_string());
}
if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) {
previewed.push(path.to_string());
}
previewed.sort();
previewed.dedup();
for path in previewed {
if !present.contains(&path) {
self.drop_preview_of(&path);
changed = true;
}
}
if self.selected_file.is_none() {
changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx);
}
changed
}
fn pane_title(&self) -> SharedString {
self.selected_file
.clone()
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into())
}
fn activate_row(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(row) = self.rows.get(index) else {
return;
};
let id = row.id.clone();
let expandable = row.expandable;
self.selected_index = Some(index);
if expandable {
self.toggle_folder(&id);
} else {
self.open_file(&id, window, cx);
}
cx.notify();
}
fn render_tree_column(
&self,
rows: Vec<TreeRow>,
selected: Option<usize>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let change_view = view.clone();
let disclosure_view = view;
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.p_2()
.flex_none()
.border_r_1()
.border_color(cx.theme().colors.border)
.child(
div().flex_1().min_h_0().child(
Tree::new("repo-tree")
.selected(selected)
.items(move |_, _| {
rows.iter()
.map(|row| {
let item = TreeItem::new(row.id.clone(), row.label.clone())
.depth(row.depth)
.expandable(row.expandable)
.expanded(row.expanded);
if row.expandable {
item.icon(Icon::named(IconRole::Folder))
} else {
item
}
})
.collect()
})
.on_change({
let view = change_view;
move |index, window, cx| {
if let Some(index) = index
&& let Err(error) = view.update(cx, |this, cx| {
this.activate_row(index, window, cx);
})
{
log::warn!("failed to activate tree row: {error}");
}
}
})
.on_disclosure({
let view = disclosure_view;
move |index, _, cx| {
if let Err(error) = view.update(cx, |this, cx| {
let id = this.rows.get(index).map(|row| row.id.clone());
if let Some(id) = id {
this.toggle_folder(&id);
cx.notify();
}
}) {
log::warn!("failed to toggle folder: {error}");
}
}
}),
),
)
}
fn render_content_column(
&self,
pane_title: SharedString,
cx: &mut Context<Self>,
) -> impl IntoElement {
let body: AnyElement = if let Some(path) = self.selected_file.clone() {
match self.files.get(path.as_ref()) {
Some(FileContent::Text(_)) => {
if is_markdown_path(path.as_ref()) {
self.markdown_element(Some(path.as_ref()))
} else {
self.code_element(path.as_ref())
}
}
Some(FileContent::Binary) => empty_state("Binary file - preview not supported"),
Some(FileContent::TooLarge) => empty_state("File is too large to preview"),
Some(FileContent::Failed(message)) => empty_state(message),
None => preview_spinner(),
}
} else if self.readme_name.is_some() {
self.markdown_element(None)
} else {
empty_state("No README found")
};
let commit = match &self.selected_file {
Some(path) => self.commits.get(path.as_ref()),
None => self
.readme_name
.as_ref()
.and_then(|name| self.commits.get(name.as_ref())),
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
h_flex()
.px_3()
.h_9()
.gap_2()
.bg(cx.theme().colors.muted)
.border_b_1()
.border_color(cx.theme().colors.border)
.text_color(cx.theme().colors.muted_foreground)
.child(
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_ellipsis()
.whitespace_nowrap()
.child(pane_title),
)
.when_some(commit, |this, commit| {
this.child(
h_flex()
.flex_1()
.gap_1()
.child(
div()
.text_xs()
.font_family(cx.theme().mono_font)
.child(commit.id.clone()),
)
.child(
div()
.max_w(px(250.))
.text_xs()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
}),
)
.child(div().flex_1().min_h_0().child(body))
}
fn set_markdown(
&mut self,
path: Option<SharedString>,
text: &str,
cx: &mut Context<Self>,
) -> bool {
let hash = source_hash(text);
if let Some(md) = &self.md
&& md.path == path
&& md.source_hash == hash
{
return false;
}
if let Some(md) = self.md.take()
&& md.path == path
{
md.state.update(cx, |state, cx| {
state.set_source(text, cx);
});
self.md = Some(MarkdownView {
path,
state: md.state,
source_hash: hash,
});
return true;
}
let state = cx.new(|_| MarkdownViewState::new("repo-readme", text));
self.md = Some(MarkdownView {
path,
state,
source_hash: hash,
});
true
}
fn markdown_element(&self, path: Option<&str>) -> AnyElement {
let Some(md) = &self.md else {
return preview_spinner();
};
let ready = match path {
Some(path) => md.path.as_deref() == Some(path),
None => md.path.is_none(),
};
if !ready {
return preview_spinner();
}
div()
.p_4()
.size_full()
.overflow_hidden()
.child(md.state.clone())
.into_any_element()
}
fn set_code(&mut self, path: SharedString, text: &str, cx: &mut Context<Self>) {
let hash = source_hash(text);
let language = code_language(path.as_ref());
if let Some(code) = &self.code
&& code.path == path
&& code.source_hash == hash
{
return;
}
if let Some(code) = self.code.take()
&& code.path == path
{
code.state.update(cx, |state, cx| {
state.set_source(text, language, cx);
});
self.code = Some(CodeView {
path,
state: code.state,
source_hash: hash,
});
return;
}
let state = cx.new(|cx| CodeViewState::new("repo-code", text, language, cx));
self.code = Some(CodeView {
path,
state,
source_hash: hash,
});
}
fn code_element(&self, path: &str) -> AnyElement {
let Some(code) = &self.code else {
return preview_spinner();
};
if code.path.as_ref() != path {
return preview_spinner();
}
div()
.size_full()
.overflow_hidden()
.child(code.state.clone())
.into_any_element()
}
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
if self.files.contains_key(path) {
if let Some(FileContent::Text(text)) = self.files.get(path) {
let text = text.clone();
if is_markdown_path(path) {
if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) {
self.set_markdown(Some(path.into()), &text, cx);
}
} else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) {
self.set_code(path.into(), &text, cx);
}
}
cx.notify();
return;
}
if self.loading_files.contains(path) {
cx.notify();
return;
}
let rel = Path::new(path);
let unsafe_path = rel.is_absolute()
|| rel.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
let Some(worktree) = self.worktree.clone() else {
return;
};
if unsafe_path {
return;
}
self.loading_files.insert(path.to_string());
let path = path.to_string();
self.load_commit(&path, cx);
let task: Task<Result<(), Error>> = cx.spawn_in(window, async move |this, cx| {
let path_for_read = path.clone();
let content = cx
.background_spawn(async move {
let full = worktree.join(&path_for_read);
let metadata = match std::fs::metadata(&full) {
Ok(metadata) => metadata,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
return Ok(FileContent::TooLarge);
}
let bytes = match std::fs::read(&full) {
Ok(bytes) => bytes,
Err(error) => return Err(anyhow::anyhow!("{}", error)),
};
match String::from_utf8(bytes) {
Ok(text) => Ok(FileContent::Text(text)),
Err(_) => Ok(FileContent::Binary),
}
})
.await;
this.update_in(cx, |this, _window, cx| {
this.loading_files.remove(&path);
match content {
Ok(kind) => {
if let FileContent::Text(text) = &kind {
if is_markdown_path(&path) {
let same = this.md.as_ref().map(|md| md.path.as_deref())
== Some(Some(path.as_str()));
if !same {
this.set_markdown(Some(path.clone().into()), text, cx);
}
} else {
let same = this.code.as_ref().map(|code| code.path.as_str())
== Some(path.as_str());
if !same {
this.set_code(path.clone().into(), text, cx);
}
}
this.preview_bytes += text.len();
}
this.files.insert(path.clone(), kind);
this.file_order.push_back(path);
this.evict_previews();
}
Err(error) => {
this.files
.insert(path, FileContent::Failed(error.to_string()));
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
fn drop_preview_of(&mut self, path: &str) {
if let Some(FileContent::Text(text)) = self.files.remove(path) {
self.preview_bytes -= text.len();
}
self.commits.remove(path);
if self.selected_file.as_deref() == Some(path) {
self.selected_file = None;
}
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) {
self.md = None;
}
if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) {
self.code = None;
}
}
fn evict_previews(&mut self) {
while (self.files.len() > MAX_PREVIEWED_FILES
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
&& self.file_order.len() > 1
{
let Some(path) = self.file_order.pop_front() else {
break;
};
if Some(path.as_str()) == self.selected_file.as_deref() {
self.file_order.push_back(path);
continue;
}
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
self.preview_bytes -= text.len();
}
if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path.as_str()) {
self.md = None;
}
if self
.code
.as_ref()
.is_some_and(|code| code.path.as_ref() == path.as_str())
{
self.code = None;
}
self.commits.remove(&path);
}
}
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
return;
}
self.pending_commits.push(path.to_string());
if !self.loading_commits {
self.load_commits(cx);
}
}
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
}
let Some(worktree) = self.worktree.clone() else {
self.pending_commits.clear();
return;
};
self.loading_commits = true;
let paths = std::mem::take(&mut self.pending_commits);
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let result = cx
.background_spawn(async move {
Repo::open_cached(&worktree).and_then(|repo| repo.last_commits(&rels))
})
.await;
this.update(cx, |this, cx| {
this.loading_commits = false;
if let Ok(found) = result {
for (path, commit) in found {
this.commits
.insert(path.to_string_lossy().into_owned(), commit);
}
}
if !this.pending_commits.is_empty() {
this.load_commits(cx);
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
}
impl Render for RepoFilesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rows = self.rows.clone();
let selected = self.selected_index;
let view = cx.entity().downgrade();
let pane_title = self.pane_title();
h_flex()
.flex_1()
.w_full()
.overflow_hidden()
.child(self.render_tree_column(rows, selected, view, cx))
.child(self.render_content_column(pane_title, cx))
}
}
+667
View File
@@ -0,0 +1,667 @@
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 _;
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()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(avatar)
.child(name),
)
.child(
div()
.min_w_0()
.text_sm()
.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()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.font_weight(gpui::FontWeight::SEMIBOLD)
.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,
))
.child(clone_group(
"Clone with nak",
"copy-nak",
&nak_for_popover,
))
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.text_color(
cx.theme().colors.muted_foreground,
)
.child("Grasp Servers"),
)
.when(git_for_popover.is_empty(), |this| {
this.child(
div()
.text_xs()
.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()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(avatar)
.child(name),
)
.child(
div()
.min_w_0()
.text_sm()
.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().text_xs().whitespace_nowrap().child(owner_name)),
)
.when(!rest.is_empty(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child(SharedString::from(format!("+{}", rest.len()))),
)
}),
)
.into_any_element()
}
}
+514
View File
@@ -0,0 +1,514 @@
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 _;
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) -> AnyElement {
v_flex()
.gap_1()
.child(div().text_xs().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()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(Icon::named(CustomIconName::GitBranch).size(px(14.)))
.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()
.text_sm()
.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()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.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)
.text_xs()
.bg(cx.theme().colors.muted)
.border_y_1()
.border_color(cx.theme().colors.border)
.text_color(cx.theme().colors.muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
old_start, old_lines, new_start, new_lines
)))
.into_any_element(),
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
}
}
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().ramps.success.base.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().ramps.destructive.base.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().colors.muted_foreground;
h_flex()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font)
.text_xs()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().colors.foreground)
.child(line.text.clone()),
)
.into_any_element()
}
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)
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child(commit.id.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
.child(
h_flex()
.gap_2()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child(commit.author.clone())
.child(relative_time_secs(commit.time)),
),
)
.on_click(move |_, window, cx| on_click(window, cx))
.into_any_element()
}
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
}
+212
View File
@@ -0,0 +1,212 @@
use std::path::PathBuf;
use anyhow::Error;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, Task, UniformListScrollHandle, WeakEntity, Window, div, uniform_list,
};
use gpui_native_kit::common::v_flex;
use gpui_native_kit::layout::dock::DockState;
use gpui_native_kit::scrollbar::Scrollbar;
use gpui_native_kit::spinner::Spinner;
use gpui_native_kit::theme::ActiveTheme as _;
use signed_git::{CommitList, Repo};
use signed_state::RepoStore;
use super::commit_diff::CommitDiffView;
use super::helpers::commit_row;
use super::helpers::repo_display_name;
use crate::shared::panels;
pub struct RepoHistoryView {
store: Entity<RepoStore>,
dock: WeakEntity<DockState>,
worktree: Option<PathBuf>,
all_commits: Option<CommitList>,
loading_all_commits: bool,
scroll_handle: UniformListScrollHandle,
tasks: Vec<Task<Result<(), Error>>>,
}
impl RepoHistoryView {
pub fn new(store: Entity<RepoStore>, dock: WeakEntity<DockState>) -> Self {
Self {
store,
dock,
worktree: None,
all_commits: None,
loading_all_commits: false,
scroll_handle: UniformListScrollHandle::new(),
tasks: Vec::new(),
}
}
pub fn set_worktree(&mut self, path: Option<PathBuf>) {
self.worktree = path;
}
/// Number of commits reachable from HEAD, for the Commits tab label.
pub fn commit_count(&self) -> Option<usize> {
self.all_commits.as_ref().map(|list| list.total)
}
/// Drop the current list and walk HEAD again.
pub fn reload(&mut self, cx: &mut Context<Self>) {
self.all_commits = None;
self.loading_all_commits = false;
self.load(cx);
}
fn load(&mut self, cx: &mut Context<Self>) {
if self.loading_all_commits || self.all_commits.is_some() {
return;
}
let Some(worktree) = self.worktree.clone() else {
return;
};
self.loading_all_commits = true;
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let result = cx
.background_spawn(async move {
Repo::open_cached(&worktree).and_then(|repo| repo.all_commits())
})
.await;
this.update(cx, |this, cx| {
if let Ok(list) = result {
this.all_commits = Some(list);
}
this.loading_all_commits = false;
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
pub fn open_commit_diff(
&mut self,
commit_id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(worktree) = self.worktree.clone() else {
return;
};
// Same display name as the repo detail panel's title.
let repo_name = repo_display_name(self.store.read(cx));
let store = self.store.clone();
let commit_id = commit_id.to_owned();
let id = match store.read(cx).addr() {
Some(addr) => SharedString::from(format!("commit:{addr}:{commit_id}")),
None => SharedString::from(format!("commit:{}:{}", worktree.display(), commit_id)),
};
let dock = self.dock.clone();
panels::open(
&dock,
id,
move |window, cx| {
cx.new(|cx| {
CommitDiffView::new(
store.clone(),
worktree.clone(),
repo_name.clone(),
commit_id.clone(),
window,
cx,
)
})
},
window,
cx,
);
}
}
impl Render for RepoHistoryView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(list) = self.all_commits.as_ref() else {
return if self.loading_all_commits {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new("commits-loading"))
.into_any_element()
} else {
crate::shared::widgets::empty_state("Failed to load commits")
};
};
if list.commits.is_empty() {
return crate::shared::widgets::empty_state("No commits found");
}
let commits = list.commits.clone();
let shown = commits.len();
let total = list.total;
let view = cx.entity().downgrade();
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.child(
uniform_list("commits", shown, move |range, _, cx| {
range
.map(|ix| {
let commit = commits[ix].clone();
let id = commit.id.clone();
let view = view.clone();
commit_row(
ix,
&commit,
move |window, cx| {
if let Err(error) = view.update(cx, |this, cx| {
this.open_commit_diff(&id, window, cx);
}) {
log::warn!("failed to open commit diff: {error}");
}
},
cx,
)
})
.collect()
})
.track_scroll(&self.scroll_handle)
.size_full(),
)
.when(shown < total, |this| {
this.child(
div()
.py_2()
.w_full()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child(format!("Showing {shown} of {total} commits")),
)
})
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(
"commits-scroll",
self.scroll_handle.clone(),
)),
)
.into_any_element()
}
}
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
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)
});
}
}
+358
View File
@@ -0,0 +1,358 @@
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 _;
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()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(title.into()),
)
.child(
div()
.text_xs()
.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()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(title.into()),
)
.child(
div()
.text_xs()
.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)
.text_sm()
.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)
.text_sm()
.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();
}
+107
View File
@@ -0,0 +1,107 @@
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);
}
+59
View File
@@ -0,0 +1,59 @@
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));
}
+18
View File
@@ -0,0 +1,18 @@
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()
);
}
}
+23
View File
@@ -0,0 +1,23 @@
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}");
}
}
+128
View File
@@ -0,0 +1,128 @@
use gpui::{
AnyElement, App, Context, IntoElement as _, ParentElement as _, SharedString, Styled as _,
Subscription, div, px,
};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::spinner::Spinner;
use gpui_native_kit::theme::ActiveTheme as _;
use signed_core::RepoAddr;
use signed_state::{Backend, BackendEvent, PushStepStatus};
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
#[derive(Debug, Default)]
pub struct DialogProgress {
pub busy: bool,
pub error: Option<SharedString>,
// The repository whose push checklist is shown; `None` outside a publish.
push_addr: Option<RepoAddr>,
pub push_steps: Vec<PushStepStatus>,
_push_subscription: Option<Subscription>,
}
impl DialogProgress {
/// Marks an action as started, disabling the form and clearing the previous error.
pub fn begin(&mut self) {
self.busy = true;
self.error = None;
}
/// Starts a publish for the repository at `addr` and listens for its push steps.
pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context<Self>) {
self.begin();
self.push_addr = Some(addr);
self.push_steps.clear();
if self._push_subscription.is_none() {
let backend = Backend::global(cx);
self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| {
let BackendEvent::PushProgress { addr, status } = event else {
return;
};
if this.push_addr.as_ref() != Some(addr) {
return;
}
match this
.push_steps
.iter_mut()
.find(|step| step.step() == status.step())
{
Some(step) => *step = status.clone(),
None => this.push_steps.push(status.clone()),
}
cx.notify();
}));
}
}
/// Marks an action as failed, enabling the form and showing `message`.
pub fn fail(&mut self, message: impl Into<SharedString>) {
self.busy = false;
self.error = Some(message.into());
}
}
/// The shared error line under a dialog form, `None` when there is no error.
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
error.as_ref().map(|message| {
div()
.text_sm()
.text_color(cx.theme().ramps.destructive.base)
.child(message.clone())
.into_any_element()
})
}
/// The push checklist of a running publish, `None` before any step is reported.
pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option<AnyElement> {
if steps.is_empty() {
return None;
}
let rows = steps
.iter()
.map(|status| {
let label = SharedString::from(status.step().label());
match status {
PushStepStatus::Running(_) => h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child(Spinner::new("push-step"))
.child(label)
.into_any_element(),
PushStepStatus::Done(_) => h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().colors.foreground)
.child(
Icon::named(IconRole::Check)
.size(px(14.))
.color(cx.theme().colors.foreground),
)
.child(label)
.into_any_element(),
PushStepStatus::Failed(_, reason) => h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().ramps.destructive.base)
.child(
Icon::named(IconRole::Close)
.size(px(14.))
.color(cx.theme().ramps.destructive.base),
)
.child(SharedString::from(format!(
"{}: {reason}",
status.step().label()
)))
.into_any_element(),
}
})
.collect::<Vec<_>>();
Some(v_flex().gap_1().children(rows).into_any_element())
}
+188
View File
@@ -0,0 +1,188 @@
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");
}
}
+98
View File
@@ -0,0 +1,98 @@
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 _;
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()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(cx.theme().colors.muted_foreground)
.child(text.into())
.into_any_element()
}
+51
View File
@@ -0,0 +1,51 @@
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{AnyElement, App, IntoElement, SharedString};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::common::Sizable as _;
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())
.small()
.into_any_element();
};
let profile = directory.read(cx).profile(&owner, cx);
Avatar::new(owner)
.name(profile.name())
.picture(profile.picture())
.small()
.into_any_element()
}

Some files were not shown because too many files have changed in this diff Show More