Merge pull request 'Add embedded PTY terminal, keyboard shortcuts, and assignee/label integrations' (#53) from UI-UX into main
This commit was merged in pull request #53.
This commit is contained in:
+178
@@ -4,6 +4,184 @@ All notable user-facing changes to Gitty are documented in this file.
|
||||
|
||||
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Ctrl/Cmd + 1 to 4 switch between Dashboard, Repositories, Pull Requests,
|
||||
and Issues & Boards.
|
||||
- Ctrl/Cmd + A selects every file in the focused status lane; Esc clears the
|
||||
selection again.
|
||||
- Issues can be assigned to people, and labels are managed directly from the
|
||||
Issue Center.
|
||||
- An embedded terminal in the repository view, docked above the status bar and
|
||||
adjustable in height. It runs a real pseudo terminal with the user's own
|
||||
shell, so colors, interactive prompts, tab completion, and Ctrl+C work as
|
||||
usual. Every repository tab keeps its own session; Ctrl + ` toggles the dock.
|
||||
|
||||
### Changed
|
||||
|
||||
- The description field of the Create Issue dialog uses the full comment
|
||||
editor instead of a plain text area.
|
||||
- The help center no longer carries a "What's new" section. Release notes are
|
||||
maintained in this file.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Long file paths no longer displace the header controls of the file history
|
||||
dialog.
|
||||
|
||||
## [2026.9.8] - 2026-09-18
|
||||
|
||||
### Added
|
||||
|
||||
- Submodule management: submodules are listed, initialized, updated, and
|
||||
cloned recursively, including authentication and checkout of a specific
|
||||
submodule revision.
|
||||
- Worktrees and tags are available as collapsible panels in the left sidebar,
|
||||
and a section menu toggles which panels are shown.
|
||||
- Individual lines can be restored from a historical commit instead of
|
||||
restoring the whole file.
|
||||
- The branch panel supports filtering, remembers its view state, and groups
|
||||
branches with their own accent color.
|
||||
- Pull request merges offer a merge-method selection with provider-specific
|
||||
payloads and can delete the source branch automatically.
|
||||
- The Review Center filters by repository, and select menus show repository
|
||||
details next to each option.
|
||||
|
||||
### Changed
|
||||
|
||||
- File history marks the entry that is identical to the working tree.
|
||||
- Confirmation dialogs are centralized, translated, and can carry inputs,
|
||||
checkboxes, and multi-selection actions.
|
||||
- Left sidebar panels share one sizing and resize implementation, and global
|
||||
scrollbar styles were consolidated.
|
||||
- Status colors, overlay tints, and theme tokens for ink and accent moved to
|
||||
CSS variables.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The repository selector in the tab bar shows a pointer cursor.
|
||||
|
||||
## [2026.9.7] - 2026-09-11
|
||||
|
||||
### Added
|
||||
|
||||
- Pull request drafts can be generated with AI; all AI settings live on one
|
||||
page.
|
||||
- The commit history resolves visible parents, which keeps the graph correct
|
||||
when commits are filtered.
|
||||
|
||||
### Changed
|
||||
|
||||
- Dialogs share unified header chrome.
|
||||
- Provider icons were restyled and share one implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Switching to the Management view no longer opens a repository on its own.
|
||||
|
||||
## [2026.9.6] - 2026-09-11
|
||||
|
||||
### Added
|
||||
|
||||
- Workspaces group repositories into sessions that can be saved, switched,
|
||||
and managed; their UI filters persist.
|
||||
- Repository tabs can be reordered by drag and drop.
|
||||
- Issues can be created from the app, including Azure DevOps work items with
|
||||
state management.
|
||||
- Gitea repositories are grouped in the Clone dialog like the other
|
||||
providers.
|
||||
|
||||
### Changed
|
||||
|
||||
- Undoing the last commit uses a soft reset, so the changes stay staged.
|
||||
- Behind segments in the commit graph use the lane color supplied by the
|
||||
graph.
|
||||
|
||||
## [2026.9.5] - 2026-09-09
|
||||
|
||||
### Changed
|
||||
|
||||
- All main views share one page-header layout.
|
||||
- The integration board view was refined, and assignees are displayed more
|
||||
compactly.
|
||||
|
||||
## [2026.9.4] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
- Boards and issues from multiple providers, including moving cards between
|
||||
columns.
|
||||
- Pull requests can be created from the app; the dialog loads the repository
|
||||
branches and suggests the current local branch.
|
||||
- Repository tabs became a workspace navigation bar.
|
||||
- Pull request status is loaded separately and surfaced as badges.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Review Center table and toolbar adapt to narrow windows.
|
||||
|
||||
## [2026.9.3] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
- The dashboard offers a list and a card view, and remembers the choice.
|
||||
- A notice shows the progress of a running merge.
|
||||
|
||||
### Changed
|
||||
|
||||
- The line patch dialog was redesigned.
|
||||
- Sync colors are defined once as CSS variables.
|
||||
|
||||
## [2026.9.2] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
- Guided Git bisect with a dedicated dialog for the commit range.
|
||||
- The Review Center loads pull requests from the configured integrations,
|
||||
with a detail panel, actions, and search.
|
||||
- Conflicts from a pull request merge can be resolved locally; the merge
|
||||
continues automatically and the provider is polled after the push.
|
||||
- The dashboard shows pull request badges and links into the Review Center.
|
||||
|
||||
### Changed
|
||||
|
||||
- Repository management moved into its own dashboard component.
|
||||
- Credentials use the Linux keyring.
|
||||
|
||||
## [2026.9.1] - 2026-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- A dialog for initializing a repository.
|
||||
- A Merge Branch dialog with a confirmation step.
|
||||
- Advanced clone options: shallow, sparse, blobless, and custom flags.
|
||||
|
||||
## [2026.8.10] - 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- The Clone dialog was redesigned and groups Azure DevOps repositories by
|
||||
project.
|
||||
- Tab and dialog close buttons were visually refined.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Clone dialog no longer breaks its layout or scrolling on small windows.
|
||||
- The loading overlay accounts for the repository tab bar.
|
||||
|
||||
## [2026.8.9] - 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- Pulling histories without a common ancestor is possible as an explicit
|
||||
opt-in.
|
||||
|
||||
### Changed
|
||||
|
||||
- The AI commit flow was simplified; local models are no longer supported.
|
||||
|
||||
## [2026.8.8] - 2026-08-29
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+17
-78
@@ -13,6 +13,8 @@
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.4.15",
|
||||
"marked": "^18.0.12",
|
||||
"simple-icons": "^16.24.1",
|
||||
@@ -582,9 +584,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -598,9 +597,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -614,9 +610,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -630,9 +623,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -646,9 +636,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -662,9 +649,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -678,9 +662,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -694,9 +675,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -710,9 +688,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -726,9 +701,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -742,9 +714,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -758,9 +727,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -774,9 +740,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1045,9 +1008,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1064,9 +1024,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1083,9 +1040,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1102,9 +1056,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1288,9 +1239,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1308,9 +1256,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1328,9 +1273,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1348,9 +1290,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1368,9 +1307,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1461,6 +1397,21 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
@@ -1846,9 +1797,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1869,9 +1817,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1892,9 +1837,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1915,9 +1857,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"dompurify": "^3.4.15",
|
||||
"marked": "^18.0.12",
|
||||
"simple-icons": "^16.24.1",
|
||||
|
||||
Generated
+105
-6
@@ -511,6 +511,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
@@ -919,6 +925,12 @@ dependencies = [
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -981,7 +993,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1099,6 +1111,17 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
@@ -1475,6 +1498,7 @@ dependencies = [
|
||||
"commit_ai",
|
||||
"keyring",
|
||||
"log",
|
||||
"portable-pty",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2160,6 +2184,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -2378,6 +2408,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.1.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -2386,7 +2428,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
@@ -2399,7 +2441,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -3055,6 +3097,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.28.0",
|
||||
"serial2",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3163,7 +3226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
@@ -3203,7 +3266,7 @@ version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
@@ -3901,6 +3964,17 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial2"
|
||||
version = "0.2.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -3954,6 +4028,22 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -4027,7 +4117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.2.1",
|
||||
"core-graphics 0.24.0",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
@@ -5954,6 +6044,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
||||
@@ -25,6 +25,7 @@ log = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||
shlex = "2"
|
||||
portable-pty = "0.9"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
mod labels;
|
||||
pub use labels::*;
|
||||
mod assignees;
|
||||
pub use assignees::*;
|
||||
mod cleanup;
|
||||
mod merge;
|
||||
pub use merge::get_integration_review_merge_options;
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
use super::issue_comments::{comment_client, request};
|
||||
use super::*;
|
||||
use reqwest::{Method, Url};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntegrationAssignee {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AssignmentTarget {
|
||||
pub repository: String,
|
||||
#[serde(default)]
|
||||
pub repository_id: String,
|
||||
pub number: u64,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
pub(super) fn api_url(provider: &str, base: &str, segments: &[&str]) -> Result<Url, String> {
|
||||
let base = match provider {
|
||||
"github" => github_api_base_url(base)?,
|
||||
"gitea" | "gitlab" | "gitlab-self-hosted" | "azure-devops" => normalized_base_url(base)?,
|
||||
_ => return Err("Unsupported assignment provider.".into()),
|
||||
};
|
||||
let mut url = Url::parse(&base).map_err(|e| e.to_string())?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| "Invalid API URL.")?
|
||||
.pop_if_empty()
|
||||
.extend(segments.iter().copied());
|
||||
if provider == "azure-devops" {
|
||||
url.set_query(Some("api-version=7.1"));
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn project(target: &AssignmentTarget) -> Result<&str, String> {
|
||||
let name = if target.kind == "review" {
|
||||
target.repository.split_once('/').map(|p| p.0).unwrap_or("")
|
||||
} else {
|
||||
&target.repository
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err("Select an Azure project.".into());
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
pub(super) fn repository_parts(repository: &str) -> Result<(&str, &str), String> {
|
||||
repository
|
||||
.split_once('/')
|
||||
.filter(|(owner, repo)| {
|
||||
!owner.is_empty()
|
||||
&& !repo.is_empty()
|
||||
&& !repo.contains('/')
|
||||
&& ![".", ".."].contains(owner)
|
||||
&& ![".", ".."].contains(repo)
|
||||
})
|
||||
.ok_or("Invalid repository name.".into())
|
||||
}
|
||||
|
||||
pub(super) fn target_url(provider: &str, base: &str, target: &AssignmentTarget) -> Result<Url, String> {
|
||||
if target.number == 0 || !["issue", "review"].contains(&target.kind.as_str()) {
|
||||
return Err("Invalid assignment target.".into());
|
||||
}
|
||||
let number = target.number.to_string();
|
||||
match provider {
|
||||
"github" | "gitea" => {
|
||||
let (owner, repo) = repository_parts(&target.repository)?;
|
||||
let mut parts = vec!["repos", owner, repo, "issues", &number];
|
||||
if provider == "gitea" {
|
||||
parts.splice(0..0, ["api", "v1"]);
|
||||
}
|
||||
api_url(provider, base, &parts)
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => api_url(
|
||||
provider,
|
||||
base,
|
||||
&[
|
||||
"api",
|
||||
"v4",
|
||||
"projects",
|
||||
&target.repository,
|
||||
if target.kind == "review" {
|
||||
"merge_requests"
|
||||
} else {
|
||||
"issues"
|
||||
},
|
||||
&number,
|
||||
],
|
||||
),
|
||||
"azure-devops" if target.kind == "review" => {
|
||||
if target.repository_id.is_empty() {
|
||||
return Err("Missing Azure repository ID.".into());
|
||||
}
|
||||
api_url(
|
||||
provider,
|
||||
base,
|
||||
&[
|
||||
project(target)?,
|
||||
"_apis",
|
||||
"git",
|
||||
"repositories",
|
||||
&target.repository_id,
|
||||
"pullrequests",
|
||||
&number,
|
||||
"reviewers",
|
||||
],
|
||||
)
|
||||
}
|
||||
"azure-devops" => api_url(
|
||||
provider,
|
||||
base,
|
||||
&[project(target)?, "_apis", "wit", "workitems", &number],
|
||||
),
|
||||
_ => Err("Unsupported assignment provider.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn user(value: &Value, provider: &str) -> Option<IntegrationAssignee> {
|
||||
let username = value_string(
|
||||
value,
|
||||
&[if provider == "azure-devops" {
|
||||
"uniqueName"
|
||||
} else if provider.starts_with("gitlab") {
|
||||
"username"
|
||||
} else {
|
||||
"login"
|
||||
}],
|
||||
);
|
||||
let id = if provider == "github" || provider == "gitea" {
|
||||
username.clone()
|
||||
} else {
|
||||
json_id(value)
|
||||
};
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let name = value_string(
|
||||
value,
|
||||
&[if provider == "azure-devops" {
|
||||
"displayName"
|
||||
} else if provider == "gitea" {
|
||||
"full_name"
|
||||
} else {
|
||||
"name"
|
||||
}],
|
||||
);
|
||||
Some(IntegrationAssignee {
|
||||
id,
|
||||
name: if name.is_empty() {
|
||||
username.clone()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
username,
|
||||
})
|
||||
}
|
||||
|
||||
fn assigned(value: &Value, provider: &str, kind: &str) -> Result<Vec<IntegrationAssignee>, String> {
|
||||
if provider == "azure-devops" && kind == "issue" {
|
||||
let identity = &value["fields"]["System.AssignedTo"];
|
||||
if identity.is_null() || identity.as_str() == Some("") {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
return user(identity, provider)
|
||||
.map(|u| vec![u])
|
||||
.ok_or("Azure returned an invalid assigned identity.".into());
|
||||
}
|
||||
// Gitea serializes an unassigned issue/PR with `assignees: null`.
|
||||
// Older responses can expose only the singular `assignee` field.
|
||||
if provider == "gitea" && value.get("assignees").is_none_or(Value::is_null) {
|
||||
if let Some(identity) = value.get("assignee").filter(|identity| !identity.is_null()) {
|
||||
return user(identity, provider)
|
||||
.map(|u| vec![u])
|
||||
.ok_or("Gitea returned an invalid assigned user.".into());
|
||||
}
|
||||
if value.get("assignees").is_some() || value.get("assignee").is_some() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
}
|
||||
let field = if provider == "azure-devops" {
|
||||
"value"
|
||||
} else {
|
||||
"assignees"
|
||||
};
|
||||
let entries = value[field]
|
||||
.as_array()
|
||||
.ok_or("The provider returned no assignment list.")?;
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
user(entry, provider).ok_or("The provider returned an invalid assigned user.".into())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) struct IntegrationApi<'a> {
|
||||
pub(super) client: Client,
|
||||
pub(super) provider: &'a str,
|
||||
pub(super) username: &'a str,
|
||||
pub(super) token: &'a str,
|
||||
}
|
||||
impl IntegrationApi<'_> {
|
||||
pub(super) fn send(&self, method: Method, url: Url, body: Option<&Value>) -> Result<Value, String> {
|
||||
let mut req = request(
|
||||
&self.client,
|
||||
method,
|
||||
url,
|
||||
self.provider,
|
||||
self.username,
|
||||
self.token,
|
||||
)?;
|
||||
if let Some(body) = body {
|
||||
if body.is_array() {
|
||||
req = req.header("Content-Type", "application/json-patch+json");
|
||||
}
|
||||
req = req.json(body);
|
||||
}
|
||||
let response = req
|
||||
.send()
|
||||
.map_err(|e| format!("Integration request could not be confirmed: {e}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response, self.provider));
|
||||
}
|
||||
if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
response.json().map_err(|e| e.to_string())
|
||||
}
|
||||
pub(super) fn pages(&self, url: Url) -> Result<Vec<Value>, String> {
|
||||
let mut result = Vec::new();
|
||||
let mut previous = None;
|
||||
for page in 1..=1000 {
|
||||
let mut endpoint = url.clone();
|
||||
if self.provider == "azure-devops" {
|
||||
endpoint
|
||||
.query_pairs_mut()
|
||||
.append_pair("$top", "100")
|
||||
.append_pair("$skip", &((page - 1) * 100).to_string());
|
||||
} else {
|
||||
endpoint
|
||||
.query_pairs_mut()
|
||||
.append_pair("per_page", "100")
|
||||
.append_pair("limit", "100")
|
||||
.append_pair("page", &page.to_string());
|
||||
}
|
||||
let response = request(
|
||||
&self.client,
|
||||
Method::GET,
|
||||
endpoint,
|
||||
self.provider,
|
||||
self.username,
|
||||
self.token,
|
||||
)?
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response, self.provider));
|
||||
}
|
||||
let next = response
|
||||
.headers()
|
||||
.get("link")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.contains("rel=\"next\""));
|
||||
let value: Value = response.json().map_err(|e| e.to_string())?;
|
||||
if self.provider == "gitea" && value.is_null() { return Ok(result); }
|
||||
let entries = if self.provider == "azure-devops" {
|
||||
value["value"].as_array()
|
||||
} else {
|
||||
value.as_array()
|
||||
}
|
||||
.ok_or("Invalid entry list.")?;
|
||||
if entries.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
if previous.as_ref() == Some(&value) {
|
||||
return Err("The provider repeated a result page.".into());
|
||||
}
|
||||
result.extend(entries.iter().cloned());
|
||||
if !next.unwrap_or(entries.len() == 100) {
|
||||
return Ok(result);
|
||||
}
|
||||
previous = Some(value);
|
||||
}
|
||||
Err("Too many result pages returned by the provider.".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_assignees(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
target: AssignmentTarget,
|
||||
) -> Result<Vec<IntegrationAssignee>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
token: &token,
|
||||
};
|
||||
let entries = match provider.as_str() {
|
||||
"github" | "gitea" => {
|
||||
let (owner, repo) = repository_parts(&target.repository)?;
|
||||
let mut parts = vec!["repos", owner, repo, "assignees"];
|
||||
if provider == "gitea" {
|
||||
parts.splice(0..0, ["api", "v1"]);
|
||||
}
|
||||
api.pages(api_url(&provider, &base_url, &parts)?)?
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => api.pages(api_url(
|
||||
&provider,
|
||||
&base_url,
|
||||
&[
|
||||
"api",
|
||||
"v4",
|
||||
"projects",
|
||||
&target.repository,
|
||||
"members",
|
||||
"all",
|
||||
],
|
||||
)?)?,
|
||||
"azure-devops" => {
|
||||
let project = project(&target)?;
|
||||
let teams = api.pages(api_url(
|
||||
&provider,
|
||||
&base_url,
|
||||
&["_apis", "projects", project, "teams"],
|
||||
)?)?;
|
||||
let mut members = vec![];
|
||||
for team in teams {
|
||||
let id = team["id"]
|
||||
.as_str()
|
||||
.ok_or("Azure returned an invalid team.")?;
|
||||
members.extend(
|
||||
api.pages(api_url(
|
||||
&provider,
|
||||
&base_url,
|
||||
&["_apis", "projects", project, "teams", id, "members"],
|
||||
)?)?
|
||||
.into_iter()
|
||||
.map(|v| v["identity"].clone()),
|
||||
);
|
||||
}
|
||||
members
|
||||
}
|
||||
_ => return Err("Unsupported assignment provider.".into()),
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut users: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry["state"]
|
||||
.as_str()
|
||||
.is_none_or(|state| state == "active")
|
||||
&& entry["isContainer"] != true
|
||||
})
|
||||
.filter_map(|v| user(v, &provider))
|
||||
.filter(|u| seen.insert(u.id.clone()))
|
||||
.collect();
|
||||
users.sort_by_key(|u| (u.name.to_lowercase(), u.username.to_lowercase()));
|
||||
Ok(users)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_integration_assignees(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
target: AssignmentTarget,
|
||||
) -> Result<Vec<IntegrationAssignee>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
token: &token,
|
||||
};
|
||||
assigned(
|
||||
&api.send(
|
||||
Method::GET,
|
||||
target_url(&provider, &base_url, &target)?,
|
||||
None,
|
||||
)?,
|
||||
&provider,
|
||||
&target.kind,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn assignment_payload(
|
||||
provider: &str,
|
||||
kind: &str,
|
||||
users: &[IntegrationAssignee],
|
||||
current: &Value,
|
||||
) -> Result<Value, String> {
|
||||
if users.iter().any(|u| u.id.trim().is_empty()) {
|
||||
return Err("Invalid assigned user.".into());
|
||||
}
|
||||
if provider == "azure-devops" && kind == "issue" {
|
||||
if users.len() > 1 {
|
||||
return Err("Azure work items support one assignee.".into());
|
||||
}
|
||||
let rev = current["rev"]
|
||||
.as_u64()
|
||||
.ok_or("Azure returned no work item revision.")?;
|
||||
let identity = users
|
||||
.first()
|
||||
.map(|u| {
|
||||
if u.username.is_empty() {
|
||||
u.id.as_str()
|
||||
} else {
|
||||
u.username.as_str()
|
||||
}
|
||||
})
|
||||
.unwrap_or("");
|
||||
return Ok(
|
||||
json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.AssignedTo","value":identity}]),
|
||||
);
|
||||
}
|
||||
if provider.starts_with("gitlab") {
|
||||
let ids = users
|
||||
.iter()
|
||||
.map(|u| {
|
||||
u.id.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| *id > 0)
|
||||
.ok_or("Invalid GitLab user ID.")
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
return Ok(json!({"assignee_ids": if ids.is_empty() { vec![0] } else { ids }}));
|
||||
}
|
||||
Ok(json!({"assignees":users.iter().map(|u| &u.id).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_integration_assignees(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
target: AssignmentTarget,
|
||||
users: Vec<IntegrationAssignee>,
|
||||
) -> Result<Vec<IntegrationAssignee>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi { client: comment_client()?, provider: &provider, username: &username, token: &token };
|
||||
let url = target_url(&provider, &base_url, &target)?;
|
||||
if users.iter().any(|u| u.id.trim().is_empty()) { return Err("Invalid assigned user.".into()); }
|
||||
let value = if provider == "azure-devops" && target.kind == "review" {
|
||||
let current = assigned(&api.send(Method::GET, url.clone(), None)?, &provider, "review")?;
|
||||
// Only touch changed reviewers: rewriting existing reviewers resets their votes.
|
||||
for user in users.iter().filter(|u| !current.iter().any(|old| old.id == u.id)) {
|
||||
let mut endpoint = url.clone();
|
||||
endpoint.path_segments_mut().map_err(|_| "Invalid reviewer URL.")?.push(&user.id);
|
||||
api.send(Method::PUT, endpoint, Some(&json!({"id":user.id,"vote":0})))?;
|
||||
}
|
||||
for user in current.iter().filter(|u| !users.iter().any(|next| next.id == u.id)) {
|
||||
let mut endpoint = url.clone();
|
||||
endpoint.path_segments_mut().map_err(|_| "Invalid reviewer URL.")?.push(&user.id);
|
||||
api.send(Method::DELETE, endpoint, None)?;
|
||||
}
|
||||
api.send(Method::GET, url, None)?
|
||||
} else {
|
||||
let current = if provider == "azure-devops" { api.send(Method::GET, url.clone(), None)? } else { Value::Null };
|
||||
let payload = assignment_payload(&provider, &target.kind, &users, ¤t)?;
|
||||
api.send(if provider.starts_with("gitlab") { Method::PUT } else { Method::PATCH }, url, Some(&payload))?
|
||||
};
|
||||
let actual = assigned(&value, &provider, &target.kind)?;
|
||||
let ids = |users: &[IntegrationAssignee]| users.iter().map(|u| u.id.to_lowercase()).collect::<BTreeSet<_>>();
|
||||
if ids(&actual) != ids(&users) { return Err("The provider did not confirm all assignments. Reload and check your permissions or the provider's assignee limit.".into()); }
|
||||
Ok(actual)
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
fn target(kind: &str) -> AssignmentTarget {
|
||||
AssignmentTarget {
|
||||
repository: "team/repo".into(),
|
||||
repository_id: "repo-id".into(),
|
||||
number: 12,
|
||||
kind: kind.into(),
|
||||
}
|
||||
}
|
||||
fn person(id: &str) -> IntegrationAssignee {
|
||||
IntegrationAssignee {
|
||||
id: id.into(),
|
||||
username: "alex@example.com".into(),
|
||||
name: "Alex".into(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn routes_prs_and_issues_to_correct_provider_endpoints() {
|
||||
assert_eq!(
|
||||
target_url("github", "https://github.com", &target("review"))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/repos/team/repo/issues/12"
|
||||
);
|
||||
assert_eq!(
|
||||
target_url("gitea", "https://git.test/sub", &target("review"))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/sub/api/v1/repos/team/repo/issues/12"
|
||||
);
|
||||
assert_eq!(
|
||||
target_url(
|
||||
"gitlab-self-hosted",
|
||||
"https://git.test/sub",
|
||||
&target("review")
|
||||
)
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/sub/api/v4/projects/team%2Frepo/merge_requests/12"
|
||||
);
|
||||
assert_eq!(
|
||||
target_url("gitlab", "https://git.test", &target("issue"))
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/api/v4/projects/team%2Frepo/issues/12"
|
||||
);
|
||||
assert_eq!(
|
||||
target_url(
|
||||
"azure-devops",
|
||||
"https://dev.azure.com/org/",
|
||||
&target("review")
|
||||
)
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/org/team/_apis/git/repositories/repo-id/pullrequests/12/reviewers"
|
||||
);
|
||||
assert!(target_url("gitea", "https://git.test", &target("other")).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn payloads_support_assignment_and_removal() {
|
||||
assert_eq!(
|
||||
assignment_payload("github", "review", &[person("alex")], &Value::Null).unwrap(),
|
||||
json!({"assignees":["alex"]})
|
||||
);
|
||||
assert_eq!(
|
||||
assignment_payload("gitea", "issue", &[], &Value::Null).unwrap(),
|
||||
json!({"assignees":[]})
|
||||
);
|
||||
assert_eq!(
|
||||
assignment_payload("gitlab", "review", &[person("42")], &Value::Null).unwrap(),
|
||||
json!({"assignee_ids":[42]})
|
||||
);
|
||||
assert_eq!(
|
||||
assignment_payload("gitlab", "issue", &[], &Value::Null).unwrap(),
|
||||
json!({"assignee_ids":[0]})
|
||||
);
|
||||
assert!(assignment_payload("gitlab", "issue", &[person("alex")], &Value::Null).is_err());
|
||||
let patch = assignment_payload(
|
||||
"azure-devops",
|
||||
"issue",
|
||||
&[person("uuid")],
|
||||
&json!({"rev":3}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(patch[0], json!({"op":"test","path":"/rev","value":3}));
|
||||
assert_eq!(patch[1]["value"], "alex@example.com");
|
||||
assert_eq!(
|
||||
assignment_payload("azure-devops", "issue", &[], &json!({"rev":3})).unwrap()[1]["value"],
|
||||
""
|
||||
);
|
||||
assert!(
|
||||
assignment_payload(
|
||||
"azure-devops",
|
||||
"issue",
|
||||
&[person("a"), person("b")],
|
||||
&json!({"rev":3})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(assignment_payload("azure-devops", "issue", &[], &Value::Null).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn gitea_accepts_unassigned_and_legacy_responses_without_hiding_invalid_data() {
|
||||
for kind in ["issue", "review"] {
|
||||
for response in [
|
||||
json!({"assignees":null,"assignee":null}),
|
||||
json!({"assignees":null}),
|
||||
json!({"assignees":[]}),
|
||||
json!({"assignee":null}),
|
||||
] {
|
||||
assert!(assigned(&response, "gitea", kind).unwrap().is_empty());
|
||||
}
|
||||
for response in [
|
||||
json!({"assignee":{"login":"alex"}}),
|
||||
json!({"assignees":null,"assignee":{"login":"alex"}}),
|
||||
] {
|
||||
assert_eq!(assigned(&response, "gitea", kind).unwrap()[0].id, "alex");
|
||||
}
|
||||
assert_eq!(
|
||||
assigned(
|
||||
&json!({"assignees":[{"login":"alex"},{"login":"sam"}]}),
|
||||
"gitea",
|
||||
kind
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
for response in [
|
||||
json!({}),
|
||||
json!({"assignees":"invalid"}),
|
||||
json!({"assignees":null,"assignee":{}}),
|
||||
] {
|
||||
assert!(assigned(&response, "gitea", kind).is_err());
|
||||
}
|
||||
}
|
||||
assert!(assigned(&json!({"assignees":null}), "github", "issue").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_identities_and_rejects_missing_assignment_data() {
|
||||
assert_eq!(
|
||||
assigned(
|
||||
&json!({"assignees":[{"login":"alex","id":3}]}),
|
||||
"github",
|
||||
"review"
|
||||
)
|
||||
.unwrap()[0]
|
||||
.id,
|
||||
"alex"
|
||||
);
|
||||
assert_eq!(
|
||||
assigned(
|
||||
&json!({"assignees":[{"username":"alex","id":42}]}),
|
||||
"gitlab",
|
||||
"issue"
|
||||
)
|
||||
.unwrap()[0]
|
||||
.id,
|
||||
"42"
|
||||
);
|
||||
assert_eq!(assigned(&json!({"fields":{"System.AssignedTo":{"id":"uuid","displayName":"Alex","uniqueName":"alex@example.com"}}}), "azure-devops", "issue").unwrap()[0], person("uuid"));
|
||||
assert!(
|
||||
assigned(&json!({"fields":{}}), "azure-devops", "issue")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(assigned(&json!({}), "github", "review").is_err());
|
||||
}
|
||||
|
||||
// Exercise the actual authenticated HTTP path against a local provider fixture.
|
||||
pub(crate) fn fixture(
|
||||
responses: Vec<(String, u16, String, String)>,
|
||||
) -> (String, std::thread::JoinHandle<Vec<String>>) {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut requests = vec![];
|
||||
for (expected, status, headers, body) in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut data = Vec::new();
|
||||
let header_end = loop {
|
||||
let mut byte = [0];
|
||||
stream.read_exact(&mut byte).unwrap();
|
||||
data.push(byte[0]);
|
||||
if data.ends_with(b"\r\n\r\n") {
|
||||
break data.len();
|
||||
}
|
||||
};
|
||||
let header = String::from_utf8_lossy(&data).to_string();
|
||||
let length = header
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|n| n.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
data.resize(header_end + length, 0);
|
||||
stream.read_exact(&mut data[header_end..]).unwrap();
|
||||
let received = String::from_utf8(data).unwrap();
|
||||
assert!(
|
||||
received.starts_with(&expected),
|
||||
"Unexpected HTTP request: {expected}"
|
||||
);
|
||||
assert!(header.to_lowercase().contains("authorization:"));
|
||||
requests.push(received);
|
||||
write!(stream, "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len()).unwrap();
|
||||
}
|
||||
requests
|
||||
});
|
||||
(base, worker)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_directory_follows_pages_and_deduplicates_users() {
|
||||
let (base, worker) = fixture(vec![
|
||||
(
|
||||
"GET /api/v1/repos/team/repo/assignees?".into(),
|
||||
200,
|
||||
"Link: </next>; rel=\"next\"\r\n".into(),
|
||||
json!([{"login":"alex","id":1}]).to_string(),
|
||||
),
|
||||
(
|
||||
"GET /api/v1/repos/team/repo/assignees?".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!([{"login":"alex","id":1},{"login":"sam","id":2}]).to_string(),
|
||||
),
|
||||
]);
|
||||
let users = list_integration_assignees(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
target("issue"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(users.len(), 2);
|
||||
let requests = worker.join().unwrap();
|
||||
assert!(requests[0].contains("page=1"));
|
||||
assert!(requests[1].contains("page=2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn azure_reviewer_changes_preserve_existing_votes() {
|
||||
let root = "/team/_apis/git/repositories/repo-id/pullrequests/12/reviewers";
|
||||
let old = json!({"id":"a","uniqueName":"alex@example.com","displayName":"Alex","vote":10});
|
||||
let new = json!({"id":"c","uniqueName":"sam@example.com","displayName":"Sam","vote":0});
|
||||
let (base, worker) = fixture(vec![
|
||||
(
|
||||
format!("GET {root}?"),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"value":[old,{"id":"b","displayName":"Former reviewer"}]}).to_string(),
|
||||
),
|
||||
(
|
||||
format!("PUT {root}/c?"),
|
||||
200,
|
||||
String::new(),
|
||||
new.to_string(),
|
||||
),
|
||||
(
|
||||
format!("DELETE {root}/b?"),
|
||||
204,
|
||||
String::new(),
|
||||
String::new(),
|
||||
),
|
||||
(
|
||||
format!("GET {root}?"),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"value":[old,new]}).to_string(),
|
||||
),
|
||||
]);
|
||||
let users = set_integration_assignees(
|
||||
"azure-devops".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
target("review"),
|
||||
vec![person("a"), person("c")],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(users.len(), 2);
|
||||
let requests = worker.join().unwrap();
|
||||
let body: Value =
|
||||
serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap();
|
||||
assert_eq!(body, json!({"id":"c","vote":0}));
|
||||
assert!(
|
||||
!requests
|
||||
.iter()
|
||||
.any(|request| request.starts_with(&format!("PUT {root}/a")))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn silent_provider_rejection_is_not_reported_as_success() {
|
||||
let (base, worker) = fixture(vec![(
|
||||
"PATCH /api/v1/repos/team/repo/issues/12 ".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"assignees":[]}).to_string(),
|
||||
)]);
|
||||
let result = set_integration_assignees(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
target("issue"),
|
||||
vec![person("alex")],
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().contains("did not confirm"));
|
||||
let requests = worker.join().unwrap();
|
||||
let body: Value =
|
||||
serde_json::from_str(requests[0].split_once("\r\n\r\n").unwrap().1).unwrap();
|
||||
assert_eq!(body, json!({"assignees":["alex"]}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
use super::assignees::{IntegrationApi, api_url, repository_parts, target_url};
|
||||
use super::issue_comments::comment_client;
|
||||
use super::*;
|
||||
use reqwest::{Method, Url};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntegrationLabel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
fn catalog_url(provider: &str, base: &str, repository: &str) -> Result<Url, String> {
|
||||
if repository.trim().is_empty() {
|
||||
return Err("Select a repository or project.".into());
|
||||
}
|
||||
match provider {
|
||||
"github" | "gitea" => {
|
||||
let (owner, repo) = repository_parts(repository)?;
|
||||
let mut parts = vec!["repos", owner, repo, "labels"];
|
||||
if provider == "gitea" {
|
||||
parts.splice(0..0, ["api", "v1"]);
|
||||
}
|
||||
api_url(provider, base, &parts)
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => {
|
||||
let mut url = api_url(
|
||||
provider,
|
||||
base,
|
||||
&["api", "v4", "projects", repository, "labels"],
|
||||
)?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("include_ancestor_groups", "true");
|
||||
Ok(url)
|
||||
}
|
||||
"azure-devops" => api_url(provider, base, &[repository, "_apis", "wit", "tags"]),
|
||||
_ => Err("Unsupported label provider.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_label(value: &Value, provider: &str) -> Result<IntegrationLabel, String> {
|
||||
let name = value
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| value_string(value, &["name"]));
|
||||
if name.trim().is_empty() {
|
||||
return Err("The provider returned an invalid label name.".into());
|
||||
}
|
||||
let id = if provider == "gitea" {
|
||||
json_id(value)
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
if id.is_empty() {
|
||||
return Err("Gitea returned no label ID.".into());
|
||||
}
|
||||
let color = value_string(value, &["color"]);
|
||||
let color = color.trim_start_matches('#');
|
||||
Ok(IntegrationLabel {
|
||||
id,
|
||||
name,
|
||||
color: if color.len() == 6 && color.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
format!("#{color}")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
description: value_string(value, &["description"]),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_labels(value: &Value, provider: &str) -> Result<Vec<IntegrationLabel>, String> {
|
||||
if provider == "gitea" && value.is_null() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
value
|
||||
.as_array()
|
||||
.ok_or("The provider returned no label list.")?
|
||||
.iter()
|
||||
.map(|label| parse_label(label, provider))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn issue_labels(value: &Value, provider: &str) -> Result<Vec<IntegrationLabel>, String> {
|
||||
if provider == "azure-devops" {
|
||||
let fields = value["fields"]
|
||||
.as_object()
|
||||
.ok_or("Azure returned no work item fields.")?;
|
||||
let tags = match fields.get("System.Tags") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(tags)) => tags,
|
||||
_ => return Err("Azure returned an invalid tag list.".into()),
|
||||
};
|
||||
return tags
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|tag| !tag.is_empty())
|
||||
.map(|name| parse_label(&json!(name), provider))
|
||||
.collect();
|
||||
}
|
||||
parse_labels(
|
||||
value
|
||||
.get("labels")
|
||||
.ok_or("The provider returned no issue labels.")?,
|
||||
provider,
|
||||
)
|
||||
}
|
||||
|
||||
fn label_payload(
|
||||
provider: &str,
|
||||
labels: &[IntegrationLabel],
|
||||
current: &Value,
|
||||
) -> Result<Value, String> {
|
||||
if labels.iter().any(|label| label.name.trim().is_empty()) {
|
||||
return Err("Labels must have a name.".into());
|
||||
}
|
||||
match provider {
|
||||
"gitea" => {
|
||||
let ids = labels
|
||||
.iter()
|
||||
.map(|label| {
|
||||
label
|
||||
.id
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| *id > 0)
|
||||
.ok_or("Invalid Gitea label ID.")
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(json!({"labels":ids}))
|
||||
}
|
||||
"github" => {
|
||||
Ok(json!({"labels":labels.iter().map(|label| &label.name).collect::<Vec<_>>()}))
|
||||
}
|
||||
"gitlab" | "gitlab-self-hosted" => {
|
||||
if labels.iter().any(|label| label.name.contains(',')) {
|
||||
return Err("GitLab label names cannot contain commas for this operation.".into());
|
||||
}
|
||||
Ok(
|
||||
json!({"labels":labels.iter().map(|label| label.name.as_str()).collect::<Vec<_>>().join(",")}),
|
||||
)
|
||||
}
|
||||
"azure-devops" => {
|
||||
if labels.iter().any(|label| label.name.contains(';')) {
|
||||
return Err("Azure tag names cannot contain semicolons.".into());
|
||||
}
|
||||
let rev = current["rev"]
|
||||
.as_u64()
|
||||
.ok_or("Azure returned no work item revision.")?;
|
||||
Ok(
|
||||
json!([{"op":"test","path":"/rev","value":rev},{"op":"add","path":"/fields/System.Tags","value":labels.iter().map(|label| label.name.as_str()).collect::<Vec<_>>().join("; ")}]),
|
||||
)
|
||||
}
|
||||
_ => Err("Unsupported label provider.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn names(labels: &[IntegrationLabel]) -> BTreeSet<String> {
|
||||
labels.iter().map(|label| label.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn desired_labels(
|
||||
current: &[IntegrationLabel],
|
||||
requested: Vec<IntegrationLabel>,
|
||||
expected: Option<Vec<String>>,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
if let Some(expected) = expected {
|
||||
if names(current) != expected.into_iter().collect() {
|
||||
return Err(
|
||||
"The issue labels changed. Reload the current labels before saving.".into(),
|
||||
);
|
||||
}
|
||||
Ok(requested)
|
||||
} else {
|
||||
// Creation adds to provider defaults. Retrying is idempotent after a lost response.
|
||||
let mut result = current.to_vec();
|
||||
for label in requested {
|
||||
if !result.iter().any(|existing| existing.name == label.name) {
|
||||
result.push(label);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_integration_labels(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
repository: String,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
token: &token,
|
||||
};
|
||||
let url = catalog_url(&provider, &base_url, &repository)?;
|
||||
let mut labels = if provider == "azure-devops" {
|
||||
let value = api.send(Method::GET, url, None)?;
|
||||
// Azure services return the collection envelope; also accept the documented array form.
|
||||
parse_labels(value.get("value").unwrap_or(&value), &provider)?
|
||||
} else {
|
||||
api.pages(url)?
|
||||
.iter()
|
||||
.filter(|label| label["archived_at"].is_null())
|
||||
.map(|label| parse_label(label, &provider))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
let mut seen = BTreeSet::new();
|
||||
labels.retain(|label| seen.insert(label.name.clone()));
|
||||
labels.sort_by_key(|label| label.name.to_lowercase());
|
||||
Ok(labels)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_integration_issue_labels(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
repository: String,
|
||||
number: u64,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi {
|
||||
client: comment_client()?,
|
||||
provider: &provider,
|
||||
username: &username,
|
||||
token: &token,
|
||||
};
|
||||
let target = AssignmentTarget {
|
||||
repository,
|
||||
repository_id: String::new(),
|
||||
number,
|
||||
kind: "issue".into(),
|
||||
};
|
||||
issue_labels(
|
||||
&api.send(
|
||||
Method::GET,
|
||||
target_url(&provider, &base_url, &target)?,
|
||||
None,
|
||||
)?,
|
||||
&provider,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_integration_issue_labels(
|
||||
provider: String,
|
||||
base_url: String,
|
||||
username: String,
|
||||
token: String,
|
||||
repository: String,
|
||||
number: u64,
|
||||
labels: Vec<IntegrationLabel>,
|
||||
expected: Option<Vec<String>>,
|
||||
) -> Result<Vec<IntegrationLabel>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let api = IntegrationApi { client: comment_client()?, provider: &provider, username: &username, token: &token };
|
||||
let target = AssignmentTarget { repository, repository_id: String::new(), number, kind: "issue".into() };
|
||||
let mut url = target_url(&provider, &base_url, &target)?;
|
||||
let current = api.send(Method::GET, url.clone(), None)?;
|
||||
let current_labels = issue_labels(¤t, &provider)?;
|
||||
let labels = desired_labels(¤t_labels, labels, expected)?;
|
||||
if names(¤t_labels) == names(&labels) { return Ok(current_labels); }
|
||||
let body = label_payload(&provider, &labels, ¤t)?;
|
||||
let array_response = provider == "github" || provider == "gitea";
|
||||
if array_response { url.path_segments_mut().map_err(|_| "Invalid labels URL.")?.push("labels"); }
|
||||
let response = api.send(if provider == "azure-devops" { Method::PATCH } else { Method::PUT }, url, Some(&body))?;
|
||||
let actual = if array_response { parse_labels(&response, &provider)? } else { issue_labels(&response, &provider)? };
|
||||
if names(&actual) != names(&labels) { return Err("The provider did not confirm the labels. Reload the issue and check your permissions.".into()); }
|
||||
Ok(actual)
|
||||
}).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn label(name: &str) -> IntegrationLabel {
|
||||
IntegrationLabel {
|
||||
id: "7".into(),
|
||||
name: name.into(),
|
||||
color: String::new(),
|
||||
description: String::new(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn provider_catalog_routes_preserve_subpaths() {
|
||||
assert_eq!(
|
||||
catalog_url("gitea", "https://git.test/sub", "team/repo")
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/sub/api/v1/repos/team/repo/labels"
|
||||
);
|
||||
assert_eq!(
|
||||
catalog_url("github", "https://github.com", "team/repo")
|
||||
.unwrap()
|
||||
.host_str(),
|
||||
Some("api.github.com")
|
||||
);
|
||||
let gl = catalog_url(
|
||||
"gitlab-self-hosted",
|
||||
"https://git.test/sub",
|
||||
"team/nested/repo",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
gl.path(),
|
||||
"/sub/api/v4/projects/team%2Fnested%2Frepo/labels"
|
||||
);
|
||||
assert_eq!(gl.query(), Some("include_ancestor_groups=true"));
|
||||
assert_eq!(
|
||||
catalog_url("azure-devops", "https://dev.azure.com/org", "My Project")
|
||||
.unwrap()
|
||||
.path(),
|
||||
"/org/My%20Project/_apis/wit/tags"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn provider_payloads_add_and_clear_labels() {
|
||||
assert_eq!(
|
||||
label_payload("gitea", &[label("bug")], &Value::Null).unwrap(),
|
||||
json!({"labels":[7]})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("github", &[label("bug")], &Value::Null).unwrap(),
|
||||
json!({"labels":["bug"]})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("gitlab", &[label("bug"), label("urgent")], &Value::Null).unwrap(),
|
||||
json!({"labels":"bug,urgent"})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("gitlab", &[], &Value::Null).unwrap(),
|
||||
json!({"labels":""})
|
||||
);
|
||||
assert_eq!(
|
||||
label_payload("gitea", &[], &Value::Null).unwrap(),
|
||||
json!({"labels":[]})
|
||||
);
|
||||
let patch = label_payload("azure-devops", &[label("bug")], &json!({"rev":8})).unwrap();
|
||||
assert_eq!(patch[0], json!({"op":"test","path":"/rev","value":8}));
|
||||
assert_eq!(patch[1]["value"], "bug");
|
||||
assert_eq!(
|
||||
label_payload("azure-devops", &[], &json!({"rev":8})).unwrap()[1]["value"],
|
||||
""
|
||||
);
|
||||
assert!(label_payload("gitlab", &[label("comma,name")], &Value::Null).is_err());
|
||||
assert!(label_payload("azure-devops", &[label("bad;tag")], &json!({"rev":8})).is_err());
|
||||
assert!(label_payload("azure-devops", &[], &Value::Null).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reads_gitea_null_gitlab_strings_and_azure_tags() {
|
||||
assert!(
|
||||
issue_labels(&json!({"labels":null}), "gitea")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let gitea = issue_labels(
|
||||
&json!({"labels":[{"id":7,"name":"bug","color":"ff0000"}]}),
|
||||
"gitea",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(gitea[0].id, "7");
|
||||
assert_eq!(gitea[0].color, "#ff0000");
|
||||
assert_eq!(
|
||||
issue_labels(&json!({"labels":["bug"]}), "gitlab").unwrap()[0].name,
|
||||
"bug"
|
||||
);
|
||||
assert_eq!(
|
||||
issue_labels(
|
||||
&json!({"fields":{"System.Tags":"bug; urgent; "}}),
|
||||
"azure-devops"
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
issue_labels(&json!({"fields":{}}), "azure-devops")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(issue_labels(&json!({}), "gitea").is_err());
|
||||
assert!(issue_labels(&json!({}), "azure-devops").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn creation_preserves_defaults_and_edit_detects_stale_labels() {
|
||||
let current = vec![label("default")];
|
||||
let next = desired_labels(¤t, vec![label("bug")], None).unwrap();
|
||||
assert_eq!(next.len(), 2);
|
||||
assert_eq!(
|
||||
desired_labels(&next, vec![label("bug")], None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(desired_labels(¤t, vec![label("bug")], Some(vec![])).is_err());
|
||||
assert!(
|
||||
desired_labels(¤t, vec![], Some(vec!["default".into()]))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn gitea_writes_numeric_label_ids_and_reads_null_current_labels() {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![
|
||||
(
|
||||
"GET /api/v1/repos/team/repo/issues/12 ".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"labels":null}).to_string(),
|
||||
),
|
||||
(
|
||||
"PUT /api/v1/repos/team/repo/issues/12/labels ".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!([{"id":7,"name":"bug","color":"ff0000"}]).to_string(),
|
||||
),
|
||||
]);
|
||||
let result = set_integration_issue_labels(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"team/repo".into(),
|
||||
12,
|
||||
vec![label("bug")],
|
||||
Some(vec![]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result[0].name, "bug");
|
||||
let requests = worker.join().unwrap();
|
||||
let body: Value =
|
||||
serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap();
|
||||
assert_eq!(body, json!({"labels":[7]}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn azure_tag_updates_guard_revision_and_preserve_other_fields() {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![
|
||||
(
|
||||
"GET /Project/_apis/wit/workitems/12?".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"rev":4,"fields":{"System.Tags":"default"}}).to_string(),
|
||||
),
|
||||
(
|
||||
"PATCH /Project/_apis/wit/workitems/12?".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"rev":5,"fields":{"System.Tags":"default; bug"}}).to_string(),
|
||||
),
|
||||
]);
|
||||
let result = set_integration_issue_labels(
|
||||
"azure-devops".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"Project".into(),
|
||||
12,
|
||||
vec![label("bug")],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
let requests = worker.join().unwrap();
|
||||
assert!(requests[1].contains("application/json-patch+json"));
|
||||
let body: Value =
|
||||
serde_json::from_str(requests[1].split_once("\r\n\r\n").unwrap().1).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!([{"op":"test","path":"/rev","value":4},{"op":"add","path":"/fields/System.Tags","value":"default; bug"}])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_labels_stop_before_any_write() {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![(
|
||||
"GET /api/v1/repos/team/repo/issues/12 ".into(),
|
||||
200,
|
||||
String::new(),
|
||||
json!({"labels":[{"id":9,"name":"new"}]}).to_string(),
|
||||
)]);
|
||||
let result = set_integration_issue_labels(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"team/repo".into(),
|
||||
12,
|
||||
vec![label("bug")],
|
||||
Some(vec![]),
|
||||
)
|
||||
.await;
|
||||
assert!(result.unwrap_err().contains("labels changed"));
|
||||
assert_eq!(worker.join().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_gitea_catalog_is_valid_but_permission_errors_are_not_empty_lists() {
|
||||
for (status, response, valid) in [
|
||||
(200, "null", true),
|
||||
(403, "{\"message\":\"Forbidden\"}", false),
|
||||
] {
|
||||
let (base, worker) = super::super::assignees::tests::fixture(vec![(
|
||||
"GET /api/v1/repos/team/repo/labels?".into(),
|
||||
status,
|
||||
String::new(),
|
||||
response.into(),
|
||||
)]);
|
||||
let result = list_integration_labels(
|
||||
"gitea".into(),
|
||||
base,
|
||||
"qa".into(),
|
||||
"fixture-token".into(),
|
||||
"team/repo".into(),
|
||||
)
|
||||
.await;
|
||||
if valid {
|
||||
assert!(result.unwrap().is_empty());
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod external_tools;
|
||||
mod git;
|
||||
mod integrations;
|
||||
mod telemetry;
|
||||
mod terminal;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use external_tools::{
|
||||
@@ -37,6 +38,8 @@ use git::{
|
||||
use integrations::{
|
||||
create_integration_review_request, list_integration_repository_branches,
|
||||
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
|
||||
list_integration_assignees, get_integration_assignees, set_integration_assignees,
|
||||
list_integration_labels, get_integration_issue_labels, set_integration_issue_labels,
|
||||
create_integration_issue, list_azure_issue_projects, list_azure_issue_types,
|
||||
run_integration_review_action, get_integration_review_merge_options, list_integration_issues, get_integration_board, list_integration_boards, move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, close_integration_issue, list_azure_issue_states, set_azure_issue_state,
|
||||
};
|
||||
@@ -44,6 +47,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||
use terminal::{TerminalState, terminal_close, terminal_open, terminal_resize, terminal_write};
|
||||
|
||||
struct StartupRepository(Mutex<Option<String>>);
|
||||
|
||||
@@ -323,6 +327,7 @@ async fn main() {
|
||||
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||
.manage(StartupClone(Mutex::new(startup_clone)))
|
||||
.manage(SearchCancellationState::default())
|
||||
.manage(TerminalState::default())
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
|
||||
// Linux installs are expected to come from the system package manager (see the
|
||||
@@ -333,6 +338,10 @@ async fn main() {
|
||||
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
terminal_open,
|
||||
terminal_write,
|
||||
terminal_resize,
|
||||
terminal_close,
|
||||
open_repository,
|
||||
init_repository,
|
||||
clone_repository,
|
||||
@@ -456,6 +465,12 @@ async fn main() {
|
||||
move_integration_board_card,
|
||||
list_integration_issue_comments,
|
||||
add_integration_issue_comment,
|
||||
list_integration_labels,
|
||||
get_integration_issue_labels,
|
||||
set_integration_issue_labels,
|
||||
list_integration_assignees,
|
||||
get_integration_assignees,
|
||||
set_integration_assignees,
|
||||
create_integration_issue,
|
||||
list_azure_issue_projects,
|
||||
list_azure_issue_types,
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Embedded terminal: one PTY-backed shell session per repository tab.
|
||||
//!
|
||||
//! The frontend owns the session ids and talks to a real pseudo terminal, so
|
||||
//! colors, interactive prompts, tab completion and Ctrl+C behave exactly like
|
||||
//! they do in the system terminal. Everything the shell prints is forwarded as
|
||||
//! UTF-8 chunks; everything the user types goes back through `terminal_write`.
|
||||
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
const DATA_EVENT: &str = "terminal:data";
|
||||
const EXIT_EVENT: &str = "terminal:exit";
|
||||
|
||||
/// A read never returns more than this, so one busy command cannot starve the
|
||||
/// event loop with a single enormous payload.
|
||||
const READ_CHUNK: usize = 8 * 1024;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TerminalChunk {
|
||||
id: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TerminalExit {
|
||||
id: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
writer: Box<dyn Write + Send>,
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TerminalState(Mutex<HashMap<String, Session>>);
|
||||
|
||||
fn lock_error(details: impl std::fmt::Display) -> String {
|
||||
format!("Terminal state is unavailable: {details}")
|
||||
}
|
||||
|
||||
/// The user's own shell, so their aliases, prompt and PATH are present. A PTY
|
||||
/// makes the shell interactive on its own, so no login flag is needed — that
|
||||
/// would only risk a profile changing the working directory.
|
||||
fn shell_command() -> CommandBuilder {
|
||||
#[cfg(windows)]
|
||||
let mut command = {
|
||||
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string());
|
||||
CommandBuilder::new(shell)
|
||||
};
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let mut command = {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
|
||||
CommandBuilder::new(shell)
|
||||
};
|
||||
|
||||
// xterm.js speaks xterm-256color, and git should not page into a terminal
|
||||
// the user cannot scroll back with a mouse wheel alone.
|
||||
command.env("TERM", "xterm-256color");
|
||||
command.env("COLORTERM", "truecolor");
|
||||
command
|
||||
}
|
||||
|
||||
/// Decodes as much of `buffer` as forms complete UTF-8 and returns it, leaving
|
||||
/// a split multi-byte sequence in place for the next read.
|
||||
fn take_utf8(buffer: &mut Vec<u8>) -> String {
|
||||
match std::str::from_utf8(buffer) {
|
||||
Ok(text) => {
|
||||
let text = text.to_string();
|
||||
buffer.clear();
|
||||
text
|
||||
}
|
||||
Err(error) => {
|
||||
let valid = error.valid_up_to();
|
||||
let text = String::from_utf8_lossy(&buffer[..valid]).into_owned();
|
||||
match error.error_len() {
|
||||
// Genuinely invalid bytes: drop them, or the stream would stall.
|
||||
Some(length) => buffer.drain(..valid + length),
|
||||
// An incomplete tail: keep it for the next chunk.
|
||||
None => buffer.drain(..valid),
|
||||
};
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_open(
|
||||
app: AppHandle,
|
||||
state: State<'_, TerminalState>,
|
||||
id: String,
|
||||
cwd: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = state.0.lock().map_err(lock_error)?;
|
||||
if sessions.contains_key(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let directory = Path::new(&cwd);
|
||||
if !directory.is_dir() {
|
||||
return Err(format!("Working directory does not exist: {cwd}"));
|
||||
}
|
||||
|
||||
let size = PtySize {
|
||||
rows: rows.max(1),
|
||||
cols: cols.max(1),
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
};
|
||||
let pair = native_pty_system()
|
||||
.openpty(size)
|
||||
.map_err(|error| format!("Could not open a pseudo terminal: {error}"))?;
|
||||
|
||||
let mut command = shell_command();
|
||||
command.cwd(directory);
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(command)
|
||||
.map_err(|error| format!("Could not start the shell: {error}"))?;
|
||||
// The slave side must go, otherwise the reader never sees end of file.
|
||||
drop(pair.slave);
|
||||
|
||||
let mut reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|error| format!("Could not read from the terminal: {error}"))?;
|
||||
let writer = pair
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|error| format!("Could not write to the terminal: {error}"))?;
|
||||
|
||||
let reader_app = app.clone();
|
||||
let reader_id = id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let mut chunk = [0u8; READ_CHUNK];
|
||||
let message = loop {
|
||||
match reader.read(&mut chunk) {
|
||||
Ok(0) => break String::new(),
|
||||
Ok(count) => {
|
||||
pending.extend_from_slice(&chunk[..count]);
|
||||
let text = take_utf8(&mut pending);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if reader_app
|
||||
.emit(
|
||||
DATA_EVENT,
|
||||
TerminalChunk {
|
||||
id: reader_id.clone(),
|
||||
data: text,
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
// The window is gone; nothing left to deliver output to.
|
||||
break String::new();
|
||||
}
|
||||
}
|
||||
Err(error) => break error.to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(state) = reader_app.try_state::<TerminalState>() {
|
||||
if let Ok(mut sessions) = state.0.lock() {
|
||||
if let Some(mut session) = sessions.remove(&reader_id) {
|
||||
let _ = session.child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = reader_app.emit(
|
||||
EXIT_EVENT,
|
||||
TerminalExit {
|
||||
id: reader_id,
|
||||
message,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
sessions.insert(
|
||||
id,
|
||||
Session {
|
||||
master: pair.master,
|
||||
writer,
|
||||
child,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_write(
|
||||
state: State<'_, TerminalState>,
|
||||
id: String,
|
||||
data: String,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = state.0.lock().map_err(lock_error)?;
|
||||
let session = sessions
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| "This terminal session is no longer running.".to_string())?;
|
||||
session
|
||||
.writer
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|error| format!("Could not send input to the terminal: {error}"))?;
|
||||
session
|
||||
.writer
|
||||
.flush()
|
||||
.map_err(|error| format!("Could not send input to the terminal: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_resize(
|
||||
state: State<'_, TerminalState>,
|
||||
id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Result<(), String> {
|
||||
let sessions = state.0.lock().map_err(lock_error)?;
|
||||
// A closed session is not an error here: resize races with tab switching.
|
||||
let Some(session) = sessions.get(&id) else {
|
||||
return Ok(());
|
||||
};
|
||||
session
|
||||
.master
|
||||
.resize(PtySize {
|
||||
rows: rows.max(1),
|
||||
cols: cols.max(1),
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|error| format!("Could not resize the terminal: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_close(state: State<'_, TerminalState>, id: String) -> Result<(), String> {
|
||||
let mut sessions = state.0.lock().map_err(lock_error)?;
|
||||
if let Some(mut session) = sessions.remove(&id) {
|
||||
let _ = session.child.kill();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::take_utf8;
|
||||
|
||||
#[test]
|
||||
fn take_utf8_keeps_a_split_sequence_for_the_next_chunk() {
|
||||
// "ä" is two bytes; the second one arrives later.
|
||||
let mut buffer = vec![b'a', 0xc3];
|
||||
assert_eq!(take_utf8(&mut buffer), "a");
|
||||
assert_eq!(buffer, vec![0xc3]);
|
||||
|
||||
buffer.push(0xa4);
|
||||
assert_eq!(take_utf8(&mut buffer), "ä");
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_utf8_drops_invalid_bytes_instead_of_stalling() {
|
||||
let mut buffer = vec![b'a', 0xff, b'b'];
|
||||
assert_eq!(take_utf8(&mut buffer), "a");
|
||||
assert_eq!(take_utf8(&mut buffer), "b");
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
}
|
||||
+173
-2
@@ -5,7 +5,7 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, SquareTerminal, Star, X } from "@lucide/svelte";
|
||||
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
|
||||
import { setLanguage, t } from "./lib/i18n.svelte";
|
||||
import type { ConfirmRequest } from "./lib/components/ConfirmDialog.svelte";
|
||||
@@ -298,6 +298,10 @@
|
||||
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
|
||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||
const TERMINAL_HEIGHT_KEY = "gitlite.terminalHeight.v1";
|
||||
const TERMINAL_OPEN_KEY = "gitlite.terminalOpen.v1";
|
||||
const TERMINAL_MIN_HEIGHT = 120;
|
||||
const TERMINAL_MAX_HEIGHT = 720;
|
||||
const SIDEBAR_VISIBILITY_KEY = "gitlite.sidebarVisibility.v1";
|
||||
const SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2";
|
||||
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
|
||||
@@ -566,6 +570,11 @@
|
||||
let resizeStartY = 0;
|
||||
let resizeStartHeight = 0;
|
||||
let leftSidebarWidth = loadLeftSidebarWidth();
|
||||
let terminalHeight = loadTerminalHeight();
|
||||
let terminalOpen = loadTerminalOpen();
|
||||
let resizingTerminal = false;
|
||||
let terminalResizeStartY = 0;
|
||||
let terminalResizeStartHeight = 0;
|
||||
let resizingLeftSidebar = false;
|
||||
let leftSidebarResizeStartX = 0;
|
||||
let leftSidebarResizeStartWidth = 0;
|
||||
@@ -2041,6 +2050,60 @@
|
||||
persistCommitPanelHeight(commitPanelHeight);
|
||||
}
|
||||
|
||||
// ── Embedded terminal ──────────────────────────────────────────────────────
|
||||
function clampTerminalHeight(value: number): number {
|
||||
return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, Math.round(value)));
|
||||
}
|
||||
|
||||
function loadTerminalHeight(): number {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(TERMINAL_HEIGHT_KEY));
|
||||
return Number.isFinite(stored) && stored > 0 ? clampTerminalHeight(stored) : 260;
|
||||
} catch { return 260; }
|
||||
}
|
||||
|
||||
function persistTerminalHeight(value: number) {
|
||||
try { localStorage.setItem(TERMINAL_HEIGHT_KEY, String(value)); } catch { /* storage may be unavailable */ }
|
||||
}
|
||||
|
||||
function loadTerminalOpen(): boolean {
|
||||
try { return localStorage.getItem(TERMINAL_OPEN_KEY) === "1"; } catch { return false; }
|
||||
}
|
||||
|
||||
function toggleTerminal() {
|
||||
terminalOpen = !terminalOpen;
|
||||
try { localStorage.setItem(TERMINAL_OPEN_KEY, terminalOpen ? "1" : "0"); } catch { /* storage may be unavailable */ }
|
||||
}
|
||||
|
||||
function startTerminalResize(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
resizingTerminal = true;
|
||||
terminalResizeStartY = event.clientY;
|
||||
terminalResizeStartHeight = terminalHeight;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onTerminalResizeMove(event: PointerEvent) {
|
||||
if (!resizingTerminal) return;
|
||||
// The dock grows upwards, so a smaller clientY means a taller terminal.
|
||||
terminalHeight = clampTerminalHeight(terminalResizeStartHeight + (terminalResizeStartY - event.clientY));
|
||||
}
|
||||
|
||||
function endTerminalResize(event: PointerEvent) {
|
||||
if (!resizingTerminal) return;
|
||||
resizingTerminal = false;
|
||||
persistTerminalHeight(terminalHeight);
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onTerminalResizeKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||
event.preventDefault();
|
||||
terminalHeight = clampTerminalHeight(terminalHeight + (event.key === "ArrowUp" ? 20 : -20));
|
||||
persistTerminalHeight(terminalHeight);
|
||||
}
|
||||
|
||||
function startLeftSidebarResize(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
resizingLeftSidebar = true;
|
||||
@@ -5940,6 +6003,67 @@
|
||||
|
||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
// ── Main view shortcuts ────────────────────────────────────────────────────
|
||||
// Ctrl/Cmd + 1…4 jump to the four main views in the order of the tab bar.
|
||||
const mainViewShortcuts: AppView[] = ["management", "repository", "review-center", "issues"];
|
||||
|
||||
function mainViewShortcutIndex(event: KeyboardEvent): number {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return 0;
|
||||
const code = event.code ?? "";
|
||||
const digit = code.startsWith("Digit")
|
||||
? Number(code.slice(5))
|
||||
: code.startsWith("Numpad")
|
||||
? Number(code.slice(6))
|
||||
: Number(event.key);
|
||||
if (!Number.isInteger(digit) || digit < 1 || digit > mainViewShortcuts.length) return 0;
|
||||
return digit;
|
||||
}
|
||||
|
||||
// Capture phase: dialogs and editors trap keys on their own nodes, and the
|
||||
// view shortcut must not depend on the event reaching window afterwards.
|
||||
function handleViewShortcutKeydown(event: KeyboardEvent) {
|
||||
const index = mainViewShortcutIndex(event);
|
||||
if (index === 0) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug("[gitty] view shortcut", { key: event.key, code: event.code, index, blocked: mainViewSwitchBlocked(), activeView });
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!event.repeat) switchMainView(index);
|
||||
}
|
||||
|
||||
// Anything modal owns the keyboard, so the view stays where it is.
|
||||
function mainViewSwitchBlocked(): boolean {
|
||||
return isBusy
|
||||
|| commandPaletteOpen || helpOpen || globalSearchOpen || appSettingsOpen
|
||||
|| cloneDialogOpen || initRepositoryDialogOpen || credDialogOpen
|
||||
|| compareDialogOpen || compareSelectOpen || fileHistoryDialogOpen
|
||||
|| resolveDialogOpen || interactiveRebaseOpen || reflogOpen || bisectOpen
|
||||
|| worktreeDialogOpen || submoduleDialogOpen || gitLfsDialogOpen
|
||||
|| linePatchOpen || aiReviewOpen
|
||||
|| pendingSubmoduleInitialization !== null || pendingDiscard !== null
|
||||
|| newBranchCommit !== null || renameBranchTarget !== null
|
||||
|| deleteBranchTarget !== null || commitNoteTarget !== null;
|
||||
}
|
||||
|
||||
function switchMainView(index: number) {
|
||||
if (mainViewSwitchBlocked()) return;
|
||||
const view = mainViewShortcuts[index - 1];
|
||||
if (!view || view === activeView) return;
|
||||
closeRepoTabContextMenu();
|
||||
if (view === "management") {
|
||||
openRepoManagement();
|
||||
} else if (view === "review-center") {
|
||||
openReviewCenter();
|
||||
} else if (view === "issues") {
|
||||
activeView = "issues";
|
||||
} else {
|
||||
// Without an open repository the tab bar would ask for a folder; a
|
||||
// keyboard shortcut should never pop up a native file dialog.
|
||||
const path = repoTabs.find((tab) => sameRepoPath(tab.path, activeRepoPath))?.path ?? repoTabs[0]?.path;
|
||||
if (path) void selectRepoTab(path);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
@@ -5951,6 +6075,13 @@
|
||||
openHelp();
|
||||
return;
|
||||
}
|
||||
// Backquote by code, so the shortcut survives keyboard layouts where ` is
|
||||
// a dead key.
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.code === "Backquote") {
|
||||
event.preventDefault();
|
||||
if (!event.repeat && workspaceActive) toggleTerminal();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && commandPaletteOpen) {
|
||||
commandPaletteOpen = false;
|
||||
return;
|
||||
@@ -5992,7 +6123,7 @@
|
||||
<title>Gitty</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window on:click={handleWindowClick} on:keydown={handleWindowKeydown} on:contextmenu|capture={handleWindowContextMenu} />
|
||||
<svelte:window on:click={handleWindowClick} on:keydown|capture={handleViewShortcutKeydown} on:keydown={handleWindowKeydown} on:contextmenu|capture={handleWindowContextMenu} />
|
||||
|
||||
{#if sidebarSectionMenu && activeView === "repository"}
|
||||
<SidebarSectionMenu x={sidebarSectionMenu.x} y={sidebarSectionMenu.y} language={appLanguage}
|
||||
@@ -6610,6 +6741,41 @@
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
{#if terminalOpen}
|
||||
<section class="terminal-dock" style="--terminal-height: {terminalHeight}px" aria-label={t("terminal.title")}>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="terminal-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t("terminal.resize")}
|
||||
aria-valuenow={terminalHeight}
|
||||
aria-valuemin={TERMINAL_MIN_HEIGHT}
|
||||
aria-valuemax={TERMINAL_MAX_HEIGHT}
|
||||
tabindex="0"
|
||||
onpointerdown={startTerminalResize}
|
||||
onpointermove={onTerminalResizeMove}
|
||||
onpointerup={endTerminalResize}
|
||||
onpointercancel={endTerminalResize}
|
||||
onkeydown={onTerminalResizeKeydown}
|
||||
></div>
|
||||
<header class="terminal-dock-head">
|
||||
<span class="terminal-dock-title"><SquareTerminal size={13} aria-hidden="true" />{t("terminal.title")}</span>
|
||||
<span class="terminal-dock-path" title={activeRepoPath}>{activeRepoPath}</span>
|
||||
<button class="terminal-dock-close" type="button" onclick={toggleTerminal} title={t("terminal.hide")} aria-label={t("terminal.hide")}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="terminal-dock-body">
|
||||
{#await import("./lib/components/TerminalPanel.svelte") then module}
|
||||
{#each repoTabs as tab (tab.path)}
|
||||
<module.default sessionId={`repo:${tab.path}`} repoPath={tab.path} active={sameRepoPath(tab.path, activeRepoPath)} />
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
<footer class="workspace-statusbar" aria-label="Application status summary">
|
||||
@@ -6629,6 +6795,11 @@
|
||||
{/if}
|
||||
<span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
|
||||
{/if}
|
||||
{#if workspaceActive}
|
||||
<button class:active={terminalOpen} class="workspace-terminal-toggle" type="button" onclick={toggleTerminal} aria-pressed={terminalOpen} title={terminalOpen ? t("terminal.hide") : t("terminal.show")}>
|
||||
<SquareTerminal size={12} aria-hidden="true" />{t("terminal.title")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
+150
-1
@@ -3647,6 +3647,7 @@
|
||||
.compare-dialog-backdrop { z-index: 80; }
|
||||
.file-history-dialog {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(780px, calc(100vw - 32px));
|
||||
height: min(720px, calc(100vh - 32px));
|
||||
@@ -3695,7 +3696,7 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-history-dialog-body { min-height: 0; overflow: hidden; background: var(--color-surface-solid); }
|
||||
.file-history-dialog-body { min-width: 0; min-height: 0; overflow: hidden; background: var(--color-surface-solid); }
|
||||
.file-history-dialog-list { height: 100%; padding: 6px; overflow: auto; }
|
||||
.file-history-dialog-row {
|
||||
display: grid;
|
||||
@@ -6452,6 +6453,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px));
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
flex: 1 1 0;
|
||||
padding: 0;
|
||||
background: var(--color-border-subtle);
|
||||
@@ -6459,6 +6461,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
}
|
||||
.left-sidebar { background: var(--color-surface); }
|
||||
.main-panel { border: 0; border-radius: 0; background: var(--color-surface-solid); }
|
||||
.left-sidebar,
|
||||
.left-sidebar-resize-handle,
|
||||
.history-resize-handle,
|
||||
.history-aside { grid-row: 1 / -1; }
|
||||
.main-panel { grid-row: 1; }
|
||||
.history-aside { background: var(--color-border-subtle); row-gap: 0; }
|
||||
|
||||
.panel {
|
||||
@@ -6578,6 +6585,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--color-success) 2.5%, transparent), transparent 120px),
|
||||
var(--color-surface-solid);
|
||||
}
|
||||
/* The lane holding the keyboard focus is the one Ctrl/Cmd + A selects in. */
|
||||
.status-lane:focus { outline: none; }
|
||||
.status-lane:focus-within {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 34%, transparent);
|
||||
}
|
||||
.status-flow-divider {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -9173,6 +9185,12 @@ section > header.page-header.page-header {
|
||||
:root .unified-dialog-header .unified-dialog-text :is(h2,h3,.dialog-title,strong) { font-size: 16px; }
|
||||
:root .unified-dialog-header .help-search { order: 2; flex: 1 0 100%; width: 100%; max-width: none; }
|
||||
}
|
||||
/* Keep long file paths inside the history dialog without displacing its controls. */
|
||||
:root .file-history-dialog .unified-dialog-header { min-width: 0; flex-wrap: nowrap; }
|
||||
:root .file-history-dialog .unified-dialog-header .unified-dialog-text { flex-basis: 0; }
|
||||
:root .file-history-dialog .unified-dialog-header .unified-dialog-text h2 {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
:root .cred-hero:has(> .unified-dialog-header) { padding: 0 0 14px; background: var(--app-dialog-bg); }
|
||||
:root .cred-hero:has(> .unified-dialog-header) > :is(.cred-hero-copy,.cred-security-note) { margin: 12px 18px 0; }
|
||||
:root { --dialog-close-hover-ink: var(--color-danger); }
|
||||
@@ -9289,3 +9307,134 @@ section > header.page-header.page-header {
|
||||
|
||||
/* Tab strips keep their hidden scrollbars. */
|
||||
.repo-tabs-scroll { scrollbar-width: none; }
|
||||
|
||||
/* ── Embedded terminal ─────────────────────────────────────────────────────── */
|
||||
.terminal-dock {
|
||||
/* Center column only: the gutters beside it belong to the resize handles,
|
||||
which now run the full height. */
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
display: grid;
|
||||
grid-template-rows: 5px auto minmax(0, 1fr);
|
||||
height: var(--terminal-height, 260px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-solid);
|
||||
}
|
||||
|
||||
.terminal-resize-handle {
|
||||
cursor: ns-resize;
|
||||
background: var(--color-border-subtle);
|
||||
touch-action: none;
|
||||
}
|
||||
.terminal-resize-handle:hover,
|
||||
.terminal-resize-handle:focus-visible {
|
||||
outline: none;
|
||||
background: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||
}
|
||||
|
||||
.terminal-dock-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 30px;
|
||||
padding: 0 8px 0 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: var(--color-surface-dim);
|
||||
}
|
||||
.terminal-dock-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-ink);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.terminal-dock-path {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.terminal-dock-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-ink-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.terminal-dock-close:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.terminal-dock-body {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 6px 4px 6px 10px;
|
||||
}
|
||||
.terminal-surface.hidden { display: none; }
|
||||
.terminal-host { width: 100%; height: 100%; }
|
||||
|
||||
/* xterm draws its own scrollbar; keep it in the app's visual language. */
|
||||
.terminal-surface .xterm-viewport { background: transparent !important; }
|
||||
.terminal-surface .xterm-viewport::-webkit-scrollbar { width: 9px; }
|
||||
.terminal-surface .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-ink-muted) 34%, transparent);
|
||||
}
|
||||
|
||||
.terminal-error {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
margin: 0;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-danger) 16%, var(--color-surface-solid));
|
||||
color: var(--color-danger);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.workspace-terminal-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.workspace-terminal-toggle:hover { background: color-mix(in srgb, var(--color-primary) 16%, transparent); }
|
||||
.workspace-terminal-toggle.active { color: var(--color-primary); }
|
||||
|
||||
/* Stacked layout: one column, so nothing spans rows. */
|
||||
@media (max-width: 760px) {
|
||||
.terminal-dock { grid-column: 1; }
|
||||
.left-sidebar,
|
||||
.left-sidebar-resize-handle,
|
||||
.history-resize-handle,
|
||||
.history-aside { grid-row: auto; }
|
||||
}
|
||||
|
||||
@@ -89,10 +89,10 @@
|
||||
|
||||
<header class="workspace-navigation">
|
||||
<nav class="global-navigation" aria-label={language === "de" ? "Hauptnavigation" : "Main navigation"}>
|
||||
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement}><House size={17}/><span>Dashboard</span></button>
|
||||
<button type="button" class:active={activeView === "repository"} aria-current={activeView === "repository" ? "page" : undefined} disabled={isBusy} onclick={onOpenRepositories}><Database size={17}/><span>Repositories</span></button>
|
||||
<button type="button" class:active={activeView === "review-center"} aria-current={activeView === "review-center" ? "page" : undefined} disabled={isBusy} onclick={onOpenReviewCenter}><GitPullRequest size={17}/><span>Pull Requests</span></button>
|
||||
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues}><Columns3 size={17}/><span>Issues & Boards</span></button>
|
||||
<button type="button" class:active={activeView === "management"} aria-current={activeView === "management" ? "page" : undefined} disabled={isBusy} onclick={onOpenManagement} title="Dashboard (Ctrl + 1)"><House size={17}/><span>Dashboard</span></button>
|
||||
<button type="button" class:active={activeView === "repository"} aria-current={activeView === "repository" ? "page" : undefined} disabled={isBusy} onclick={onOpenRepositories} title="Repositories (Ctrl + 2)"><Database size={17}/><span>Repositories</span></button>
|
||||
<button type="button" class:active={activeView === "review-center"} aria-current={activeView === "review-center" ? "page" : undefined} disabled={isBusy} onclick={onOpenReviewCenter} title="Pull Requests (Ctrl + 3)"><GitPullRequest size={17}/><span>Pull Requests</span></button>
|
||||
<button type="button" class:active={activeView === "issues"} aria-current={activeView === "issues" ? "page" : undefined} disabled={isBusy} onclick={onOpenIssues} title="Issues & Boards (Ctrl + 4)"><Columns3 size={17}/><span>Issues & Boards</span></button>
|
||||
</nav>
|
||||
{#if activeView === "repository"}
|
||||
<div class="repository-row">
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { X } from "@lucide/svelte";
|
||||
import { listIntegrationAssignees } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { AssignmentTarget, GitIntegrationSource, IntegrationAssignee, StoredCredential } from "../types";
|
||||
|
||||
let { source, target, de, loadCredential, value = $bindable<IntegrationAssignee[]>([]), disabled = false }: {
|
||||
source: GitIntegrationSource; target: AssignmentTarget; de: boolean;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
value?: IntegrationAssignee[]; disabled?: boolean;
|
||||
} = $props();
|
||||
let users = $state<IntegrationAssignee[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state("");
|
||||
let retry = $state(0);
|
||||
const reviewer = $derived(source.provider === "azure-devops" && target.kind === "review");
|
||||
const single = $derived(source.provider === "azure-devops" && target.kind === "issue");
|
||||
const label = $derived(reviewer ? "Reviewer" : (de ? "Zugewiesen an" : "Assignees"));
|
||||
function displayName(user: IntegrationAssignee): string {
|
||||
return [user.name, user.username].map(name => name.trim()).find(name => name && !name.includes("@")) || (de ? "Benutzer" : "User");
|
||||
}
|
||||
const options = $derived(users.filter(user => !value.some(selected => selected.id === user.id)).map(user => ({
|
||||
value: user.id, label: displayName(user),
|
||||
})));
|
||||
$effect(() => {
|
||||
const current = source;
|
||||
const context = { repository: target.repository, repositoryId: target.repositoryId, kind: target.kind, number: 0 };
|
||||
void retry;
|
||||
let cancelled = false;
|
||||
users = []; error = ""; loading = !!context.repository;
|
||||
if (context.repository) void (async () => {
|
||||
try {
|
||||
const auth = await loadCredential(integrationCredentialKey(current.provider, current.accountId));
|
||||
if (!auth?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
const result = await listIntegrationAssignees(current.provider, current.baseUrl, auth.username, auth.password, context);
|
||||
if (!cancelled) users = result;
|
||||
} catch (cause) { if (!cancelled) error = String(cause); }
|
||||
finally { if (!cancelled) loading = false; }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
});
|
||||
function add(id: string) {
|
||||
if (disabled) return;
|
||||
const user = users.find(user => user.id === id);
|
||||
if (user) value = single ? [user] : [...value, user];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="assignee-picker">
|
||||
<span class="field-label">{label}</span>
|
||||
{#if value.length}
|
||||
<ul aria-label={label}>
|
||||
{#each value as user (user.id)}
|
||||
<li><span>{displayName(user)}</span><button type="button" {disabled} aria-label={`${de ? "Entfernen" : "Remove"}: ${displayName(user)}`} onclick={() => value = value.filter(selected => selected.id !== user.id)}><X size={13}/></button></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<SelectMenu value="" {options} disabled={disabled || loading || !target.repository || !!error} searchable ariaLabel={label}
|
||||
placeholder={loading ? (de ? "Benutzer werden geladen …" : "Loading users …") : !target.repository ? (de ? "Zuerst Repository/Projekt auswählen" : "Select a repository/project first") : single && value.length ? (de ? "Benutzer wechseln …" : "Change user …") : (de ? "Benutzer auswählen …" : "Select user …")}
|
||||
searchPlaceholder={de ? "Benutzer suchen …" : "Search users …"} emptyText={de ? "Keine verfügbaren Benutzer" : "No available users"} onChange={add}/>
|
||||
{#if error}<div class="error" role="alert">{de ? "Benutzer konnten nicht geladen werden." : "Could not load users."} {error}<button type="button" {disabled} onclick={() => retry++}>{de ? "Erneut laden" : "Retry"}</button></div>
|
||||
{:else if target.repository && !loading && !users.length}<small>{de ? "Keine zuweisbaren Benutzer gefunden." : "No assignable users found."}</small>{/if}
|
||||
{#if source.provider === "azure-devops"}<small>{reviewer ? (de ? "Azure-PRs verwenden Reviewer. Auswahl aus den Projektteams." : "Azure PRs use reviewers. Select from project teams.") : (de ? "Auswahl aus den Projektteams; eine Person pro Work Item." : "Select from project teams; one person per work item.")}</small>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.assignee-picker{display:grid;gap:8px;min-width:0;font-size:12px;color:var(--color-ink)}
|
||||
.field-label{font-weight:500}ul{display:flex;flex-wrap:wrap;gap:6px;list-style:none;margin:0;padding:0}
|
||||
li{display:flex;align-items:center;gap:6px;max-width:100%;padding:4px 6px;background:var(--color-surface);border:1px solid var(--color-border)}
|
||||
li span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
button{font:inherit;color:inherit;cursor:pointer;background:var(--color-surface);border:1px solid var(--color-border);padding:4px 8px}li button{display:grid;place-items:center;border:0;padding:2px;background:transparent}
|
||||
button:disabled{opacity:.5;cursor:default}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}
|
||||
small{font-size:11px;color:var(--color-ink-dim);line-height:1.5}.error{color:var(--color-danger);overflow-wrap:anywhere}.error button{margin-top:6px;display:block}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import AssigneePicker from "./AssigneePicker.svelte";
|
||||
import { getIntegrationAssignees, setIntegrationAssignees } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { AssignmentTarget, GitIntegrationSource, IntegrationAssignee, StoredCredential } from "../types";
|
||||
|
||||
let { source, target, de, loadCredential, disabled = false, onSaved = () => {} }: {
|
||||
source: GitIntegrationSource; target: AssignmentTarget; de: boolean; disabled?: boolean;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
onSaved?: (users: IntegrationAssignee[]) => void;
|
||||
} = $props();
|
||||
let value = $state<IntegrationAssignee[]>([]);
|
||||
let original = $state<IntegrationAssignee[]>([]);
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let loaded = $state(false);
|
||||
let retry = $state(0);
|
||||
let generation = 0;
|
||||
const ids = (users: IntegrationAssignee[]) => JSON.stringify(users.map(user => user.id).sort());
|
||||
const dirty = $derived(ids(value) !== ids(original));
|
||||
async function auth(current: GitIntegrationSource) {
|
||||
const result = await loadCredential(integrationCredentialKey(current.provider, current.accountId));
|
||||
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
return result;
|
||||
}
|
||||
$effect(() => {
|
||||
const current = source;
|
||||
const context = { ...target };
|
||||
void retry;
|
||||
const requestGeneration = ++generation;
|
||||
loading = true; loaded = false; busy = false; error = ""; value = []; original = [];
|
||||
void (async () => {
|
||||
try {
|
||||
const credential = await auth(current);
|
||||
const result = await getIntegrationAssignees(current.provider, current.baseUrl, credential.username, credential.password, context);
|
||||
if (requestGeneration !== generation) return;
|
||||
original = result; value = [...result]; loaded = true;
|
||||
} catch (cause) { if (requestGeneration === generation) error = String(cause); }
|
||||
finally { if (requestGeneration === generation) loading = false; }
|
||||
})();
|
||||
return () => { generation++; };
|
||||
});
|
||||
async function save() {
|
||||
if (!loaded || busy || disabled || !dirty) return;
|
||||
const current = source, context = { ...target }, users = [...value], requestGeneration = generation;
|
||||
const savedCallback = onSaved;
|
||||
busy = true; error = "";
|
||||
try {
|
||||
const credential = await auth(current);
|
||||
const result = await setIntegrationAssignees(current.provider, current.baseUrl, credential.username, credential.password, context, users);
|
||||
savedCallback(result);
|
||||
if (requestGeneration !== generation) return;
|
||||
original = result; value = [...result];
|
||||
} catch (cause) { if (requestGeneration === generation) error = String(cause); }
|
||||
finally { if (requestGeneration === generation) busy = false; }
|
||||
}
|
||||
</script>
|
||||
<div class="assignment-editor" aria-busy={loading || busy}>
|
||||
<AssigneePicker {source} {target} {de} {loadCredential} bind:value disabled={disabled || loading || busy || !loaded}/>
|
||||
{#if loading}<small role="status">{de ? "Zuweisung wird geladen …" : "Loading assignment …"}</small>{/if}
|
||||
{#if error}<p role="alert">{error}</p><button type="button" disabled={busy} onclick={() => retry++}>{de ? "Aktuelle Zuweisung neu laden" : "Reload current assignment"}</button>{/if}
|
||||
{#if loaded && dirty}<div class="actions"><button type="button" disabled={disabled || busy} onclick={save}>{busy ? (de ? "Wird gespeichert …" : "Saving …") : (de ? "Zuweisung speichern" : "Save assignment")}</button><button type="button" disabled={disabled || busy} onclick={() => { value = [...original]; error = ""; }}>{de ? "Abbrechen" : "Cancel"}</button></div>{/if}
|
||||
</div>
|
||||
<style>
|
||||
.assignment-editor{display:grid;gap:8px;min-width:0}.actions{display:flex;flex-wrap:wrap;gap:6px}button{font:inherit;font-size:11px;padding:6px 8px;color:var(--color-ink);background:var(--color-surface);border:1px solid var(--color-border);cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}small{color:var(--color-ink-dim);font-size:11px}p{margin:0;font-size:12px;line-height:1.5;color:var(--color-danger);overflow-wrap:anywhere}
|
||||
</style>
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { CirclePlus, X } from "@lucide/svelte";
|
||||
import IssueLabelEditor from "./IssueLabelEditor.svelte";
|
||||
import AssigneePicker from "./AssigneePicker.svelte";
|
||||
import { setIntegrationAssignees, setIntegrationIssueLabels } from "../git";
|
||||
import type { IntegrationAssignee, IntegrationLabel } from "../types";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import CommentEditor from "./CommentEditor.svelte";
|
||||
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
|
||||
@@ -19,6 +24,12 @@
|
||||
let workItemType = $state("");
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let assignees = $state<IntegrationAssignee[]>([]);
|
||||
let labels = $state<IntegrationLabel[]>([]);
|
||||
let assigneesSaved = false;
|
||||
let labelsSaved = false;
|
||||
let created = $state<IntegrationIssue | null>(null);
|
||||
function finish() { if (created) onCreated(created); else onClose(); }
|
||||
let loading = $state(true);
|
||||
let typesLoading = $state(false);
|
||||
let busy = $state(false);
|
||||
@@ -55,6 +66,7 @@
|
||||
finally { if (!destroyed) loading = false; }
|
||||
}
|
||||
async function selectTarget(value: string) {
|
||||
assignees = []; labels = []; assigneesSaved = false; labelsSaved = false;
|
||||
repository = value; types = []; workItemType = ""; typeError = "";
|
||||
const generation = ++typeGeneration;
|
||||
typesLoading = azure && !!value;
|
||||
@@ -74,8 +86,19 @@
|
||||
busy = true; error = "";
|
||||
try {
|
||||
const auth = await credential();
|
||||
const issue = await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
|
||||
onCreated(issue);
|
||||
created ??= await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
|
||||
if (assignees.length && !assigneesSaved) {
|
||||
const assigned = await setIntegrationAssignees(source.provider, source.baseUrl, auth.username, auth.password,
|
||||
{ repository: created.repositoryName, number: created.number, kind: "issue" }, assignees);
|
||||
created.assignees = assigned.map(user => azure ? user.name || user.username : user.username || user.name);
|
||||
assigneesSaved = true;
|
||||
}
|
||||
if (labels.length && !labelsSaved) {
|
||||
const saved = await setIntegrationIssueLabels(source.provider, source.baseUrl, auth.username, auth.password, created.repositoryName, created.number, labels);
|
||||
created.labels = saved.map(label => label.name);
|
||||
labelsSaved = true;
|
||||
}
|
||||
onCreated(created);
|
||||
} catch (cause) { if (!destroyed) error = String(cause); }
|
||||
finally { if (!destroyed) busy = false; }
|
||||
}
|
||||
@@ -83,10 +106,10 @@
|
||||
onDestroy(() => { destroyed = true; typeGeneration++; });
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
|
||||
<dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) finish(); }}>
|
||||
<form onsubmit={submit}>
|
||||
<header class="unified-dialog-header"><span class="unified-dialog-icon" aria-hidden="true"><CirclePlus size={20}/></span><div class="unified-dialog-text"><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
|
||||
<div class="body">
|
||||
<header class="unified-dialog-header"><span class="unified-dialog-icon" aria-hidden="true"><CirclePlus size={20}/></span><div class="unified-dialog-text"><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={finish}><X size={18}/></button></header>
|
||||
<fieldset class="body" disabled={!!created}>
|
||||
<div class="field"><span>{azure ? (de ? "Projekt" : "Project") : "Repository"}</span>
|
||||
<SelectMenu value={repository} options={targets} showSelectedGroup searchable disabled={loading || busy} ariaLabel={azure ? (de ? "Projekt" : "Project") : "Repository"} placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Bitte auswählen" : "Select an option")} searchPlaceholder={de ? "Suchen …" : "Search …"} onChange={value => void selectTarget(value)}/>
|
||||
</div>
|
||||
@@ -97,25 +120,34 @@
|
||||
{#if typeError}<p class="error" role="alert">{typeError}</p><button type="button" disabled={typesLoading || busy} onclick={() => selectTarget(repository)}>{de ? "Typen erneut laden" : "Retry types"}</button>
|
||||
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
|
||||
{/if}
|
||||
<AssigneePicker {source} target={{ repository, number: 0, kind: "issue" }} {de} {loadCredential} bind:value={assignees} disabled={busy || !!created}/>
|
||||
<IssueLabelEditor {source} {repository} {de} {loadCredential} bind:value={labels} disabled={busy || !!created}/>
|
||||
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={busy} required placeholder={de ? "Was soll erledigt werden?" : "What needs to be done?"}/></label>
|
||||
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"}></textarea></label>
|
||||
{#if error}<p class="error" role="alert">{de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed."} {error}</p>{/if}
|
||||
</div>
|
||||
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
|
||||
<div class="field">
|
||||
<span>{de ? "Beschreibung" : "Description"}</span>
|
||||
<CommentEditor bind:value={description} language={de ? "de" : "en"} disabled={busy} rows={7}
|
||||
ariaLabel={de ? "Beschreibung" : "Description"}
|
||||
previewLabel={de ? "Beschreibungsvorschau" : "Description preview"}
|
||||
placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"} />
|
||||
</div>
|
||||
{#if error}<p class="error" role="alert">{created ? (de ? "Issue wurde erstellt, aber Zuweisung oder Labels konnten nicht vollständig gespeichert werden. Erneut versuchen speichert nur die ausstehenden Angaben." : "Issue created, but assignment or labels could not be fully saved. Retry saves only the remaining details.") : (de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed.")} {error}</p>{/if}
|
||||
</fieldset>
|
||||
<footer><button type="button" disabled={busy} onclick={finish}>{created ? (de ? "Fertig" : "Done") : (de ? "Abbrechen" : "Cancel")}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : created ? (de ? "Angaben erneut speichern" : "Retry saving details") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
fieldset.body {border:0;margin:0;min-width:0}
|
||||
dialog {margin:auto;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 40px);padding:0;border:1px solid var(--color-border);background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font:inherit;overflow:auto}
|
||||
dialog::backdrop {background:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent)}
|
||||
header {display:flex;align-items:center;gap:12px;padding:18px 24px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border)}
|
||||
header>div {flex:1} header>:global(svg) {color:var(--color-accent)}
|
||||
h2 {font-size:17px;margin:0} p {margin:4px 0;color:var(--color-ink-dim);font-size:12px}
|
||||
.body {display:grid;gap:18px;padding:22px 24px}.field,label {display:grid;gap:8px;min-width:0;font-size:12px}
|
||||
button,input,textarea {font:inherit;color:var(--color-ink);border:1px solid var(--color-border);background:var(--color-surface);border-radius:0}
|
||||
input,textarea {box-sizing:border-box;width:100%;padding:10px;font-size:13px}textarea {resize:vertical;line-height:1.5}
|
||||
button,input {font:inherit;color:var(--color-ink);border:1px solid var(--color-border);background:var(--color-surface);border-radius:0}
|
||||
input {box-sizing:border-box;width:100%;padding:10px;font-size:13px}
|
||||
button {padding:8px 12px;cursor:pointer;font-size:12px}button:disabled {opacity:.5;cursor:default}
|
||||
input:focus-visible,textarea:focus-visible,button:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
|
||||
input:focus-visible,button:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
|
||||
.dialog-close {display:grid;place-items:center;padding:6px;border:0;background:transparent}
|
||||
.error {color:var(--color-danger);overflow-wrap:anywhere;line-height:1.5}
|
||||
footer {display:flex;justify-content:flex-end;gap:10px;padding:16px 24px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AssigneePicker from "./AssigneePicker.svelte";
|
||||
import { setIntegrationAssignees } from "../git";
|
||||
import type { IntegrationAssignee } from "../types";
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import CommentEditor from "./CommentEditor.svelte";
|
||||
import { onMount } from "svelte";
|
||||
@@ -51,6 +54,7 @@
|
||||
});
|
||||
async function loadRepositoryBranches(repository?: GitIntegrationRepository) {
|
||||
const generation = ++branchGeneration;
|
||||
assignees = [];
|
||||
branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = "";
|
||||
branchesLoading = !!repository;
|
||||
if (!repository) return;
|
||||
@@ -85,6 +89,9 @@
|
||||
let generating = $state(false);
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let assignees = $state<IntegrationAssignee[]>([]);
|
||||
let created = $state<IntegrationReviewRequest | null>(null);
|
||||
function finish() { if (created) onCreated(created); else onClose(); }
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
@@ -137,17 +144,19 @@
|
||||
try {
|
||||
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||
const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
|
||||
onCreated(request);
|
||||
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
|
||||
created ??= await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
|
||||
if (assignees.length) await setIntegrationAssignees(source.provider, source.baseUrl, credential.username, credential.password,
|
||||
{ repository: created.repositoryName, repositoryId: created.repositoryId, number: created.number, kind: "review" }, assignees);
|
||||
onCreated(created);
|
||||
} catch (cause) { error = (created ? (de ? "PR wurde erstellt, aber die Zuweisung konnte nicht bestätigt werden. Du kannst nur die Zuweisung erneut versuchen oder mit Fertig fortfahren. " : "PR created, but assignment could not be confirmed. Retry the assignment or continue with Done. ") : "") + (cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy && !generating) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy && !generating) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
|
||||
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy && !generating) finish(); }} onclick={(event) => { if (event.target === dialog && !busy && !generating) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) finish(); } }}>
|
||||
<form onsubmit={submit}>
|
||||
<header class="unified-dialog-header"><div class="heading-icon unified-dialog-icon"><GitPullRequest size={19} /></div><div class="unified-dialog-text"><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button data-dialog-close class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={generating || busy} onclick={onClose}><X size={18}/></button></header>
|
||||
<div class="body">
|
||||
<header class="unified-dialog-header"><div class="heading-icon unified-dialog-icon"><GitPullRequest size={19} /></div><div class="unified-dialog-text"><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button data-dialog-close class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={generating || busy} onclick={finish}><X size={18}/></button></header>
|
||||
<fieldset class="body" disabled={!!created}>
|
||||
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
|
||||
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
|
||||
<SelectMenu value={repositoryId} options={repositoryOptions} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} showSelectedGroup onChange={value => repositoryId = value}>
|
||||
@@ -169,6 +178,7 @@
|
||||
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if}
|
||||
<p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
|
||||
<div class="ai-draft-action"><button type="button" disabled={generating || busy || branchesLoading || !sourceBranch || !targetBranch || sameBranch} onclick={generateDraft}>{#if generating}<LoaderCircle class="spin" size={15}/>{:else}<Sparkles size={15}/>{/if}{generating ? (de ? "Wird generiert …" : "Generating…") : (de ? "Mit KI erstellen" : "Generate with AI")}</button><small>{de ? "Erstellt Titel und Beschreibung aus dem lokalen Stand der Remote-Branches. Vorher Fetch ausführen." : "Creates a title and description from locally fetched remote branches. Fetch first."}</small></div>
|
||||
<AssigneePicker {source} target={{ repository: repositoryById(repositoryId)?.fullName ?? "", repositoryId, number: 0, kind: "review" }} {de} {loadCredential} bind:value={assignees} disabled={generating || busy || !!created}/>
|
||||
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={generating || busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
|
||||
<div class="repository-field">
|
||||
<span>{de ? "Beschreibung" : "Description"}</span>
|
||||
@@ -177,12 +187,13 @@
|
||||
previewLabel={de ? "Beschreibungsvorschau" : "Description preview"}
|
||||
placeholder={de ? "Beschreibe deine Änderungen …" : "Describe your changes…"} />
|
||||
</div>
|
||||
</div>
|
||||
<footer><button type="button" disabled={generating || busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={generating || busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer>
|
||||
</fieldset>
|
||||
<footer><button type="button" disabled={generating || busy} onclick={finish}>{created ? (de ? "Fertig" : "Done") : (de ? "Abbrechen" : "Cancel")}</button><button class="primary" type="submit" disabled={generating || busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : created ? (de ? "Zuweisung erneut versuchen" : "Retry assignment") : heading}</button></footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
fieldset.body {border:0;margin:0;min-width:0}
|
||||
.ai-draft-action{display:flex;align-items:center;gap:12px}.ai-draft-action small{color:var(--color-ink-muted);line-height:1.5}.ai-draft-action button{flex-shrink:0}
|
||||
.repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)}
|
||||
dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent);backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border-subtle)}h2{margin:0;font-size:16px;font-weight:650}p{margin:5px 0 0;color:var(--color-ink-muted)}.heading-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:color-mix(in srgb,var(--color-accent) 12%,transparent);color:var(--color-accent)}button,input{font:inherit}button{display:inline-flex;justify-content:center;align-items:center;gap:8px;border:1px solid var(--color-border-subtle);border-radius:7px;padding:9px 13px;background:var(--color-surface);color:var(--color-ink);cursor:pointer}button:disabled{opacity:.5;cursor:default}.close{margin-left:auto;border:0;padding:6px}.body{display:grid;gap:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input{box-sizing:border-box;width:100%;padding:10px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--app-bg);color:var(--color-ink);font-weight:400}input:focus{outline:2px solid var(--color-accent);outline-offset:1px}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);display:grid;grid-template-columns:minmax(0,1fr) 16px minmax(0,1fr);gap:12px;align-items:end}.branches>:global(svg){margin-bottom:12px;color:var(--color-accent)}.hint{margin-top:-8px;font-size:11px;line-height:1.5}.error,.validation{color:var(--color-danger);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent-solid);border-color:var(--color-accent-solid);color:var(--color-on-accent)}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="file-history-dialog-icon unified-dialog-icon" aria-hidden="true"><History size={19} /></div>
|
||||
<div class="file-history-dialog-heading unified-dialog-text">
|
||||
<span class="eyebrow">File history</span>
|
||||
<h2 id="file-history-dialog-title">{fileName(filePath)}</h2>
|
||||
<h2 id="file-history-dialog-title" title={fileName(filePath)}>{fileName(filePath)}</h2>
|
||||
<span class="file-history-dialog-path" title={filePath}>{filePath}</span>
|
||||
</div>
|
||||
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label="Close file history">
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
Search,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
@@ -249,7 +248,10 @@
|
||||
summary: "Die Hilfe ist überall erreichbar. Dialoge lassen sich konsistent schließen und Suchfelder direkt fokussieren.",
|
||||
commands: [
|
||||
{ command: "Ctrl + /", description: "Diese Hilfe öffnen" },
|
||||
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen" },
|
||||
{ command: "Ctrl + 1 … 4", description: "Zwischen Dashboard, Repositories, Pull Requests und Issues & Boards wechseln" },
|
||||
{ command: "Ctrl + ^", description: "Terminal im Repository ein- und ausblenden" },
|
||||
{ command: "Ctrl + A", description: "Alle Dateien in der aktiven Statusliste („Ungestaged“ oder „Gestaged“) auswählen" },
|
||||
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen – in der Statusliste die aktuelle Auswahl aufheben" },
|
||||
{ command: "Tab / Shift + Tab", description: "Zwischen Bedienelementen wechseln" },
|
||||
{ command: "Enter / Leertaste", description: "Fokussierte Aktion ausführen" },
|
||||
],
|
||||
@@ -459,7 +461,10 @@
|
||||
summary: "Help is available everywhere. Dialogs close consistently and search fields receive focus automatically.",
|
||||
commands: [
|
||||
{ command: "Ctrl + /", description: "Open this help center" },
|
||||
{ command: "Escape", description: "Close the current overlay or dialog" },
|
||||
{ command: "Ctrl + 1 … 4", description: "Switch between Dashboard, Repositories, Pull Requests and Issues & Boards" },
|
||||
{ command: "Ctrl + `", description: "Show or hide the terminal inside the repository view" },
|
||||
{ command: "Ctrl + A", description: "Select every file in the focused status list (Unstaged or Staged)" },
|
||||
{ command: "Escape", description: "Close the current overlay or dialog – in the status list, clear the current selection" },
|
||||
{ command: "Tab / Shift + Tab", description: "Move between controls" },
|
||||
{ command: "Enter / Space", description: "Activate the focused control" },
|
||||
],
|
||||
@@ -1543,321 +1548,7 @@
|
||||
},
|
||||
);
|
||||
|
||||
deCategories.splice(1, 0, {
|
||||
id: "changelog",
|
||||
label: "Neu in Gitty",
|
||||
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-8",
|
||||
title: "Version 2026.8.8",
|
||||
summary: "Dieses Release verbindet Gitty mit den wichtigsten Git-Hosting-Diensten und macht das Klonen aus deinen eigenen Repository-Listen deutlich schneller.",
|
||||
steps: [
|
||||
"Neue Integrationen für GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps und Gitea lassen sich zentral in den Einstellungen verwalten. Personal Access Tokens werden sicher im Schlüsselbund des Betriebssystems gespeichert.",
|
||||
"Azure DevOps unterstützt mehrere Organisationen mit jeweils eigenem Anzeigenamen, eigener Organisations-URL, eigenem Benutzernamen und Token.",
|
||||
"Der Clone-Dialog besitzt einen Integrationen-Reiter. Er lädt alle zugänglichen Repositories des gewählten Kontos, sortiert sie alphabetisch und unterstützt Suche, Aktualisieren und direktes Klonen mit den gespeicherten Zugangsdaten.",
|
||||
"Die Repository-Tab-Leiste ist kompakter und näher an klassischen Git-Clients gestaltet. Das Schließen-X bleibt sichtbar und wird nur beim Überfahren rot.",
|
||||
"Eine schmale eigene Scrollbar im Repository-Browser überdeckt weder Namen noch Metadaten und wird beim Überfahren nur leicht breiter.",
|
||||
"Repository-Loading- und Status-Flächen reagieren konsistenter auf das aktive Theme und sind kompakter und kontrastreicher.",
|
||||
"Das Entfernen eines nicht vorhandenen Upstreams ist jetzt ein sicherer No-op und löst keinen fatalen Git-Fehler mehr aus.",
|
||||
],
|
||||
note: "Die Integrationen verwenden HTTPS und Personal Access Tokens. Welche Repositories sichtbar sind, richtet sich nach den Berechtigungen des jeweiligen Tokens und Kontos.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-7",
|
||||
title: "Version 2026.8.7",
|
||||
summary: "Dieses Release erweitert die Darstellungseinstellungen und macht die Branch-Auswahl bei vielen lokalen und entfernten Branches übersichtlicher.",
|
||||
steps: [
|
||||
"In den Einstellungen stehen die Darstellungsstile Aktuell, Klassisch und Eigene zur Verfügung. Beim eigenen Stil lässt sich eine individuelle Farbpalette konfigurieren und dauerhaft speichern.",
|
||||
"Ein vollständiges helles Theme ergänzt die überarbeitete dunkle Darstellung. Farben, Flächen, Bedienelemente und Fokusrahmen besitzen klarere Grenzen und konsistentere Kontraste.",
|
||||
"Der Dialog zur Branch-Sichtbarkeit trennt lokale und entfernte Branches in auf- und zuklappbare Gruppen und zeigt für jede Gruppe die Anzahl der ausgewählten Branches.",
|
||||
"Beim Öffnen ist die lokale Gruppe ausgeklappt und die Remote-Gruppe zunächst geschlossen, damit häufig verwendete Branches schneller erreichbar sind.",
|
||||
"Die Branch-Auswahl passt sich kleineren Fenstergrößen besser an und folgt dem visuellen Stil der übrigen Gitty-Dialoge.",
|
||||
],
|
||||
note: "Darstellungsstil und eigene Farben werden lokal gespeichert und beim nächsten Start automatisch wieder angewendet.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-6",
|
||||
title: "Version 2026.8.6",
|
||||
summary: "Dieses Wartungsrelease stabilisiert Git-LFS-Workflows vom Tracking über Clone und Pull bis zum Push großer Dateien nach Azure DevOps.",
|
||||
steps: [
|
||||
"LFS-Muster aus der .gitattributes im Repository-Stamm bleiben im LFS-Dialog sichtbar, auch wenn die Datei noch ungetrackt ist oder zuvor durch eine Ignore-Regel ausgeblendet wurde.",
|
||||
"Beim Aktivieren von Git LFS und beim Hinzufügen eines Tracking-Musters stellt Gitty sicher, dass die .gitattributes nicht ignoriert wird. Nur wenn nötig, wird die gezielte Ausnahme !/.gitattributes am Ende der .gitignore ergänzt.",
|
||||
"Clone und Pull verwenden für erkannte LFS-Repositories denselben Remote und dieselben Zugangsdaten auch zum Laden der LFS-Objekte. Neue Klone aktivieren Filter und Pre-Push-Hook automatisch.",
|
||||
"Wenn Azure DevOps einen großen LFS-Upload über HTTP/2 mit HTTP 413 ablehnt, wiederholt Gitty den Push einmal mit einer nur für diesen Befehl geltenden HTTP/1.1-Konfiguration. Globale und Repository-Einstellungen bleiben unverändert.",
|
||||
"LFS-, Größen- und andere allgemeine Push-Fehler werden nicht mehr als Non-Fast-Forward verwechselt. Der unnötige Ablauf „Pull vor Push“ mit anschließendem „Push after pull“ erscheint nur noch bei einem tatsächlichen veralteten lokalen Branch.",
|
||||
"Der Tauri-Debug-Launcher entfernt ausschließlich bekannte nicht routende Test-Proxys aus dem Gitty-Unterprozess. Echte Benutzer- und Unternehmens-Proxys bleiben erhalten, sodass Remote- und LFS-Abläufe auch im Debug-Build testbar sind.",
|
||||
],
|
||||
note: "Die HTTP/1.1-Wiederholung greift nur nach einem LFS-Fehler 413. Änderungen an .gitattributes und .gitignore bleiben normale Repository-Änderungen und müssen committed und gepusht werden.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-5",
|
||||
title: "Version 2026.8.5",
|
||||
summary: "Dieses Release integriert Git LFS direkt in Gitty und macht den Staging-Bereich bei vielen geänderten Dateien deutlich übersichtlicher.",
|
||||
steps: [
|
||||
"Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.",
|
||||
"LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.",
|
||||
"Nach einem erfolgreichen Clone oder Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Neue Klone aktivieren LFS außerdem lokal, sodass kein zweiter manueller Pull erforderlich ist.",
|
||||
"Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.",
|
||||
"Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.",
|
||||
"Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Neue und ungetrackte Inhalte können im Changes-Bereich und im File Explorer als exakte Datei, kompletter Ordner oder Dateiendungs-Muster in die .gitignore übernommen werden; die Ordneroption erscheint nur beim Rechtsklick auf einen Ordner. Bereits getrackte Dateien und Ordner lassen sich mit „Stop tracking“ aus dem Git-Index entfernen, bleiben aber auf der Festplatte erhalten. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.",
|
||||
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Mit clone REMOTE ZIEL, --clone REMOTE ZIEL oder --clone=REMOTE ZIEL klont Gitty ein Remote-Repository in den exakt angegebenen lokalen Ordner und öffnet es anschließend. Relative Pfade werden gegen das aktuelle Arbeitsverzeichnis aufgelöst; der Aufruf wird auch an eine bereits laufende Gitty-Instanz weitergegeben.",
|
||||
"Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.",
|
||||
],
|
||||
note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-4",
|
||||
title: "Version 2026.8.4",
|
||||
summary: "Dieses Release vereinfacht die Verwaltung zusammengehöriger Branches und sorgt für eine einheitlichere, klarere Oberfläche.",
|
||||
steps: [
|
||||
"Lokale und verschachtelte Remote-Branch-Ordner lassen sich über ihr Kontextmenü gesammelt löschen. Der oberste Remote-Ordner wie origin ist geschützt. Der aktuell ausgecheckte Branch bleibt erhalten und einzelne Fehler werden nach Abschluss verständlich aufgeführt.",
|
||||
"Der Compare-Auswahldialog ist vollständig auf Deutsch verfügbar und orientiert sich bei Feldern, Gruppen, Typografie und Dialogflächen am Styling der externen Tools.",
|
||||
"Die Schließen-Schaltflächen der Repository-Tabs sind quadratisch und haben ausgewogenere Abstände sowie deutlichere Hover- und Tastaturfokus-Zustände.",
|
||||
],
|
||||
note: "Der oberste Remote-Ordner wie origin kann nicht gesammelt gelöscht werden. Seine Unterordner können weiterhin gezielt verwaltet werden.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-3",
|
||||
title: "Version 2026.8.3",
|
||||
summary: "Dieses Release verbindet Gitty enger mit deinen Entwicklungswerkzeugen und macht Branches, Historie und Vergleiche deutlich leistungsfähiger.",
|
||||
steps: [
|
||||
"Externe Tools: Editor, Diff-Tool, Merge-Tool, Terminal und Dateimanager lassen sich in den neu gestalteten Einstellungen erkennen, auswählen und individuell konfigurieren.",
|
||||
"VS Code, JetBrains-IDEs, Beyond Compare und weitere unterstützte Programme werden in einem eigenen Fenster geöffnet; dokumentierte Ergebnis-Codes werden beim Schließen korrekt behandelt.",
|
||||
"Git Notes: Commits erhalten lokale Notizen, ohne ihre Historie umzuschreiben. Notizen lassen sich bearbeiten, löschen sowie gezielt vom Remote abrufen oder dorthin übertragen.",
|
||||
"Vollständiger Branch-Vergleich: Lokale Branches, Remote-Branches und Commits können direkt ausgewählt und dateiweise im Side-by-Side-Diff verglichen werden.",
|
||||
"Remote-Branches lassen sich im Kontextmenü sicher umbenennen. Gitty schützt dabei vorhandene Ziel-Branches und zwischenzeitlich geänderte Remote-Stände.",
|
||||
"Der überarbeitete Commit-Graph zeigt Branches kompakter, reduziert überladene Commit-Zeilen und blendet zusätzliche Flag-Details beim Darüberfahren ein.",
|
||||
"Noch nicht veröffentlichte Branches sind als „Nur lokal“ erkennbar – in der Werkzeugleiste, Repository-Übersicht, Statuszeile und direkt an der Branch-Flag. Die erste Push-Aktion heißt passend „Veröffentlichen“.",
|
||||
"Branch-Sichtbarkeit, feinere Graph-Verbindungen und ein kleineres Mindestmaß des Verlaufsbereichs verbessern die Übersicht bei großen Repositories.",
|
||||
"Die neue Befehlspalette öffnet Aktionen, Dateien und Commits schneller; asynchrone Git-Befehle halten Gitty auch bei langsameren Operationen reaktionsfähig.",
|
||||
],
|
||||
note: "Der Branch-Vergleich zeigt die vollständig festgeschriebenen Zustände der beiden Branch-Spitzen. Nicht commitete Änderungen im Arbeitsverzeichnis sind nicht enthalten.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-2",
|
||||
title: "Version 2026.8.2",
|
||||
summary: "Dieses Release stabilisiert die Darstellung komplexer Verläufe und verbessert die Veröffentlichung neuer Gitty-Versionen.",
|
||||
steps: [
|
||||
"Branch-Farben bleiben über Eltern-Lanes hinweg stabil, sodass sich Linien in längeren und verzweigten Historien leichter verfolgen lassen.",
|
||||
"Release-Artefakte werden automatisch und ohne doppelte Dateien an das passende Gitea-Release angehängt.",
|
||||
"Beim Beenden der Anwendung wird die Telemetrie zuverlässiger abgeschlossen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-1",
|
||||
title: "Version 2026.8.1",
|
||||
summary: "Dieses Release macht große Commit-Verläufe und die Historie einzelner Dateien leichter zugänglich und verbessert die Paketverteilung.",
|
||||
steps: [
|
||||
"Die Commit-Historie lädt ältere Einträge seitenweise nach und ist nicht mehr auf die erste Ergebnismenge begrenzt.",
|
||||
"Die Dateihistorie öffnet sich aus dem Explorer-Kontextmenü in einem eigenen, größeren Dialog statt in einem dauerhaft belegten Seitenbereich.",
|
||||
"Dialoge reagieren konsistenter auf die Escape-Taste.",
|
||||
"Windows- und Ubuntu-Releases sowie der AUR-Paketablauf wurden erweitert und robuster gemacht.",
|
||||
"Die Arch-Linux-Anleitung verwendet jetzt das AUR-Paket gitty-desktop; SSH-Einrichtung, Zeitlimits und Wiederholungsversuche wurden verbessert.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-22",
|
||||
title: "Version 2026.07.22",
|
||||
summary: "Dieses Release erweitert Gitty um eine AI-gestützte Aufteilung gestagter Änderungen in logisch getrennte Commits.",
|
||||
steps: [
|
||||
"AI-Commit-Aufteilung: Der gestagte Diff wird analysiert und als geordneter Plan aus mehreren logisch zusammengehörenden Commits vorgeschlagen.",
|
||||
"Für jede Gruppe wird automatisch eine editierbare Conventional-Commit-Nachricht erzeugt.",
|
||||
"Dateien können vor dem Commit zwischen den vorgeschlagenen Gruppen verschoben werden.",
|
||||
"Mit „Commit all“ werden alle bestätigten Gruppen sicher und der Reihe nach committed.",
|
||||
"Der Dialog erklärt leere Gruppen oder fehlende Nachrichten und schützt vor einem zwischenzeitlich veränderten Staging-Bereich.",
|
||||
"„Commit all“ reagiert wieder zuverlässig und bricht nicht mehr beim Kopieren des reaktiven Dialogzustands ab.",
|
||||
"Ein geschlossenes aktives Repository kann nicht mehr durch einen verspäteten Status- oder Fetch-Request erneut geöffnet werden.",
|
||||
],
|
||||
note: "Die AI-Commit-Aufteilung unterstützt OpenAI, Anthropic und eigene OpenAI-kompatible Endpunkte. In Paketdateien erscheint diese Version als 2026.7.22.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-21",
|
||||
title: "Version 2026.07.21",
|
||||
summary: "Dieses Release bündelt paralleles Arbeiten mit Worktrees, präzisere Commits und die neue Arch-Linux-Verteilung.",
|
||||
steps: [
|
||||
"Worktree-Verwaltung: zusätzliche Arbeitsordner erstellen, öffnen, verschieben, sperren, entsperren, reparieren, entfernen und veraltete Registrierungen aufräumen.",
|
||||
"Worktrees sind direkt über den neuen Reiter unter Tags erreichbar; Branches lassen sich außerdem aus ihrem Kontextmenü in einem Worktree öffnen.",
|
||||
"Zeilenweises Staging: einzelne Ergänzungen und Löschungen auswählen, per Shift-Klick Bereiche markieren sowie ausgewählte Zeilen stagen, unstagen oder verwerfen.",
|
||||
"Sicherere Dialoge: verständlichere Branch-Löschabfrage und weichgezeichneter Hintergrund bei geöffneten Dialogen.",
|
||||
"Arch-Linux-Pakete: automatisierter Build aus dem PKGBUILD, Veröffentlichung von .pkg.tar.zst und Repository-Datenbank für Pacman auf dem CDN.",
|
||||
"Robustere Remote-Aktionen, zentrale Fehlermeldungen und strukturierte, datensparsame Telemetrie.",
|
||||
"Erweiterte zweisprachige Hilfe mit Worktree-, Pacman- und Line-Staging-Anleitungen.",
|
||||
],
|
||||
note: "In Paketdateien kann dieselbe Version als 2026.7.21 erscheinen, weil Paketmanager numerische Versionssegmente ohne führende Null verwenden.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-7-20",
|
||||
title: "Version 2026.7.20",
|
||||
summary: "Der letzte veröffentlichte Stand konzentrierte sich auf produktiveres Arbeiten, bessere Orientierung und einen stabileren Paket-Build.",
|
||||
steps: [
|
||||
"Vor dem Commit kann eine AI-gestützte Codeprüfung den Staged-Diff analysieren.",
|
||||
"Automatische Aktualisierung hält Repository-Status und Arbeitsbereich auf Wunsch aktuell.",
|
||||
"Repository-Aktionsleiste, Statusdarstellung, Tabs und Diff-Ansicht wurden übersichtlicher gestaltet.",
|
||||
"Die integrierte Hilfe wurde um ausführliche deutsche Git-Dokumentation ergänzt.",
|
||||
"PKGBUILD und Build-Skripte wurden für die Arch-Linux-Verteilung vorbereitet.",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
enCategories.splice(1, 0, {
|
||||
id: "changelog",
|
||||
label: "What's new",
|
||||
description: "Changes since the latest published version and notable additions from earlier releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-8",
|
||||
title: "Version 2026.8.8",
|
||||
summary: "This release connects Gitty to the major Git hosting services and makes cloning from your own repository lists substantially faster.",
|
||||
steps: [
|
||||
"New integrations for GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and Gitea can be managed centrally in Settings. Personal access tokens are stored securely in the operating system keychain.",
|
||||
"Azure DevOps supports multiple organizations, each with its own display name, organization URL, username, and token.",
|
||||
"The Clone dialog has an Integrations tab. It loads every repository accessible to the selected account, sorts the list alphabetically, and supports search, refresh, and direct cloning with stored credentials.",
|
||||
"The repository tab bar is more compact and closer to familiar Git clients. Its close button remains visible and turns red only while hovered.",
|
||||
"A narrow custom scrollbar in the repository browser no longer covers names or metadata and grows only slightly on hover.",
|
||||
"Repository loading and status surfaces respond more consistently to the active theme with improved contrast and a more compact presentation.",
|
||||
"Clearing a missing upstream is now a safe no-op instead of producing a fatal Git error.",
|
||||
],
|
||||
note: "Integrations use HTTPS and personal access tokens. The repositories shown depend on the permissions granted to the selected account and token.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-7",
|
||||
title: "Version 2026.8.7",
|
||||
summary: "This release expands appearance settings and makes branch selection easier to navigate in repositories with many local and remote branches.",
|
||||
steps: [
|
||||
"Settings now provide Modern, Classic, and Custom appearance styles. Custom mode supports an individual color palette that is persisted across restarts.",
|
||||
"A complete light theme complements the refreshed dark appearance. Colors, surfaces, controls, and focus outlines have clearer boundaries and more consistent contrast.",
|
||||
"The branch visibility dialog separates local and remote branches into collapsible groups and displays the number of selected branches for each group.",
|
||||
"The local group opens by default while the remote group starts collapsed, keeping frequently used branches quicker to reach.",
|
||||
"The branch selector responds better to smaller window sizes and follows the visual language of the other Gitty dialogs.",
|
||||
],
|
||||
note: "The selected appearance style and custom colors are stored locally and restored automatically on the next start.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-6",
|
||||
title: "Version 2026.8.6",
|
||||
summary: "This maintenance release stabilizes Git LFS workflows from tracking through clone and pull to pushing large files to Azure DevOps.",
|
||||
steps: [
|
||||
"LFS patterns from the root .gitattributes remain visible in the LFS dialog even while the file is untracked or was previously hidden by an ignore rule.",
|
||||
"When Git LFS is activated or a tracking pattern is added, Gitty ensures that .gitattributes is not ignored. Only when required, the scoped !/.gitattributes exception is appended to .gitignore.",
|
||||
"Clone and pull use the same remote and credentials to download LFS objects for detected LFS repositories. Fresh clones also activate the filters and pre-push hook automatically.",
|
||||
"When Azure DevOps rejects a large LFS upload over HTTP/2 with HTTP 413, Gitty retries the push once with an HTTP/1.1 setting scoped to that command. Global and repository settings remain unchanged.",
|
||||
"LFS, size, and other generic push failures are no longer mistaken for non-fast-forward rejections. The Pull before push and Push after pull flow is now offered only when the local branch is genuinely behind its remote.",
|
||||
"The Tauri debug launcher removes only known non-routing test proxies from the Gitty child process. Real user and company proxies are preserved, keeping remote and LFS workflows testable in debug builds.",
|
||||
],
|
||||
note: "The HTTP/1.1 retry runs only after an LFS HTTP 413 failure. Changes to .gitattributes and .gitignore remain ordinary repository changes that must be committed and pushed.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-5",
|
||||
title: "Version 2026.8.5",
|
||||
summary: "This release integrates Git LFS directly into Gitty and makes the staging area much easier to navigate when many files have changed.",
|
||||
steps: [
|
||||
"Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.",
|
||||
"LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.",
|
||||
"After a successful clone or pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. Fresh clones also activate LFS locally, so a second manual pull is no longer required.",
|
||||
"Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.",
|
||||
"The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.",
|
||||
"The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. In Changes and the File Explorer, new and untracked items can be added to .gitignore as an exact file, a complete folder, or an extension-wide pattern; the folder option only appears for folder selections. Tracked files and folders can be removed from the Git index with Stop tracking while remaining on disk. The selected name, parent path, and affected file count are now clearly separated.",
|
||||
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. With clone REMOTE TARGET, --clone REMOTE TARGET, or --clone=REMOTE TARGET, Gitty clones a remote into the exact local folder and opens it afterward. Relative paths are resolved against the current working directory, and requests are forwarded to an already-running Gitty instance.",
|
||||
"Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.",
|
||||
],
|
||||
note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-4",
|
||||
title: "Version 2026.8.4",
|
||||
summary: "This release simplifies managing related branches and makes the interface more consistent and easier to read.",
|
||||
steps: [
|
||||
"Local and nested remote branch folders can be deleted in one action from their context menu. The top-level remote folder such as origin is protected. The currently checked-out branch is kept, and individual failures are summarized after processing.",
|
||||
"The Compare selector is fully localized in German and now follows the external-tool selectors for fields, groups, typography, and dialog surfaces.",
|
||||
"Repository-tab close buttons are square and have more balanced spacing and clearer hover and keyboard-focus states.",
|
||||
],
|
||||
note: "The top-level remote folder such as origin cannot be deleted in bulk. Its nested folders can still be managed selectively.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-3",
|
||||
title: "Version 2026.8.3",
|
||||
summary: "This release connects Gitty more closely with your development tools and makes branches, history, and comparisons substantially more capable.",
|
||||
steps: [
|
||||
"External tools: editors, diff tools, merge tools, terminals, and file managers can be detected, selected, and customized in the redesigned settings.",
|
||||
"VS Code, JetBrains IDEs, Beyond Compare, and other supported applications open in a separate window; documented result codes are handled correctly when they close.",
|
||||
"Git Notes: attach local notes to commits without rewriting history. Notes can be edited, deleted, fetched from a remote, or pushed explicitly.",
|
||||
"Complete branch comparison: choose local branches, remote branches, or commits and inspect every changed file in a side-by-side diff.",
|
||||
"Remote branches can be renamed safely from the context menu. Gitty protects existing destination branches and remote branches that changed after the last fetch.",
|
||||
"The redesigned commit graph presents branches more compactly, reduces crowded commit rows, and reveals additional flag details on hover.",
|
||||
"Unpublished branches are clearly marked as Local only in the toolbar, repository summary, status bar, and on the graph flag. Their first push is labeled Publish.",
|
||||
"Branch visibility controls, refined graph connectors, and a smaller minimum history width improve navigation in large repositories.",
|
||||
"The new command palette opens actions, files, and commits faster; asynchronous Git commands keep Gitty responsive during slower operations.",
|
||||
],
|
||||
note: "Branch comparison uses the fully committed state at each branch tip. Uncommitted working-tree changes are not included.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-2",
|
||||
title: "Version 2026.8.2",
|
||||
summary: "This release stabilizes complex history rendering and improves publication of new Gitty versions.",
|
||||
steps: [
|
||||
"Branch colors remain stable across parent lanes, making longer and branching histories easier to follow.",
|
||||
"Release artifacts are attached to the matching Gitea release automatically without uploading duplicates.",
|
||||
"Telemetry cleanup completes more reliably while the application is shutting down.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-1",
|
||||
title: "Version 2026.8.1",
|
||||
summary: "This release makes large commit histories and individual file histories easier to access and improves package distribution.",
|
||||
steps: [
|
||||
"Commit history loads older entries page by page instead of stopping after the initial result set.",
|
||||
"File history opens from the explorer context menu in a dedicated larger dialog instead of occupying a permanent workspace panel.",
|
||||
"Dialogs respond more consistently to the Escape key.",
|
||||
"Windows and Ubuntu publishing plus the AUR package workflow were expanded and made more robust.",
|
||||
"The Arch Linux guide now uses the gitty-desktop AUR package; SSH setup, timeouts, and retry handling were improved.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-22",
|
||||
title: "Version 2026.07.22",
|
||||
summary: "This release adds AI-assisted splitting of staged changes into separate logical commits.",
|
||||
steps: [
|
||||
"AI commit splitting analyzes the staged diff and proposes an ordered plan of logically related commits.",
|
||||
"Every group receives an automatically generated, editable Conventional Commit message.",
|
||||
"Files can be moved between proposed groups before committing.",
|
||||
"Commit all safely creates every accepted group in sequence.",
|
||||
"The dialog explains empty groups or missing messages and protects against a staging area that changed after planning.",
|
||||
"Commit all now responds reliably instead of failing while copying reactive dialog state.",
|
||||
"Closing the active repository no longer lets a delayed status or fetch request reopen the closed tab.",
|
||||
],
|
||||
note: "AI commit splitting supports OpenAI, Anthropic, and custom OpenAI-compatible endpoints. Package metadata represents this version as 2026.7.22.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-07-21",
|
||||
title: "Version 2026.07.21",
|
||||
summary: "This release combines parallel worktree workflows, more precise commits, and the new Arch Linux distribution.",
|
||||
steps: [
|
||||
"Worktree management: create, open, move, lock, unlock, repair, remove, and prune additional working folders.",
|
||||
"Worktrees are available from the new entry below Tags; branches can also be opened in a worktree from their context menu.",
|
||||
"Line-level staging: select additions and deletions, Shift-click ranges, and stage, unstage, or discard selected lines.",
|
||||
"Safer dialogs: a clearer branch deletion confirmation and a blurred background while dialogs are open.",
|
||||
"Arch Linux packages: automated PKGBUILD builds plus publication of .pkg.tar.zst and the Pacman repository database to the CDN.",
|
||||
"More robust remote operations, centralized error messages, and structured privacy-conscious telemetry.",
|
||||
"Expanded bilingual help for worktrees, Pacman installation, and line-level staging.",
|
||||
],
|
||||
note: "Package metadata may represent the same release as 2026.7.21 because package managers use numeric version segments without leading zeroes.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-7-20",
|
||||
title: "Version 2026.7.20",
|
||||
summary: "The latest published version focused on productivity, clearer navigation, and a more stable package build.",
|
||||
steps: [
|
||||
"AI-assisted pre-commit review can analyze the staged diff before committing.",
|
||||
"Optional automatic refresh keeps repository status and the workspace current.",
|
||||
"The repository action bar, status presentation, tabs, and diff view became easier to scan.",
|
||||
"The built-in help gained comprehensive German Git documentation.",
|
||||
"PKGBUILD and build scripts prepared the Arch Linux distribution workflow.",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let { language = "en", onClose = () => {} }: Props = $props();
|
||||
const isGerman = $derived(language === "de");
|
||||
@@ -1963,7 +1654,6 @@
|
||||
>
|
||||
<span class="help-nav-icon">
|
||||
{#if category.id === "start"}<Home size={17} aria-hidden="true" />
|
||||
{:else if category.id === "changelog"}<Sparkles size={17} aria-hidden="true" />
|
||||
{:else if category.id === "app"}<BookOpen size={17} aria-hidden="true" />
|
||||
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
import "../issueWorkspace.css";
|
||||
import CreateIssueDialog from "./CreateIssueDialog.svelte";
|
||||
import IssueComments from "./IssueComments.svelte";
|
||||
import IssueLabelEditor from "./IssueLabelEditor.svelte";
|
||||
import IssueLabels from "./IssueLabels.svelte";
|
||||
import AssignmentEditor from "./AssignmentEditor.svelte";
|
||||
import IssueAssignees from "./IssueAssignees.svelte";
|
||||
import { issueTone, issueStateLabel } from "../issuePresentation";
|
||||
import IntegrationBoardView from "./IntegrationBoardView.svelte";
|
||||
@@ -330,8 +332,31 @@
|
||||
{/if}
|
||||
{#if actionError}<p class="comment-error" role="alert">{actionError}</p>{/if}
|
||||
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openIssue(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if}
|
||||
<section><h3>{de ? "Zugewiesen" : "Assignees"}</h3><IssueAssignees names={selected.assignees} /></section>
|
||||
<section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section>
|
||||
<section>
|
||||
{#key `${sourceKey}:${selected.id}`}
|
||||
{@const issueId = selected.id}
|
||||
{@const assignmentSourceKey = sourceKey}
|
||||
{@const assignmentProvider = source.provider}
|
||||
<AssignmentEditor {source} target={{ repository: selected.repositoryName, number: selected.number, kind: "issue" }} {de} {loadCredential} disabled={!!closingId || loading} onSaved={users => {
|
||||
const assignees = users.map(user => assignmentProvider === "azure-devops" ? user.name || user.username : user.username || user.name);
|
||||
const saved = cache.get(assignmentSourceKey);
|
||||
if (saved) cache.set(assignmentSourceKey, { ...saved, issues: saved.issues.map(item => item.id === issueId ? { ...item, assignees } : item) });
|
||||
if (sourceKey === assignmentSourceKey) issues = issues.map(item => item.id === issueId ? { ...item, assignees } : item);
|
||||
}}/>
|
||||
{/key}
|
||||
</section>
|
||||
<section>
|
||||
{#key `${sourceKey}:${selected.id}`}
|
||||
{@const issueId = selected.id}
|
||||
{@const labelSourceKey = sourceKey}
|
||||
<IssueLabelEditor {source} repository={selected.repositoryName} number={selected.number} {de} {loadCredential} disabled={!!closingId || loading} onSaved={savedLabels => {
|
||||
const labels = savedLabels.map(label => label.name);
|
||||
const saved = cache.get(labelSourceKey);
|
||||
if (saved) cache.set(labelSourceKey, { ...saved, issues: saved.issues.map(item => item.id === issueId ? { ...item, labels } : item) });
|
||||
if (sourceKey === labelSourceKey) issues = issues.map(item => item.id === issueId ? { ...item, labels } : item);
|
||||
}}/>
|
||||
{/key}
|
||||
</section>
|
||||
<section><h3>Repository</h3><strong class="issue-detail-repo"><FolderGit2 size={15} />{selected.repositoryName}</strong></section>
|
||||
{#if selected.updatedAt}<section><h3>{de ? "Aktualisiert" : "Updated"}</h3><small>{new Date(selected.updatedAt).toLocaleString(de ? "de-DE" : "en-US")}</small></section>{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import SelectMenu from "./SelectMenu.svelte";
|
||||
import { X } from "@lucide/svelte";
|
||||
import { listIntegrationLabels, getIntegrationIssueLabels, setIntegrationIssueLabels } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, IntegrationLabel, StoredCredential } from "../types";
|
||||
|
||||
let { source, repository, number = 0, de, loadCredential, value = $bindable<IntegrationLabel[]>([]), disabled = false, onSaved = () => {} }: {
|
||||
source: GitIntegrationSource; repository: string; number?: number; de: boolean;
|
||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
value?: IntegrationLabel[]; disabled?: boolean; onSaved?: (labels: IntegrationLabel[]) => void;
|
||||
} = $props();
|
||||
let catalog = $state<IntegrationLabel[]>([]);
|
||||
let original = $state<IntegrationLabel[]>([]);
|
||||
let loading = $state(false);
|
||||
let loaded = $state(false);
|
||||
let busy = $state(false);
|
||||
let catalogError = $state("");
|
||||
let error = $state("");
|
||||
let retry = $state(0);
|
||||
let generation = 0;
|
||||
const heading = $derived(source.provider === "azure-devops" ? "Tags" : "Labels");
|
||||
const names = (labels: IntegrationLabel[]) => JSON.stringify(labels.map(label => label.name).sort());
|
||||
const dirty = $derived(names(value) !== names(original));
|
||||
const options = $derived(catalog.filter(label => !value.some(selected => selected.name === label.name)).map(label => ({value:label.id,label:label.name})));
|
||||
function colorFor(label?: IntegrationLabel): string {
|
||||
const color = label?.color || catalog.find(item => item.name === label?.name)?.color || "";
|
||||
return /^#[0-9a-f]{6}$/i.test(color) ? color : "var(--color-ink-dim)";
|
||||
}
|
||||
async function auth(current: GitIntegrationSource) {
|
||||
const result = await loadCredential(integrationCredentialKey(current.provider, current.accountId));
|
||||
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
return result;
|
||||
}
|
||||
$effect(() => {
|
||||
const current = source, repo = repository, issue = number;
|
||||
void retry;
|
||||
const currentGeneration = ++generation;
|
||||
catalog = []; original = []; catalogError = ""; error = ""; busy = false;
|
||||
loading = !!repo; loaded = !issue;
|
||||
if (issue) value = [];
|
||||
if (repo) void (async () => {
|
||||
try {
|
||||
const credential = await auth(current);
|
||||
const [available, selected] = await Promise.allSettled([
|
||||
listIntegrationLabels(current.provider, current.baseUrl, credential.username, credential.password, repo),
|
||||
issue ? getIntegrationIssueLabels(current.provider, current.baseUrl, credential.username, credential.password, repo, issue) : Promise.resolve(null),
|
||||
]);
|
||||
if (currentGeneration !== generation) return;
|
||||
if (available.status === "fulfilled") catalog = available.value;
|
||||
else catalogError = String(available.reason);
|
||||
if (selected.status === "fulfilled") {
|
||||
if (selected.value) { original = selected.value; value = [...selected.value]; }
|
||||
loaded = true;
|
||||
} else error = String(selected.reason);
|
||||
} catch (cause) { if (currentGeneration === generation) catalogError = String(cause); }
|
||||
finally { if (currentGeneration === generation) loading = false; }
|
||||
})();
|
||||
return () => { generation++; };
|
||||
});
|
||||
function add(id: string) {
|
||||
if (disabled || busy || loading || !loaded) return;
|
||||
const label = catalog.find(label => label.id === id);
|
||||
if (label && !value.some(selected => selected.name === label.name)) value = [...value, label];
|
||||
}
|
||||
async function save() {
|
||||
if (disabled || busy || loading || !loaded || !number || !dirty) return;
|
||||
const current = source, repo = repository, issue = number, labels = [...value], expected = original.map(label => label.name), currentGeneration = generation, savedCallback = onSaved;
|
||||
busy = true; error = "";
|
||||
try {
|
||||
const credential = await auth(current);
|
||||
const saved = await setIntegrationIssueLabels(current.provider, current.baseUrl, credential.username, credential.password, repo, issue, labels, expected);
|
||||
savedCallback(saved);
|
||||
if (currentGeneration !== generation) return;
|
||||
original = saved; value = [...saved];
|
||||
} catch (cause) { if (currentGeneration === generation) error = String(cause); }
|
||||
finally { if (currentGeneration === generation) busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="label-editor" aria-busy={loading || busy}>
|
||||
<span class="field-label">{heading}</span>
|
||||
{#if value.length}<ul aria-label={heading}>
|
||||
{#each value as label (label.name)}
|
||||
<li title={label.description || label.name}><span class="color-dot" style:background={colorFor(label)}></span><span class="label-name">{label.name}</span><button type="button" disabled={disabled || busy || loading || !loaded} aria-label={`${de ? "Label entfernen" : "Remove label"}: ${label.name}`} onclick={() => value = value.filter(selected => selected.name !== label.name)}><X size={13}/></button></li>
|
||||
{/each}
|
||||
</ul>{/if}
|
||||
<SelectMenu value="" {options} searchable disabled={disabled || loading || busy || !loaded || !repository || !!catalogError} ariaLabel={heading}
|
||||
placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : !repository ? (de ? "Zuerst Repository/Projekt auswählen" : "Select a repository/project first") : `${heading} ${de ? "auswählen …" : "…"}`}
|
||||
searchPlaceholder={de ? `${heading} suchen …` : `Search ${heading.toLowerCase()} …`} emptyText={de ? "Keine passenden Einträge" : "No matching entries"} onChange={add}>
|
||||
{#snippet optionIcon(option)}<span class="color-dot" style:background={colorFor(catalog.find(label => label.id === option.value))}></span>{/snippet}
|
||||
</SelectMenu>
|
||||
{#if !loading && repository && !catalog.length && !catalogError}<small>{de ? `Keine ${heading} im Repository/Projekt vorhanden.` : `No ${heading.toLowerCase()} available in this repository/project.`}</small>{/if}
|
||||
{#if catalogError || error}<p role="alert">{catalogError || error}</p><button class="retry" type="button" disabled={disabled || busy || loading} onclick={() => retry++}>{de ? `${heading} neu laden` : `Reload ${heading.toLowerCase()}`}</button>{/if}
|
||||
{#if number && loaded && dirty}<div class="actions"><button type="button" disabled={disabled || busy || loading} onclick={save}>{busy ? (de ? "Wird gespeichert …" : "Saving …") : (de ? `${heading} speichern` : `Save ${heading.toLowerCase()}`)}</button><button type="button" disabled={disabled || busy || loading} onclick={() => { value = [...original]; error = ""; }}>{de ? "Abbrechen" : "Cancel"}</button></div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-editor{display:grid;gap:8px;min-width:0;font-size:12px;color:var(--color-ink)}.field-label{font-weight:500}
|
||||
ul{display:flex;flex-wrap:wrap;gap:6px;list-style:none;margin:0;padding:0}li{display:flex;align-items:center;gap:6px;max-width:100%;padding:4px 6px;background:var(--color-surface);border:1px solid var(--color-border)}.label-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.color-dot{display:inline-block;width:9px;height:9px;border-radius:50%;flex-shrink:0}
|
||||
button{font:inherit;font-size:11px;padding:6px 8px;color:inherit;background:var(--color-surface);border:1px solid var(--color-border);cursor:pointer}li button{display:grid;place-items:center;border:0;padding:2px;background:transparent}button:disabled{opacity:.5;cursor:default}button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}.actions{display:flex;flex-wrap:wrap;gap:6px}small{color:var(--color-ink-dim);font-size:11px;line-height:1.5}p{margin:0;color:var(--color-danger);overflow-wrap:anywhere;line-height:1.5}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { AiSettings } from "../types";
|
||||
import AssignmentEditor from "./AssignmentEditor.svelte";
|
||||
import CreateReviewDialog from "./CreateReviewDialog.svelte";
|
||||
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
|
||||
import { t } from "../i18n.svelte";
|
||||
@@ -643,6 +644,13 @@
|
||||
</main>
|
||||
<aside class="detail-sidebar">
|
||||
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
|
||||
{#if activeSource}
|
||||
<section class="people-section">
|
||||
{#key `${activeSource.id}:${selected.id}`}
|
||||
<AssignmentEditor source={activeSource} target={{ repository: selected.repositoryName, repositoryId: selected.repositoryId, number: selected.number, kind: "review" }} {de} {loadCredential} disabled={!!actionBusyId}/>
|
||||
{/key}
|
||||
</section>
|
||||
{/if}
|
||||
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong></div></section>
|
||||
<section class="detail-meta"><h3>Repository</h3><strong><GitBranch size={16} />{selected.repositoryName}</strong></section>
|
||||
<section class="detail-meta"><h3>{de ? "Aktualisiert" : "Updated"}</h3><span><Clock3 size={16} />{formatDate(selected.updatedAt || selected.createdAt)}</span></section>
|
||||
|
||||
@@ -323,8 +323,44 @@
|
||||
onStopTracking(targets, target.kind);
|
||||
}
|
||||
|
||||
function focusStatusLane(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && !target.closest("input, textarea, select, a, [contenteditable]")) {
|
||||
(event.currentTarget as HTMLElement).focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
function clearStatusSelection(): boolean {
|
||||
if (selectedStatusPaths.size === 0 && !selectionAnchorKey) return false;
|
||||
selectedStatusPaths = new Set();
|
||||
selectionAnchorKey = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleStatusWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeStatusContextMenu();
|
||||
// Escape backs out one step at a time: first the context menu, then the
|
||||
// current multi-selection. No preventDefault, so an open dialog still
|
||||
// closes on the same keypress.
|
||||
if (event.key === "Escape") {
|
||||
if (statusContextTarget) {
|
||||
closeStatusContextMenu();
|
||||
return;
|
||||
}
|
||||
const escapeTarget = event.target;
|
||||
if (escapeTarget instanceof Element && escapeTarget.closest("input, textarea, select, [contenteditable]")) return;
|
||||
clearStatusSelection();
|
||||
return;
|
||||
}
|
||||
if (event.defaultPrevented || isBusy || !hasRepository || !(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "a") return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element) || target.closest("input, textarea, select, [contenteditable]")) return;
|
||||
const lane = target.closest(".status-lane");
|
||||
if (!lane) return;
|
||||
const files = lane.classList.contains("unstaged-lane") ? unstagedFiles : stagedFiles;
|
||||
event.preventDefault();
|
||||
selectedStatusPaths = new Set(files.map(fileKey));
|
||||
selectionAnchorKey = files.length ? fileKey(files[0]) : "";
|
||||
closeStatusContextMenu();
|
||||
}
|
||||
|
||||
function isStatusSelected(file: GitFileStatus): boolean {
|
||||
@@ -459,17 +495,17 @@
|
||||
<div class="blank-state">{t("status.noChanges")}</div>
|
||||
{:else}
|
||||
<div class="status-lanes">
|
||||
<section class="status-lane unstaged-lane" aria-label={t("status.laneUnstaged")}>
|
||||
<header class="status-lane-head">
|
||||
<section class="status-lane unstaged-lane" tabindex="-1" onpointerdown={focusStatusLane} aria-label={t("status.laneUnstaged")}>
|
||||
<header class="status-lane-head" title={t("status.selectAllHint")}>
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>{t("status.unstagedTitle")}</strong><small>{t("status.workingTree")}</small></div>
|
||||
<span class="status-lane-count">{unstagedCount}</span>
|
||||
</div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||
<span class="status-selection-count">{t("status.selectionCount", { count: selectedUnstagedCount })}</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={t("status.stageSelected", { count: selectedUnstagedCount })}>
|
||||
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||
<ArrowRight size={13} aria-hidden="true" /> {t("status.stageCount", { count: selectedUnstagedCount })}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title={t("status.stageAllHint")}>
|
||||
@@ -516,17 +552,17 @@
|
||||
<span><ArrowRight size={12} /></span>
|
||||
</div>
|
||||
|
||||
<section class="status-lane staged-lane" aria-label={t("status.laneStaged")}>
|
||||
<header class="status-lane-head">
|
||||
<section class="status-lane staged-lane" tabindex="-1" onpointerdown={focusStatusLane} aria-label={t("status.laneStaged")}>
|
||||
<header class="status-lane-head" title={t("status.selectAllHint")}>
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>{t("status.stagedTitle")}</strong><small>{t("status.nextCommit")}</small></div>
|
||||
<span class="status-lane-count">{stagedCount}</span>
|
||||
</div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedStagedCount > 1}
|
||||
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
||||
<span class="status-selection-count">{t("status.selectionCount", { count: selectedStagedCount })}</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={t("status.unstageSelected", { count: selectedStagedCount })}>
|
||||
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||
<ArrowLeft size={13} aria-hidden="true" /> {t("status.unstageCount", { count: selectedStagedCount })}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title={t("status.unstageAllHint")}>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { closeTerminal, openTerminal, resizeTerminal, writeTerminal } from "../git";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
interface Props {
|
||||
/** Stable per repository tab, so every tab keeps its own shell. */
|
||||
sessionId: string;
|
||||
repoPath: string;
|
||||
/** Only the active tab's terminal is visible; the others stay alive. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
let { sessionId, repoPath, active = false }: Props = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let terminal: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let observer: ResizeObserver | null = null;
|
||||
let unlisteners: UnlistenFn[] = [];
|
||||
let started = false;
|
||||
let exited = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
/** xterm needs concrete colors, so the theme tokens are resolved once here. */
|
||||
function readTheme() {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
|
||||
const foreground = read("--color-ink", "#e6e9f0");
|
||||
return {
|
||||
background: read("--color-surface-solid", "#12141a"),
|
||||
foreground,
|
||||
cursor: read("--color-primary", "#4f8cff"),
|
||||
cursorAccent: read("--color-surface-solid", "#12141a"),
|
||||
selectionBackground: read("--color-selection", "rgba(79, 140, 255, 0.32)"),
|
||||
red: read("--code-delete-strong", "#e86060"),
|
||||
green: read("--code-add-strong", "#4eca76"),
|
||||
brightRed: read("--code-delete-text", "#e86060"),
|
||||
brightGreen: read("--code-add-text", "#5dd88a"),
|
||||
blue: read("--color-primary", "#4f8cff"),
|
||||
brightBlue: read("--color-accent", "#7aa2ff"),
|
||||
};
|
||||
}
|
||||
|
||||
function fit() {
|
||||
if (!fitAddon || !terminal || !active) return;
|
||||
try {
|
||||
fitAddon.fit();
|
||||
void resizeTerminal(sessionId, terminal.cols, terminal.rows).catch(() => {});
|
||||
} catch {
|
||||
// A hidden or zero-sized host throws; the next visible fit corrects it.
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (started || !host) return;
|
||||
started = true;
|
||||
|
||||
terminal = new Terminal({
|
||||
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font-mono").trim() || "monospace",
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.25,
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
theme: readTheme(),
|
||||
});
|
||||
fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(host);
|
||||
await tick();
|
||||
try { fitAddon.fit(); } catch { /* see fit() */ }
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (exited) return;
|
||||
void writeTerminal(sessionId, data).catch((cause) => { error = String(cause); });
|
||||
});
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (exited) return;
|
||||
void resizeTerminal(sessionId, cols, rows).catch(() => {});
|
||||
});
|
||||
|
||||
unlisteners.push(await listen<{ id: string; data: string }>("terminal:data", (event) => {
|
||||
if (event.payload.id !== sessionId) return;
|
||||
terminal?.write(event.payload.data);
|
||||
}));
|
||||
unlisteners.push(await listen<{ id: string; message: string }>("terminal:exit", (event) => {
|
||||
if (event.payload.id !== sessionId) return;
|
||||
exited = true;
|
||||
if (event.payload.message) error = event.payload.message;
|
||||
}));
|
||||
|
||||
try {
|
||||
await openTerminal(sessionId, repoPath, terminal.cols, terminal.rows);
|
||||
terminal.focus();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
exited = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void start();
|
||||
if (host) {
|
||||
observer = new ResizeObserver(() => fit());
|
||||
observer.observe(host);
|
||||
}
|
||||
return () => {};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
observer?.disconnect();
|
||||
for (const unlisten of unlisteners) unlisten();
|
||||
unlisteners = [];
|
||||
terminal?.dispose();
|
||||
terminal = null;
|
||||
void closeTerminal(sessionId).catch(() => {});
|
||||
});
|
||||
|
||||
// Becoming visible again needs a fresh measurement: xterm cannot size itself
|
||||
// while its host is display:none.
|
||||
$effect(() => {
|
||||
if (!active) return;
|
||||
void tick().then(() => {
|
||||
fit();
|
||||
terminal?.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="terminal-surface" class:hidden={!active} aria-hidden={!active}>
|
||||
<div bind:this={host} class="terminal-host"></div>
|
||||
{#if error}
|
||||
<p class="terminal-error" role="status">{error}</p>
|
||||
{:else if exited}
|
||||
<p class="terminal-error" role="status">{t("terminal.exited")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -757,3 +757,42 @@ export function checkoutSubmoduleRevision(path: string, modulePath: string, revi
|
||||
export function getFileRestorePatch(path: string, commit: string, file: string): Promise<string> {
|
||||
return invoke("get_file_restore_patch", { path, commit, file });
|
||||
}
|
||||
|
||||
export function listIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget): Promise<import("./types").IntegrationAssignee[]> {
|
||||
return invoke("list_integration_assignees", { provider, baseUrl, username, token, target });
|
||||
}
|
||||
export function getIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget): Promise<import("./types").IntegrationAssignee[]> {
|
||||
return invoke("get_integration_assignees", { provider, baseUrl, username, token, target });
|
||||
}
|
||||
export function setIntegrationAssignees(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, target: import("./types").AssignmentTarget, users: import("./types").IntegrationAssignee[]): Promise<import("./types").IntegrationAssignee[]> {
|
||||
return invoke("set_integration_assignees", { provider, baseUrl, username, token, target, users });
|
||||
}
|
||||
|
||||
export function listIntegrationLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string): Promise<import("./types").IntegrationLabel[]> {
|
||||
return invoke("list_integration_labels", { provider, baseUrl, username, token, repository });
|
||||
}
|
||||
export function getIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise<import("./types").IntegrationLabel[]> {
|
||||
return invoke("get_integration_issue_labels", { provider, baseUrl, username, token, repository, number });
|
||||
}
|
||||
export function setIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, labels: import("./types").IntegrationLabel[], expected: string[] | null = null): Promise<import("./types").IntegrationLabel[]> {
|
||||
return invoke("set_integration_issue_labels", { provider, baseUrl, username, token, repository, number, labels, expected });
|
||||
}
|
||||
|
||||
// ── Embedded terminal ────────────────────────────────────────────────────────
|
||||
// Session ids are owned by the frontend: one per repository tab.
|
||||
|
||||
export function openTerminal(id: string, cwd: string, cols: number, rows: number): Promise<void> {
|
||||
return invoke<void>("terminal_open", { id, cwd, cols, rows });
|
||||
}
|
||||
|
||||
export function writeTerminal(id: string, data: string): Promise<void> {
|
||||
return invoke<void>("terminal_write", { id, data });
|
||||
}
|
||||
|
||||
export function resizeTerminal(id: string, cols: number, rows: number): Promise<void> {
|
||||
return invoke<void>("terminal_resize", { id, cols, rows });
|
||||
}
|
||||
|
||||
export function closeTerminal(id: string): Promise<void> {
|
||||
return invoke<void>("terminal_close", { id });
|
||||
}
|
||||
|
||||
@@ -185,6 +185,15 @@ export const messages = {
|
||||
"stashes.drop": { en: "Drop", de: "Löschen" },
|
||||
|
||||
// ── Status panel ───────────────────────────────────────────────────────────
|
||||
// ── Embedded terminal ──────────────────────────────────────────────────────
|
||||
"terminal.title": { en: "Terminal", de: "Terminal" },
|
||||
"terminal.show": { en: "Show terminal", de: "Terminal anzeigen" },
|
||||
"terminal.hide": { en: "Hide terminal", de: "Terminal ausblenden" },
|
||||
"terminal.close": { en: "Close terminal", de: "Terminal schließen" },
|
||||
"terminal.resize": { en: "Resize terminal", de: "Terminalhöhe ändern" },
|
||||
"terminal.exited": { en: "The shell has ended. Close and reopen the terminal to start a new one.", de: "Die Shell wurde beendet. Terminal schließen und erneut öffnen startet eine neue." },
|
||||
"terminal.hint": { en: "Runs in the repository directory", de: "Läuft im Repository-Verzeichnis" },
|
||||
|
||||
"status.panelLabel": { en: "Working tree status", de: "Status des Arbeitsverzeichnisses" },
|
||||
"status.eyebrow": { en: "Workspace", de: "Arbeitsbereich" },
|
||||
"status.title": { en: "Changes", de: "Änderungen" },
|
||||
@@ -206,6 +215,7 @@ export const messages = {
|
||||
"status.stagedTitle": { en: "Staged", de: "Gestaged" },
|
||||
"status.workingTree": { en: "Working tree", de: "Arbeitsverzeichnis" },
|
||||
"status.nextCommit": { en: "Next commit", de: "Nächster Commit" },
|
||||
"status.selectAllHint": { en: "Ctrl + A selects every file in this list, Esc clears the selection", de: "Strg + A wählt alle Dateien in dieser Liste aus, Esc hebt die Auswahl auf" },
|
||||
"status.stageSelected": { en: "Stage {count} selected files", de: "{count} ausgewählte Dateien stagen" },
|
||||
"status.unstageSelected": { en: "Unstage {count} selected files", de: "{count} ausgewählte Dateien entstagen" },
|
||||
"status.stageAllHint": { en: "Stage all unstaged files", de: "Alle ungestagten Dateien stagen" },
|
||||
@@ -215,6 +225,9 @@ export const messages = {
|
||||
"status.discardUnstagedSelected": { en: "Discard unstaged changes in {count} selected files", de: "Ungestagte Änderungen in {count} ausgewählten Dateien verwerfen" },
|
||||
"status.discardStagedSelected": { en: "Discard staged changes in {count} selected files", de: "Gestagte Änderungen in {count} ausgewählten Dateien verwerfen" },
|
||||
"status.discard": { en: "Discard", de: "Verwerfen" },
|
||||
"status.selectionCount": { en: "{count} selected", de: "{count} ausgewählt" },
|
||||
"status.stageCount": { en: "Stage {count}", de: "{count} stagen" },
|
||||
"status.unstageCount": { en: "Unstage {count}", de: "{count} entstagen" },
|
||||
"status.selectInExplorer": { en: "Select {path} in Explorer", de: "{path} im Explorer auswählen" },
|
||||
"status.stageFile": { en: "Stage file", de: "Datei stagen" },
|
||||
"status.unstageFile": { en: "Unstage file", de: "Datei entstagen" },
|
||||
|
||||
@@ -511,3 +511,23 @@ export interface GitSubmodule {
|
||||
conflicted: boolean;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface IntegrationAssignee {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AssignmentTarget {
|
||||
repository: string;
|
||||
repositoryId?: string;
|
||||
number: number;
|
||||
kind: "issue" | "review";
|
||||
}
|
||||
|
||||
export interface IntegrationLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user