diff --git a/CHANGELOG.md b/CHANGELOG.md index 93bf7fc..1664d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index d2f7d30..94df986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/package.json b/package.json index 8b9e892..54dcf45 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a809c04..e4dc71f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d5f864f..52d61f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 = [] } diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index f086f4a..7a3241b 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -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; diff --git a/src-tauri/src/integrations/assignees.rs b/src-tauri/src/integrations/assignees.rs new file mode 100644 index 0000000..6baf586 --- /dev/null +++ b/src-tauri/src/integrations/assignees.rs @@ -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 { + 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 { + 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 { + 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, 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 { + 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, 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, 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, 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 { + 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::() + .ok() + .filter(|id| *id > 0) + .ok_or("Invalid GitLab user ID.") + }) + .collect::, _>>()?; + return Ok(json!({"assignee_ids": if ids.is_empty() { vec![0] } else { ids }})); + } + Ok(json!({"assignees":users.iter().map(|u| &u.id).collect::>()})) +} + +#[tauri::command] +pub async fn set_integration_assignees( + provider: String, + base_url: String, + username: String, + token: String, + target: AssignmentTarget, + users: Vec, +) -> Result, 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::>(); + 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>) { + 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::().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: ; 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"]})); + } +} diff --git a/src-tauri/src/integrations/labels.rs b/src-tauri/src/integrations/labels.rs new file mode 100644 index 0000000..5fd8c0d --- /dev/null +++ b/src-tauri/src/integrations/labels.rs @@ -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 { + 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 { + 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, 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, 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 { + 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::() + .ok() + .filter(|id| *id > 0) + .ok_or("Invalid Gitea label ID.") + }) + .collect::, _>>()?; + Ok(json!({"labels":ids})) + } + "github" => { + Ok(json!({"labels":labels.iter().map(|label| &label.name).collect::>()})) + } + "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::>().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::>().join("; ")}]), + ) + } + _ => Err("Unsupported label provider.".into()), + } +} + +fn names(labels: &[IntegrationLabel]) -> BTreeSet { + labels.iter().map(|label| label.name.clone()).collect() +} + +fn desired_labels( + current: &[IntegrationLabel], + requested: Vec, + expected: Option>, +) -> Result, 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, 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::, _>>()? + }; + 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, 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, + expected: Option>, +) -> Result, 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(); + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 46c2850..e1a0409 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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>); @@ -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, diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs new file mode 100644 index 0000000..4b699d2 --- /dev/null +++ b/src-tauri/src/terminal.rs @@ -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, + writer: Box, + child: Box, +} + +#[derive(Default)] +pub struct TerminalState(Mutex>); + +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) -> 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 = 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::() { + 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()); + } +} diff --git a/src/App.svelte b/src/App.svelte index 5c54df2..462afdb 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@ Gitty - + {#if sidebarSectionMenu && activeView === "repository"} + {#if terminalOpen} +
+ + + +
+ + {activeRepoPath} + +
+
+ {#await import("./lib/components/TerminalPanel.svelte") then module} + {#each repoTabs as tab (tab.path)} + + {/each} + {/await} +
+
+ {/if} {/if}
@@ -6629,6 +6795,11 @@ {/if} Auto {/if} + {#if workspaceActive} + + {/if} {#if appVersion}Gitty v{appVersion}{/if}
diff --git a/src/app.css b/src/app.css index 88f844f..b0bb38c 100644 --- a/src/app.css +++ b/src/app.css @@ -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; } +} diff --git a/src/lib/RepoTabs.svelte b/src/lib/RepoTabs.svelte index 69e4604..ec04fe0 100644 --- a/src/lib/RepoTabs.svelte +++ b/src/lib/RepoTabs.svelte @@ -89,10 +89,10 @@
{#if activeView === "repository"}
diff --git a/src/lib/components/AssigneePicker.svelte b/src/lib/components/AssigneePicker.svelte new file mode 100644 index 0000000..995f8a6 --- /dev/null +++ b/src/lib/components/AssigneePicker.svelte @@ -0,0 +1,75 @@ + + +
+ {label} + {#if value.length} +
    + {#each value as user (user.id)} +
  • {displayName(user)}
  • + {/each} +
+ {/if} + + {#if error} + {:else if target.repository && !loading && !users.length}{de ? "Keine zuweisbaren Benutzer gefunden." : "No assignable users found."}{/if} + {#if source.provider === "azure-devops"}{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.")}{/if} +
+ + diff --git a/src/lib/components/AssignmentEditor.svelte b/src/lib/components/AssignmentEditor.svelte new file mode 100644 index 0000000..907afff --- /dev/null +++ b/src/lib/components/AssignmentEditor.svelte @@ -0,0 +1,67 @@ + +
+ + {#if loading}{de ? "Zuweisung wird geladen …" : "Loading assignment …"}{/if} + {#if error}

{error}

{/if} + {#if loaded && dirty}
{/if} +
+ diff --git a/src/lib/components/CreateIssueDialog.svelte b/src/lib/components/CreateIssueDialog.svelte index c2a408f..8d22690 100644 --- a/src/lib/components/CreateIssueDialog.svelte +++ b/src/lib/components/CreateIssueDialog.svelte @@ -1,7 +1,12 @@ - { event.preventDefault(); if (!busy) onClose(); }}> + { event.preventDefault(); if (!busy) finish(); }}>
-

{de ? "Neues Issue" : "New issue"}

{source.label}

-
+

{de ? "Neues Issue" : "New issue"}

{source.label}

+
{azure ? (de ? "Projekt" : "Project") : "Repository"} void selectTarget(value)}/>
@@ -97,25 +120,34 @@ {#if typeError} {:else if repository && !typesLoading && !types.length}

{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}

{/if} {/if} + + - - {#if error}{/if} -
-
+
+ {de ? "Beschreibung" : "Description"} + +
+ {#if error}{/if} + +
diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index 63e9605..b99c80b 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -1,6 +1,7 @@ + +
+
+ {#if error} +

{error}

+ {:else if exited} +

{t("terminal.exited")}

+ {/if} +
diff --git a/src/lib/git.ts b/src/lib/git.ts index 044dcc5..5933f3a 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -757,3 +757,42 @@ export function checkoutSubmoduleRevision(path: string, modulePath: string, revi export function getFileRestorePatch(path: string, commit: string, file: string): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return invoke("terminal_open", { id, cwd, cols, rows }); +} + +export function writeTerminal(id: string, data: string): Promise { + return invoke("terminal_write", { id, data }); +} + +export function resizeTerminal(id: string, cols: number, rows: number): Promise { + return invoke("terminal_resize", { id, cols, rows }); +} + +export function closeTerminal(id: string): Promise { + return invoke("terminal_close", { id }); +} diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 840f6d4..e280d6d 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -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" }, diff --git a/src/lib/types.ts b/src/lib/types.ts index ec12f81..44c9225 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; +}