From 865a9a55602e68f7b37c048849df1648fda5ee51 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 22 Sep 2026 09:13:28 +0200 Subject: [PATCH 1/9] feat(CreateIssueDialog): replace plain textarea with CommentEditor for description Replace the simple +
+ {de ? "Beschreibung" : "Description"} + +
{#if error}{/if} @@ -112,10 +119,10 @@ 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)} -- 2.54.0 From 8e63f2a9391b9efd2cef1c7231c74ec7ce901d3d Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 22 Sep 2026 10:21:57 +0200 Subject: [PATCH 2/9] feat(integrations): add assignee/assignment support Add a new integrations/assignees backend (src-tauri/src/integrations/assignees.rs) and expose commands to list, read and set assignees: - list_integration_assignees - get_integration_assignees - set_integration_assignees Introduce IntegrationAssignee and AssignmentTarget types and provider-specific URL/payload logic (GitHub, Gitea, GitLab / self-hosted, Azure DevOps). The code handles pagination, provider quirks (Gitea legacy fields, GitLab assignee_ids, Azure reviewer vs work-item differences) and validates/verifies assignments. Unit tests cover routing and payload behavior. Add UI components AssigneePicker.svelte and AssignmentEditor.svelte and update CreateIssueDialog, CreateReviewDialog, IssueCenter, ReviewCenter, git types and git.ts to use the new assignment functionality. --- src-tauri/src/integrations.rs | 2 + src-tauri/src/integrations/assignees.rs | 814 +++++++++++++++++++ src-tauri/src/main.rs | 4 + src/lib/components/AssigneePicker.svelte | 75 ++ src/lib/components/AssignmentEditor.svelte | 67 ++ src/lib/components/CreateIssueDialog.svelte | 30 +- src/lib/components/CreateReviewDialog.svelte | 27 +- src/lib/components/IssueCenter.svelte | 15 +- src/lib/components/ReviewCenter.svelte | 8 + src/lib/git.ts | 10 + src/lib/types.ts | 13 + 11 files changed, 1048 insertions(+), 17 deletions(-) create mode 100644 src-tauri/src/integrations/assignees.rs create mode 100644 src/lib/components/AssigneePicker.svelte create mode 100644 src/lib/components/AssignmentEditor.svelte diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index f086f4a..3e939f1 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -1,3 +1,5 @@ +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..8f874cd --- /dev/null +++ b/src-tauri/src/integrations/assignees.rs @@ -0,0 +1,814 @@ +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, +} + +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) +} + +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()) +} + +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() +} + +struct AssignmentApi<'a> { + client: Client, + provider: &'a str, + username: &'a str, + token: &'a str, +} +impl AssignmentApi<'_> { + 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!("Assignment 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()) + } + 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())?; + let entries = if self.provider == "azure-devops" { + value["value"].as_array() + } else { + value.as_array() + } + .ok_or("Invalid user list.")?; + if entries.is_empty() { + return Ok(result); + } + if previous.as_ref() == Some(&value) { + return Err("The provider repeated a user page.".into()); + } + result.extend(entries.iter().cloned()); + if !next.unwrap_or(entries.len() == 100) { + return Ok(result); + } + previous = Some(value); + } + Err("Too many user 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 = AssignmentApi { + 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 = AssignmentApi { + 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 = AssignmentApi { 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)] +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. + 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/main.rs b/src-tauri/src/main.rs index 46c2850..2152be5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -37,6 +37,7 @@ 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, 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, }; @@ -456,6 +457,9 @@ async fn main() { move_integration_board_card, list_integration_issue_comments, add_integration_issue_comment, + list_integration_assignees, + get_integration_assignees, + set_integration_assignees, create_integration_issue, list_azure_issue_projects, list_azure_issue_types, 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 35d5877..4388930 100644 --- a/src/lib/components/CreateIssueDialog.svelte +++ b/src/lib/components/CreateIssueDialog.svelte @@ -1,6 +1,9 @@ - { 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)}/>
@@ -98,6 +110,7 @@ {#if typeError} {:else if repository && !typesLoading && !types.length}

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

{/if} {/if} +
{de ? "Beschreibung" : "Description"} @@ -106,13 +119,14 @@ previewLabel={de ? "Beschreibungsvorschau" : "Description preview"} placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"} />
- {#if error}{/if} -
-
+ {#if error}{/if} + +
diff --git a/src/lib/git.ts b/src/lib/git.ts index 7c3c87e..6a7a076 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -767,3 +767,13 @@ export function getIntegrationAssignees(provider: GitIntegrationProvider, baseUr 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 }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 742cfa6..44c9225 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -524,3 +524,10 @@ export interface AssignmentTarget { number: number; kind: "issue" | "review"; } + +export interface IntegrationLabel { + id: string; + name: string; + color: string; + description: string; +} -- 2.54.0 From baa226d536d593661443314943824921bcd1a617 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 22 Sep 2026 11:28:45 +0200 Subject: [PATCH 4/9] fix(file-history-dialog): prevent long paths from displacing header controls Constrain the dialog header/body layout so very long file paths don't push or wrap the dialog controls. Results: the title is truncated with an ellipsis but the full name is available on hover. - CSS: add grid-template-columns and min-width:0 to dialog/body; force unified-dialog-header to avoid wrapping and set unified-dialog-text flex-basis:0; truncate h2 with overflow/ellipsis. - Svelte: add a title attribute to the h2 so the full filename is shown on hover. --- src/app.css | 9 ++++++++- src/lib/components/FileHistoryDialog.svelte | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app.css b/src/app.css index 88f844f..3bace5e 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; @@ -9173,6 +9174,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); } diff --git a/src/lib/components/FileHistoryDialog.svelte b/src/lib/components/FileHistoryDialog.svelte index 1169cb9..ce67cb1 100644 --- a/src/lib/components/FileHistoryDialog.svelte +++ b/src/lib/components/FileHistoryDialog.svelte @@ -52,7 +52,7 @@
File history -

{fileName(filePath)}

+

{fileName(filePath)}

{filePath}
- - - + + + + {#if activeView === "repository"}
diff --git a/src/lib/components/HelpOverlay.svelte b/src/lib/components/HelpOverlay.svelte index 94ffc18..4271014 100644 --- a/src/lib/components/HelpOverlay.svelte +++ b/src/lib/components/HelpOverlay.svelte @@ -249,6 +249,7 @@ summary: "Die Hilfe ist überall erreichbar. Dialoge lassen sich konsistent schließen und Suchfelder direkt fokussieren.", commands: [ { command: "Ctrl + /", description: "Diese Hilfe öffnen" }, + { command: "Ctrl + 1 … 4", description: "Zwischen Dashboard, Repositories, Pull Requests und Issues & Boards wechseln" }, { 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" }, @@ -460,6 +461,7 @@ summary: "Help is available everywhere. Dialogs close consistently and search fields receive focus automatically.", commands: [ { command: "Ctrl + /", description: "Open this help center" }, + { command: "Ctrl + 1 … 4", description: "Switch between Dashboard, Repositories, Pull Requests and Issues & Boards" }, { 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" }, -- 2.54.0 From d2cf81ac941dc2d02fcff6a2161690229994f72b Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 22 Sep 2026 17:48:00 +0200 Subject: [PATCH 7/9] refactor(help-overlay): remove changelog sections and unused Sparkles icon Remove the large static "changelog" entries previously spliced into enCategories and deCategories in HelpOverlay.svelte, and drop the now-unused Sparkles import from @lucide/svelte. Before: the help overlay injected a multi-version "What's new / Neu in Gitty" category with many release notes. After: that category and its content are no longer added, and the unused icon import is removed (reduces clutter/unused code). --- src/lib/components/HelpOverlay.svelte | 316 -------------------------- 1 file changed, 316 deletions(-) diff --git a/src/lib/components/HelpOverlay.svelte b/src/lib/components/HelpOverlay.svelte index 4271014..4b4b49e 100644 --- a/src/lib/components/HelpOverlay.svelte +++ b/src/lib/components/HelpOverlay.svelte @@ -18,7 +18,6 @@ Lightbulb, ListChecks, Search, - Sparkles, Wrench, X, } from "@lucide/svelte"; @@ -1547,321 +1546,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"); @@ -1967,7 +1652,6 @@ > {#if category.id === "start"}
{#if selectedUnstagedCount > 1} - {selectedUnstagedCount} selected + {t("status.selectionCount", { count: selectedUnstagedCount })} {/if}
{#if selectedStagedCount > 1} - {selectedStagedCount} selected + {t("status.selectionCount", { count: selectedStagedCount })} {/if} + +
+ {#await import("./lib/components/TerminalPanel.svelte") then module} + {#each repoTabs as tab (tab.path)} + + {/each} + {/await} +
+ + {/if} {/if}
@@ -6690,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 86199b4..b0bb38c 100644 --- a/src/app.css +++ b/src/app.css @@ -6453,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); @@ -6460,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 { @@ -9301,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/components/HelpOverlay.svelte b/src/lib/components/HelpOverlay.svelte index 4b4b49e..61c605d 100644 --- a/src/lib/components/HelpOverlay.svelte +++ b/src/lib/components/HelpOverlay.svelte @@ -249,6 +249,7 @@ commands: [ { command: "Ctrl + /", description: "Diese Hilfe öffnen" }, { 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" }, @@ -461,6 +462,7 @@ commands: [ { command: "Ctrl + /", description: "Open this help center" }, { 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" }, diff --git a/src/lib/components/TerminalPanel.svelte b/src/lib/components/TerminalPanel.svelte new file mode 100644 index 0000000..22cfe4e --- /dev/null +++ b/src/lib/components/TerminalPanel.svelte @@ -0,0 +1,142 @@ + + +
+
+ {#if error} +

{error}

+ {:else if exited} +

{t("terminal.exited")}

+ {/if} +
diff --git a/src/lib/git.ts b/src/lib/git.ts index 6a7a076..5933f3a 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -777,3 +777,22 @@ export function getIntegrationIssueLabels(provider: GitIntegrationProvider, base 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 157df28..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" }, -- 2.54.0