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.
This commit is contained in:
2026-08-29 23:55:27 +02:00
parent 91263547db
commit c242a72edd
9 changed files with 414 additions and 34 deletions
+94
View File
@@ -38,6 +38,24 @@ struct GitLabProject {
visibility: String, visibility: String,
} }
#[derive(Debug, Deserialize)]
struct GitHubRepository {
id: u64,
name: String,
full_name: String,
#[serde(default)]
description: Option<String>,
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)] #[derive(Debug, Deserialize)]
struct GiteaRepository { struct GiteaRepository {
id: u64, id: u64,
@@ -113,6 +131,14 @@ fn normalized_base_url(base_url: &str) -> Result<String, String> {
Ok(base_url.to_string()) Ok(base_url.to_string())
} }
fn github_api_base_url(base_url: &str) -> Result<String, String> {
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 { fn response_error(response: Response, provider: &str) -> String {
let status = response.status(); let status = response.status();
let detail = response.text().ok().and_then(|body| { let detail = response.text().ok().and_then(|body| {
@@ -188,6 +214,57 @@ fn gitlab_repositories(
Ok(repositories) Ok(repositories)
} }
fn github_repositories(
client: &Client,
base_url: &str,
token: &str,
) -> Result<Vec<IntegrationRepository>, 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::<Vec<GitHubRepository>>()
.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( fn gitea_repositories(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
@@ -286,6 +363,7 @@ pub async fn list_integration_repositories(
let base_url = normalized_base_url(&base_url)?; let base_url = normalized_base_url(&base_url)?;
let client = client()?; let client = client()?;
match provider.as_str() { match provider.as_str() {
"github" => github_repositories(&client, &base_url, &credential.password),
"gitlab" | "gitlab-self-hosted" => { "gitlab" | "gitlab-self-hosted" => {
gitlab_repositories(&client, &base_url, &credential.password) gitlab_repositories(&client, &base_url, &credential.password)
} }
@@ -318,10 +396,19 @@ mod tests {
"https://gitlab.example.com" "https://gitlab.example.com"
); );
assert!(normalized_base_url("gitlab.example.com").is_err()); 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] #[test]
fn integration_credential_keys_match_the_frontend() { 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("gitea", None).unwrap(), "integration:gitea");
assert_eq!( assert_eq!(
integration_key("azure-devops", Some("org-123")).unwrap(), integration_key("azure-devops", Some("org-123")).unwrap(),
@@ -336,6 +423,13 @@ mod tests {
#[test] #[test]
fn provider_repository_payloads_deserialize() { fn provider_repository_payloads_deserialize() {
let github: Vec<GitHubRepository> = 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<GitLabProject> = serde_json::from_str( let gitlab: Vec<GitLabProject> = 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"}]"#, 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"}]"#,
) )
+2 -1
View File
@@ -1285,7 +1285,8 @@
if (azureOrganization) azureOrganization.tokenStored = false; if (azureOrganization) azureOrganization.tokenStored = false;
else providerConfig.tokenStored = false; else providerConfig.tokenStored = false;
} else if (update.token) { } 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"); await credSave(key, username, update.token, "token");
if (azureOrganization) azureOrganization.tokenStored = true; if (azureOrganization) azureOrganization.tokenStored = true;
else providerConfig.tokenStored = true; else providerConfig.tokenStored = true;
+132
View File
@@ -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-footer { align-items: stretch; flex-direction: column; }
.cred-btns { display: grid; grid-template-columns: 1fr 1fr; } .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;
}
+3 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { BookOpen, Database, Plus, X } from "@lucide/svelte"; import { Folder, GitBranch, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem { interface RepositoryTabItem {
path: string; path: string;
@@ -28,8 +28,7 @@
disabled={isBusy} disabled={isBusy}
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"} title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
> >
<BookOpen size={14} aria-hidden="true" /> <Folder size={15} aria-hidden="true" />
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
</button> </button>
<div class="repo-tabs-scroll"> <div class="repo-tabs-scroll">
@@ -47,7 +46,7 @@
disabled={isBusy} disabled={isBusy}
title={repo.path} title={repo.path}
> >
<Database size={15} aria-hidden="true" /> <GitBranch size={13} aria-hidden="true" />
<span>{repo.name}</span> <span>{repo.name}</span>
</button> </button>
<button <button
+1 -1
View File
@@ -337,7 +337,7 @@
<CloudCog size={16} aria-hidden="true" /> <CloudCog size={16} aria-hidden="true" />
<span> <span>
<strong>{isGerman ? "Integrationen" : "Integrations"}</strong> <strong>{isGerman ? "Integrationen" : "Integrations"}</strong>
<small>GitLab, Azure DevOps & Gitea</small> <small>GitHub, GitLab, Azure DevOps & Gitea</small>
</span> </span>
<em>{configuredIntegrationCount(integrationDraft)}</em> <em>{configuredIntegrationCount(integrationDraft)}</em>
</button> </button>
+161 -20
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte"; import { onDestroy, onMount } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte"; import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
import { listIntegrationRepositories } from "../git"; import { listIntegrationRepositories } from "../git";
@@ -33,6 +33,17 @@
let repositoriesBySource = $state<Record<string, GitIntegrationRepository[]>>({}); let repositoriesBySource = $state<Record<string, GitIntegrationRepository[]>>({});
let loadingSourceId = $state(""); let loadingSourceId = $state("");
let repositoryError = $state(""); let repositoryError = $state("");
let repositoryListElement = $state<HTMLDivElement>();
let repositoryScrollbarElement = $state<HTMLDivElement>();
let repositoryScrollbarVisible = $state(false);
let repositoryScrollbarTop = $state(0);
let repositoryScrollbarHeight = $state(28);
let repositoryScrollTop = $state(0);
let repositoryScrollMax = $state(0);
let repositoryScrollbarPointerId = $state<number>();
let repositoryScrollbarDragY = 0;
let repositoryScrollbarDragScrollTop = 0;
let repositoryScrollbarFrame: number | undefined;
let repositoryRequestId = 0; let repositoryRequestId = 0;
const isGerman = $derived(language === "de"); const isGerman = $derived(language === "de");
@@ -55,7 +66,100 @@
if (nextError) errorHideTimer = setTimeout(() => { visibleError = ""; }, 6000); if (nextError) errorHideTimer = setTimeout(() => { visibleError = ""; }, 6000);
}); });
onDestroy(() => { if (errorHideTimer) clearTimeout(errorHideTimer); }); $effect(() => {
filteredRepositories.length;
loadingSourceId;
repositoryError;
scheduleRepositoryScrollbarUpdate();
});
onMount(() => {
window.addEventListener("resize", scheduleRepositoryScrollbarUpdate);
return () => window.removeEventListener("resize", scheduleRepositoryScrollbarUpdate);
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
});
function scheduleRepositoryScrollbarUpdate() {
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
repositoryScrollbarFrame = requestAnimationFrame(() => {
repositoryScrollbarFrame = undefined;
updateRepositoryScrollbar();
});
}
function updateRepositoryScrollbar() {
const list = repositoryListElement;
const track = repositoryScrollbarElement;
if (!list || !track) {
repositoryScrollbarVisible = false;
return;
}
const scrollMax = Math.max(0, list.scrollHeight - list.clientHeight);
const trackHeight = track.clientHeight;
const thumbHeight = scrollMax > 0
? Math.max(28, trackHeight * (list.clientHeight / list.scrollHeight))
: trackHeight;
const thumbTravel = Math.max(0, trackHeight - thumbHeight);
repositoryScrollTop = list.scrollTop;
repositoryScrollMax = scrollMax;
repositoryScrollbarHeight = thumbHeight;
repositoryScrollbarTop = scrollMax > 0 ? (list.scrollTop / scrollMax) * thumbTravel : 0;
repositoryScrollbarVisible = scrollMax > 1;
}
function startRepositoryScrollbarDrag(event: PointerEvent) {
if (!repositoryListElement) return;
event.preventDefault();
event.stopPropagation();
repositoryScrollbarPointerId = event.pointerId;
repositoryScrollbarDragY = event.clientY;
repositoryScrollbarDragScrollTop = repositoryListElement.scrollTop;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function dragRepositoryScrollbar(event: PointerEvent) {
if (repositoryScrollbarPointerId !== event.pointerId || !repositoryListElement || !repositoryScrollbarElement) return;
const thumbTravel = repositoryScrollbarElement.clientHeight - repositoryScrollbarHeight;
if (thumbTravel <= 0) return;
repositoryListElement.scrollTop = repositoryScrollbarDragScrollTop
+ ((event.clientY - repositoryScrollbarDragY) / thumbTravel) * repositoryScrollMax;
}
function stopRepositoryScrollbarDrag(event: PointerEvent) {
if (repositoryScrollbarPointerId !== event.pointerId) return;
repositoryScrollbarPointerId = undefined;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function jumpRepositoryScrollbar(event: MouseEvent) {
if (event.target !== event.currentTarget || !repositoryListElement || !repositoryScrollbarElement) return;
const track = repositoryScrollbarElement.getBoundingClientRect();
const thumbTravel = track.height - repositoryScrollbarHeight;
if (thumbTravel <= 0) return;
const targetTop = Math.max(0, Math.min(thumbTravel, event.clientY - track.top - repositoryScrollbarHeight / 2));
repositoryListElement.scrollTop = (targetTop / thumbTravel) * repositoryScrollMax;
}
function handleRepositoryScrollbarKey(event: KeyboardEvent) {
if (!repositoryListElement) return;
const page = repositoryListElement.clientHeight * 0.85;
const changes: Record<string, number> = {
ArrowUp: repositoryListElement.scrollTop - 40,
ArrowDown: repositoryListElement.scrollTop + 40,
PageUp: repositoryListElement.scrollTop - page,
PageDown: repositoryListElement.scrollTop + page,
Home: 0,
End: repositoryScrollMax,
};
if (!(event.key in changes)) return;
event.preventDefault();
repositoryListElement.scrollTop = changes[event.key];
}
function directoryNameFromRemoteUrl(url: string): string { function directoryNameFromRemoteUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? ""; const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
@@ -171,7 +275,7 @@
{:else} {:else}
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}> <section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
{#if configuredSources.length === 0} {#if configuredSources.length === 0}
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitLab, Azure DevOps oder Gitea ein." : "Set up GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div> <div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
{:else} {:else}
<div class="integration-provider-tabs" role="tablist" aria-label={isGerman ? "Konfigurierte Anbieter" : "Configured providers"}> <div class="integration-provider-tabs" role="tablist" aria-label={isGerman ? "Konfigurierte Anbieter" : "Configured providers"}>
{#each configuredSources as integrationSource}<button type="button" role="tab" aria-selected={selectedSourceId === integrationSource.id} class:active={selectedSourceId === integrationSource.id} onclick={() => loadRepositories(integrationSource)}>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</button>{/each} {#each configuredSources as integrationSource}<button type="button" role="tab" aria-selected={selectedSourceId === integrationSource.id} class:active={selectedSourceId === integrationSource.id} onclick={() => loadRepositories(integrationSource)}>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</button>{/each}
@@ -180,22 +284,50 @@
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories filtern…" : "Filter repositories…"} aria-label={isGerman ? "Repositories filtern" : "Filter repositories"} /></label> <label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories filtern…" : "Filter repositories…"} aria-label={isGerman ? "Repositories filtern" : "Filter repositories"} /></label>
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button> <button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
</div> </div>
<div class="repository-list" aria-live="polite"> <div class="repository-list-shell">
{#if loadingSourceId} <div id="integration-repository-list" class="repository-list" bind:this={repositoryListElement} onscroll={updateRepositoryScrollbar} aria-live="polite">
<div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div> {#if loadingSourceId}
{:else if repositoryError} <div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div> {:else if repositoryError}
{:else if filteredRepositories.length === 0} <div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div> {:else if filteredRepositories.length === 0}
{:else} <div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
{#each filteredRepositories as repository (repository.id)} {:else}
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}> {#each filteredRepositories as repository (repository.id)}
<span class="repository-option-icon"><GitBranch size={16} /></span> <button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
<span class="repository-option-copy"><strong>{repository.fullName}</strong><small>{repository.description || repository.cloneUrl}</small></span> <span class="repository-option-icon"><GitBranch size={16} /></span>
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span> <span class="repository-option-copy"><strong>{repository.fullName}</strong><small>{repository.description || repository.cloneUrl}</small></span>
</button> <span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
{/each} </button>
{/if} {/each}
{/if}
</div>
<div
class="repository-scrollbar"
class:visible={repositoryScrollbarVisible}
class:dragging={repositoryScrollbarPointerId !== undefined}
bind:this={repositoryScrollbarElement}
role="scrollbar"
tabindex={repositoryScrollbarVisible ? 0 : -1}
aria-controls="integration-repository-list"
aria-label={isGerman ? "Repository-Liste scrollen" : "Scroll repository list"}
aria-orientation="vertical"
aria-valuemin="0"
aria-valuemax={repositoryScrollMax}
aria-valuenow={repositoryScrollTop}
onclick={jumpRepositoryScrollbar}
onkeydown={handleRepositoryScrollbarKey}
>
<div
class="repository-scrollbar-thumb"
role="presentation"
style={`height:${repositoryScrollbarHeight}px;transform:translateY(${repositoryScrollbarTop}px)`}
onpointerdown={startRepositoryScrollbarDrag}
onpointermove={dragRepositoryScrollbar}
onpointerup={stopRepositoryScrollbarDrag}
onpointercancel={stopRepositoryScrollbarDrag}
></div>
</div>
</div> </div>
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if} {#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
{/if} {/if}
@@ -229,7 +361,16 @@
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); } .repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; } .repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
.repository-toolbar button { min-height: 32px; padding: 0; } .repository-toolbar button { min-height: 32px; padding: 0; }
.repository-list { min-height: 162px; max-height: 250px; overflow: auto; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); } .repository-list-shell { position: relative; min-height: 162px; max-height: 250px; overflow: hidden; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.repository-list { min-height: 160px; max-height: 248px; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
.repository-scrollbar-thumb { position: absolute; top: 0; right: 2px; width: 3px; min-height: 28px; border-radius: 3px; background: var(--app-scrollbar-thumb); cursor: pointer; transition: width 100ms ease, background 100ms ease; }
.repository-scrollbar:hover .repository-scrollbar-thumb,
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; min-height: 52px; padding: 7px 9px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; } .repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; min-height: 52px; padding: 7px 9px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
.repository-option:last-child { border-bottom: 0; } .repository-option:last-child { border-bottom: 0; }
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); } .repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Building2, CheckCircle2, CircleDashed, Eye, EyeOff, KeyRound, Plus, Server, Trash2 } from "@lucide/svelte"; import { Building2, CheckCircle2, CircleDashed, Eye, EyeOff, KeyRound, Plus, Server, Trash2 } from "@lucide/svelte";
import { siGitea, siGitlab, type SimpleIcon } from "simple-icons"; import { siGitea, siGithub, siGitlab, type SimpleIcon } from "simple-icons";
import { gitIntegrationProviders, organizationNameFromUrl, providerLabel } from "../integrations"; import { gitIntegrationProviders, organizationNameFromUrl, providerLabel } from "../integrations";
import type { AppLanguage, AzureDevOpsOrganization, GitIntegrationConfig, GitIntegrationProvider, GitIntegrationSecretUpdate, GitIntegrationSettings } from "../types"; import type { AppLanguage, AzureDevOpsOrganization, GitIntegrationConfig, GitIntegrationProvider, GitIntegrationSecretUpdate, GitIntegrationSettings } from "../types";
@@ -12,7 +12,7 @@
} }
let { language, settings, onChange, onSecretsChange }: Props = $props(); let { language, settings, onChange, onSecretsChange }: Props = $props();
let selected = $state<GitIntegrationProvider>("gitlab"); let selected = $state<GitIntegrationProvider>("github");
let selectedAzureOrganizationId = $state(""); let selectedAzureOrganizationId = $state("");
let tokenValues = $state<Record<string, string>>({}); let tokenValues = $state<Record<string, string>>({});
let secretUpdates = $state<Record<string, GitIntegrationSecretUpdate>>({}); let secretUpdates = $state<Record<string, GitIntegrationSecretUpdate>>({});
@@ -27,15 +27,19 @@
title: "Azure DevOps", slug: "azuredevops", hex: "0078D4", source: "https://azure.microsoft.com/products/devops", svg: "", title: "Azure DevOps", slug: "azuredevops", hex: "0078D4", source: "https://azure.microsoft.com/products/devops", svg: "",
path: "M0 8.877 2.247 5.91l8.405-3.416v19.127l-8.405-3.53L0 15.123V8.877Zm12.154-6.968 11.846 2.423v15.336l-11.846 2.423V1.909Z", path: "M0 8.877 2.247 5.91l8.405-3.416v19.127l-8.405-3.53L0 15.123V8.877Zm12.154-6.968 11.846 2.423v15.336l-11.846 2.423V1.909Z",
}; };
const providerIcons: Record<GitIntegrationProvider, SimpleIcon> = { gitlab: siGitlab, "gitlab-self-hosted": siGitlab, "azure-devops": azureDevOpsIcon, gitea: siGitea }; const providerIcons: Record<GitIntegrationProvider, SimpleIcon> = { github: siGithub, gitlab: siGitlab, "gitlab-self-hosted": siGitlab, "azure-devops": azureDevOpsIcon, gitea: siGitea };
function providerDescription(provider: GitIntegrationProvider): string { function providerDescription(provider: GitIntegrationProvider): string {
const descriptions = isGerman const descriptions = isGerman
? { gitlab: "Cloud-Konto auf gitlab.com", "gitlab-self-hosted": "Eigene GitLab-Instanz", "azure-devops": "Mehrere Organisationen", gitea: "Cloud- oder eigene Instanz" } ? { github: "Konto auf github.com", gitlab: "Cloud-Konto auf gitlab.com", "gitlab-self-hosted": "Eigene GitLab-Instanz", "azure-devops": "Mehrere Organisationen", gitea: "Cloud- oder eigene Instanz" }
: { gitlab: "Cloud account on gitlab.com", "gitlab-self-hosted": "Your own GitLab instance", "azure-devops": "Multiple organizations", gitea: "Cloud or self-hosted instance" }; : { github: "Cloud account on github.com", gitlab: "Cloud account on gitlab.com", "gitlab-self-hosted": "Your own GitLab instance", "azure-devops": "Multiple organizations", gitea: "Cloud or self-hosted instance" };
return descriptions[provider]; return descriptions[provider];
} }
function providerColor(provider: GitIntegrationProvider): string {
return provider === "github" ? "var(--color-ink)" : `#${providerIcons[provider].hex}`;
}
function secretId(provider: GitIntegrationProvider, accountId?: string): string { function secretId(provider: GitIntegrationProvider, accountId?: string): string {
return accountId ? `${provider}:${accountId}` : provider; return accountId ? `${provider}:${accountId}` : provider;
} }
@@ -150,6 +154,7 @@
function baseUrlPlaceholder(): string { function baseUrlPlaceholder(): string {
if (selected === "azure-devops") return "https://dev.azure.com/meine-organisation"; if (selected === "azure-devops") return "https://dev.azure.com/meine-organisation";
if (selected === "github") return "https://github.com";
if (selected === "gitlab") return "https://gitlab.com"; if (selected === "gitlab") return "https://gitlab.com";
if (selected === "gitlab-self-hosted") return "https://gitlab.example.com"; if (selected === "gitlab-self-hosted") return "https://gitlab.example.com";
return "https://gitea.example.com"; return "https://gitea.example.com";
@@ -160,7 +165,7 @@
<div class="integration-providers" role="tablist" aria-label={isGerman ? "Git-Anbieter" : "Git providers"}> <div class="integration-providers" role="tablist" aria-label={isGerman ? "Git-Anbieter" : "Git providers"}>
{#each gitIntegrationProviders as provider} {#each gitIntegrationProviders as provider}
<button type="button" role="tab" aria-selected={selected === provider} class:active={selected === provider} onclick={() => selectProvider(provider)}> <button type="button" role="tab" aria-selected={selected === provider} class:active={selected === provider} onclick={() => selectProvider(provider)}>
<span class="provider-logo" style={`--provider-color:#${providerIcons[provider].hex}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[provider].path} /></svg></span> <span class="provider-logo" style={`--provider-color:${providerColor(provider)}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[provider].path} /></svg></span>
<span class="provider-copy"><strong>{providerLabel(provider)}</strong><small>{providerDescription(provider)}</small></span> <span class="provider-copy"><strong>{providerLabel(provider)}</strong><small>{providerDescription(provider)}</small></span>
<span class="provider-state" class:configured={isConfigured(provider)} title={isConfigured(provider) ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}></span> <span class="provider-state" class:configured={isConfigured(provider)} title={isConfigured(provider) ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}></span>
</button> </button>
@@ -169,7 +174,7 @@
<section class="integration-config" aria-label={`${providerLabel(selected)} ${isGerman ? "konfigurieren" : "configuration"}`}> <section class="integration-config" aria-label={`${providerLabel(selected)} ${isGerman ? "konfigurieren" : "configuration"}`}>
<header class="integration-summary"> <header class="integration-summary">
<span class="provider-logo provider-logo-large" style={`--provider-color:#${providerIcons[selected].hex}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[selected].path} /></svg></span> <span class="provider-logo provider-logo-large" style={`--provider-color:${providerColor(selected)}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[selected].path} /></svg></span>
<div><h4>{providerLabel(selected)}</h4><p>{providerDescription(selected)}</p></div> <div><h4>{providerLabel(selected)}</h4><p>{providerDescription(selected)}</p></div>
{#if selected === "azure-devops"} {#if selected === "azure-devops"}
<span class="integration-status" class:configured={isConfigured(selected)}><Building2 size={13} />{settings.azureDevOpsOrganizations.length} {isGerman ? "Orgas" : "orgs"}</span> <span class="integration-status" class:configured={isConfigured(selected)}><Building2 size={13} />{settings.azureDevOpsOrganizations.length} {isGerman ? "Orgas" : "orgs"}</span>
+8
View File
@@ -7,6 +7,7 @@ import type {
} from "./types"; } from "./types";
export const gitIntegrationProviders: GitIntegrationProvider[] = [ export const gitIntegrationProviders: GitIntegrationProvider[] = [
"github",
"gitlab", "gitlab",
"gitlab-self-hosted", "gitlab-self-hosted",
"azure-devops", "azure-devops",
@@ -14,6 +15,12 @@ export const gitIntegrationProviders: GitIntegrationProvider[] = [
]; ];
const defaults: Record<GitIntegrationProvider, Omit<GitIntegrationConfig, "provider">> = { const defaults: Record<GitIntegrationProvider, Omit<GitIntegrationConfig, "provider">> = {
github: {
enabled: false,
baseUrl: "https://github.com",
username: "",
tokenStored: false,
},
gitlab: { gitlab: {
enabled: false, enabled: false,
baseUrl: "https://gitlab.com", baseUrl: "https://gitlab.com",
@@ -144,6 +151,7 @@ export function configuredIntegrationCount(settings: GitIntegrationSettings): nu
export function providerLabel(provider: GitIntegrationProvider): string { export function providerLabel(provider: GitIntegrationProvider): string {
return { return {
github: "GitHub",
gitlab: "GitLab.com", gitlab: "GitLab.com",
"gitlab-self-hosted": "GitLab Self-Managed", "gitlab-self-hosted": "GitLab Self-Managed",
"azure-devops": "Azure DevOps", "azure-devops": "Azure DevOps",
+1 -1
View File
@@ -16,7 +16,7 @@ export type AppTheme = "system" | "light" | "dark";
export type AppAppearance = "modern" | "classic" | "custom"; export type AppAppearance = "modern" | "classic" | "custom";
export type AppLanguage = "en" | "de"; export type AppLanguage = "en" | "de";
export type GitIntegrationProvider = "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea"; export type GitIntegrationProvider = "github" | "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea";
export interface GitIntegrationConfig { export interface GitIntegrationConfig {
provider: GitIntegrationProvider; provider: GitIntegrationProvider;