From c242a72edd3a687e6b03bc05dba6854269c82c45 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sat, 29 Aug 2026 23:55:27 +0200 Subject: [PATCH] feat(integrations): support GitHub repositories and enhance repo browser UI Add GitHub repository support to integrations and update related tests. Implement server-side GitHub API calls and normalize GitHub base URLs. Improve the repository browser UI with compact tabs and updated icons. Add a custom, accessible scrollbar with pointer and keyboard support. - Add paging, auth headers, and error handling when listing GitHub repos. - Default GitHub token username to "x-access-token" when saving credentials. - Introduce compact repo tab styles, new icons, and a draggable scrollbar. --- src-tauri/src/integrations.rs | 94 +++++++++ src/App.svelte | 3 +- src/app.css | 132 +++++++++++++ src/lib/RepoTabs.svelte | 7 +- src/lib/components/AppSettingsDialog.svelte | 2 +- .../components/CloneRepositoryDialog.svelte | 181 ++++++++++++++++-- .../components/IntegrationSettingsPage.svelte | 19 +- src/lib/integrations.ts | 8 + src/lib/types.ts | 2 +- 9 files changed, 414 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs index 24da132..9de5486 100644 --- a/src-tauri/src/integrations.rs +++ b/src-tauri/src/integrations.rs @@ -38,6 +38,24 @@ struct GitLabProject { visibility: String, } +#[derive(Debug, Deserialize)] +struct GitHubRepository { + id: u64, + name: String, + full_name: String, + #[serde(default)] + description: Option, + clone_url: String, + #[serde(default)] + ssh_url: String, + #[serde(default)] + html_url: String, + #[serde(default)] + updated_at: String, + #[serde(default)] + private: bool, +} + #[derive(Debug, Deserialize)] struct GiteaRepository { id: u64, @@ -113,6 +131,14 @@ fn normalized_base_url(base_url: &str) -> Result { Ok(base_url.to_string()) } +fn github_api_base_url(base_url: &str) -> Result { + match normalized_base_url(base_url)?.to_ascii_lowercase().as_str() { + "https://github.com" | "https://www.github.com" => Ok("https://api.github.com".to_string()), + "https://api.github.com" => Ok("https://api.github.com".to_string()), + _ => Err("The GitHub integration URL must be https://github.com.".to_string()), + } +} + fn response_error(response: Response, provider: &str) -> String { let status = response.status(); let detail = response.text().ok().and_then(|body| { @@ -188,6 +214,57 @@ fn gitlab_repositories( Ok(repositories) } +fn github_repositories( + client: &Client, + base_url: &str, + token: &str, +) -> Result, String> { + let api_base_url = github_api_base_url(base_url)?; + let mut repositories = Vec::new(); + let mut page = 1usize; + loop { + let response = client + .get(format!("{api_base_url}/user/repos")) + .header(USER_AGENT, "Gitty") + .header(ACCEPT, "application/vnd.github+json") + .header("Authorization", format!("Bearer {token}")) + .header("X-GitHub-Api-Version", "2026-03-10") + .query(&[ + ("per_page", PAGE_SIZE.to_string()), + ("page", page.to_string()), + ("sort", "updated".to_string()), + ("direction", "desc".to_string()), + ]) + .send() + .map_err(|err| format!("Could not reach GitHub: {err}"))?; + if !response.status().is_success() { + return Err(response_error(response, "GitHub")); + } + let page_repositories = response + .json::>() + .map_err(|err| format!("GitHub returned an unreadable repository list: {err}"))?; + let count = page_repositories.len(); + repositories.extend(page_repositories.into_iter().map(|repository| { + IntegrationRepository { + id: repository.id.to_string(), + name: repository.name, + full_name: repository.full_name, + description: repository.description.unwrap_or_default(), + clone_url: repository.clone_url, + ssh_url: repository.ssh_url, + web_url: repository.html_url, + updated_at: repository.updated_at, + private: repository.private, + } + })); + if count < PAGE_SIZE { + break; + } + page += 1; + } + Ok(repositories) +} + fn gitea_repositories( client: &Client, base_url: &str, @@ -286,6 +363,7 @@ pub async fn list_integration_repositories( let base_url = normalized_base_url(&base_url)?; let client = client()?; match provider.as_str() { + "github" => github_repositories(&client, &base_url, &credential.password), "gitlab" | "gitlab-self-hosted" => { gitlab_repositories(&client, &base_url, &credential.password) } @@ -318,10 +396,19 @@ mod tests { "https://gitlab.example.com" ); assert!(normalized_base_url("gitlab.example.com").is_err()); + assert_eq!( + github_api_base_url("https://github.com/").unwrap(), + "https://api.github.com" + ); + assert!(github_api_base_url("https://github.example.com").is_err()); } #[test] fn integration_credential_keys_match_the_frontend() { + assert_eq!( + integration_key("github", None).unwrap(), + "integration:github" + ); assert_eq!(integration_key("gitea", None).unwrap(), "integration:gitea"); assert_eq!( integration_key("azure-devops", Some("org-123")).unwrap(), @@ -336,6 +423,13 @@ mod tests { #[test] fn provider_repository_payloads_deserialize() { + let github: Vec = serde_json::from_str( + r#"[{"id":6,"name":"desktop","full_name":"team/desktop","description":null,"clone_url":"https://github.com/team/desktop.git","ssh_url":"git@github.com:team/desktop.git","html_url":"https://github.com/team/desktop","updated_at":"2026-08-29T09:00:00Z","private":true}]"#, + ) + .unwrap(); + assert_eq!(github[0].full_name, "team/desktop"); + assert!(github[0].description.is_none()); + let gitlab: Vec = serde_json::from_str( r#"[{"id":7,"name":"app","path_with_namespace":"team/app","description":"Demo","http_url_to_repo":"https://gitlab.test/team/app.git","ssh_url_to_repo":"git@gitlab.test:team/app.git","web_url":"https://gitlab.test/team/app","last_activity_at":"2026-08-29T10:00:00Z","visibility":"private"}]"#, ) diff --git a/src/App.svelte b/src/App.svelte index 99a406e..351776c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1285,7 +1285,8 @@ if (azureOrganization) azureOrganization.tokenStored = false; else providerConfig.tokenStored = false; } else if (update.token) { - const username = (azureOrganization?.username ?? providerConfig.username).trim() || "oauth2"; + const fallbackUsername = update.provider === "github" ? "x-access-token" : "oauth2"; + const username = (azureOrganization?.username ?? providerConfig.username).trim() || fallbackUsername; await credSave(key, username, update.token, "token"); if (azureOrganization) azureOrganization.tokenStored = true; else providerConfig.tokenStored = true; diff --git a/src/app.css b/src/app.css index 9f5830c..0a9b931 100644 --- a/src/app.css +++ b/src/app.css @@ -8839,3 +8839,135 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s .cred-footer { align-items: stretch; flex-direction: column; } .cred-btns { display: grid; grid-template-columns: 1fr 1fr; } } + +/* Compact repository strip, matching the reference's IDE-style tab chrome. */ +.repo-tabbar { + display: flex; + align-items: stretch; + min-height: 36px; + height: 36px; + padding: 0; + border: 0; + border-bottom: 1px solid var(--color-border-subtle); + border-radius: 0; + overflow: hidden; +} + +.repo-tabs-scroll { + flex: 0 1 auto; + min-height: 35px; + min-width: 0; + border-left: 1px solid var(--color-border-subtle); + scrollbar-width: none; +} + +.repo-tabs-scroll::-webkit-scrollbar { display: none; } + +.repo-tab-wrap { + flex: 0 0 auto; + min-width: 0; + max-width: 260px; + min-height: 35px; + border-right: 1px solid var(--color-border-subtle); + border-radius: 0; +} + +.repo-tab { + min-height: 35px; + height: 35px; + padding: 0 31px 0 15px; + gap: 6px; + font-size: 12.5px; + font-weight: 650; + letter-spacing: 0; +} + +.repo-tab.management { + flex: 0 0 38px; + width: 38px; + min-width: 38px; + height: 35px; + min-height: 35px; + padding: 0; + justify-content: center; + border: 0; + box-shadow: none; +} + +.repo-tab-wrap.active, +.repo-tab.management.active { + box-shadow: none; +} + +.repo-tab-close { + position: absolute; + top: 8px; + right: 6px; + width: 19px; + min-width: 19px; + max-width: 19px; + height: 19px; + min-height: 19px; + max-height: 19px; + margin: 0; + border-radius: 2px; + color: var(--color-ink-faint); + background: transparent; + opacity: 0.62; +} + +.repo-tab-wrap.active .repo-tab-close { opacity: 0.72; } +.repo-tab-wrap:hover .repo-tab-close, +.repo-tab-wrap:focus-within .repo-tab-close { opacity: 1; } +.repo-tab-close:hover:not(:disabled), +.repo-tab-close:focus-visible:not(:disabled) { + color: #e1848b; + background: transparent; +} + +.repo-tab-add { + flex: 0 0 38px; + width: 38px; + min-width: 38px; + height: 35px; + min-height: 35px; + border-left: 1px solid var(--color-border-subtle); +} + +:root:not([data-theme="light"]) .repo-tabbar { + border-bottom-color: #41454c; + background: #2b2e34; + box-shadow: inset 0 -1px 0 #23262b; +} + +:root:not([data-theme="light"]) .repo-tabs-scroll { + border-left-color: #454950; +} + +:root:not([data-theme="light"]) .repo-tab-wrap, +:root:not([data-theme="light"]) .repo-tab.management, +:root:not([data-theme="light"]) .repo-tab-add { + border-color: #454950; + color: #9ca1a9; + background: #2b2e34; +} + +:root:not([data-theme="light"]) .repo-tab-wrap:hover, +:root:not([data-theme="light"]) .repo-tab.management:hover:not(:disabled), +:root:not([data-theme="light"]) .repo-tab-add:hover:not(:disabled) { + color: #d7dae0; + background: #32363d; +} + +:root:not([data-theme="light"]) .repo-tab-wrap.active, +:root:not([data-theme="light"]) .repo-tab.management.active { + color: #d7dae0; + background: #30343a; + box-shadow: inset 0 -1px 0 #30343a; +} + +:root:not([data-theme="light"]) .repo-tab-wrap.active .repo-tab, +:root:not([data-theme="light"]) .repo-tab-wrap.active .repo-tab > svg, +:root:not([data-theme="light"]) .repo-tab.management.active > svg { + color: #d7dae0; +} diff --git a/src/lib/RepoTabs.svelte b/src/lib/RepoTabs.svelte index 2fee15a..a838c9d 100644 --- a/src/lib/RepoTabs.svelte +++ b/src/lib/RepoTabs.svelte @@ -1,5 +1,5 @@