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:
@@ -38,6 +38,24 @@ struct GitLabProject {
|
||||
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)]
|
||||
struct GiteaRepository {
|
||||
id: u64,
|
||||
@@ -113,6 +131,14 @@ fn normalized_base_url(base_url: &str) -> Result<String, 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 {
|
||||
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<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(
|
||||
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<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(
|
||||
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
@@ -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;
|
||||
|
||||
+132
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
|
||||
import { Folder, GitBranch, Plus, X } from "@lucide/svelte";
|
||||
|
||||
interface RepositoryTabItem {
|
||||
path: string;
|
||||
@@ -28,8 +28,7 @@
|
||||
disabled={isBusy}
|
||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||
>
|
||||
<BookOpen size={14} aria-hidden="true" />
|
||||
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="repo-tabs-scroll">
|
||||
@@ -47,7 +46,7 @@
|
||||
disabled={isBusy}
|
||||
title={repo.path}
|
||||
>
|
||||
<Database size={15} aria-hidden="true" />
|
||||
<GitBranch size={13} aria-hidden="true" />
|
||||
<span>{repo.name}</span>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
<CloudCog size={16} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Integrationen" : "Integrations"}</strong>
|
||||
<small>GitLab, Azure DevOps & Gitea</small>
|
||||
<small>GitHub, GitLab, Azure DevOps & Gitea</small>
|
||||
</span>
|
||||
<em>{configuredIntegrationCount(integrationDraft)}</em>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
|
||||
import { listIntegrationRepositories } from "../git";
|
||||
@@ -33,6 +33,17 @@
|
||||
let repositoriesBySource = $state<Record<string, GitIntegrationRepository[]>>({});
|
||||
let loadingSourceId = $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;
|
||||
|
||||
const isGerman = $derived(language === "de");
|
||||
@@ -55,7 +66,100 @@
|
||||
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 {
|
||||
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
||||
@@ -171,7 +275,7 @@
|
||||
{:else}
|
||||
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
|
||||
{#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}
|
||||
<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}
|
||||
@@ -180,7 +284,8 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="repository-list" aria-live="polite">
|
||||
<div class="repository-list-shell">
|
||||
<div id="integration-repository-list" class="repository-list" bind:this={repositoryListElement} onscroll={updateRepositoryScrollbar} aria-live="polite">
|
||||
{#if loadingSourceId}
|
||||
<div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
|
||||
{:else if repositoryError}
|
||||
@@ -197,6 +302,33 @@
|
||||
{/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>
|
||||
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -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 input { height: 34px; padding-left: 31px; font-size: 11px; }
|
||||
.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:last-child { border-bottom: 0; }
|
||||
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 type { AppLanguage, AzureDevOpsOrganization, GitIntegrationConfig, GitIntegrationProvider, GitIntegrationSecretUpdate, GitIntegrationSettings } from "../types";
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
let { language, settings, onChange, onSecretsChange }: Props = $props();
|
||||
let selected = $state<GitIntegrationProvider>("gitlab");
|
||||
let selected = $state<GitIntegrationProvider>("github");
|
||||
let selectedAzureOrganizationId = $state("");
|
||||
let tokenValues = $state<Record<string, string>>({});
|
||||
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: "",
|
||||
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 {
|
||||
const descriptions = isGerman
|
||||
? { 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: "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" }
|
||||
: { 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];
|
||||
}
|
||||
|
||||
function providerColor(provider: GitIntegrationProvider): string {
|
||||
return provider === "github" ? "var(--color-ink)" : `#${providerIcons[provider].hex}`;
|
||||
}
|
||||
|
||||
function secretId(provider: GitIntegrationProvider, accountId?: string): string {
|
||||
return accountId ? `${provider}:${accountId}` : provider;
|
||||
}
|
||||
@@ -150,6 +154,7 @@
|
||||
|
||||
function baseUrlPlaceholder(): string {
|
||||
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-self-hosted") return "https://gitlab.example.com";
|
||||
return "https://gitea.example.com";
|
||||
@@ -160,7 +165,7 @@
|
||||
<div class="integration-providers" role="tablist" aria-label={isGerman ? "Git-Anbieter" : "Git providers"}>
|
||||
{#each gitIntegrationProviders as 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-state" class:configured={isConfigured(provider)} title={isConfigured(provider) ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}></span>
|
||||
</button>
|
||||
@@ -169,7 +174,7 @@
|
||||
|
||||
<section class="integration-config" aria-label={`${providerLabel(selected)} ${isGerman ? "konfigurieren" : "configuration"}`}>
|
||||
<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>
|
||||
{#if selected === "azure-devops"}
|
||||
<span class="integration-status" class:configured={isConfigured(selected)}><Building2 size={13} />{settings.azureDevOpsOrganizations.length} {isGerman ? "Orgas" : "orgs"}</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
export const gitIntegrationProviders: GitIntegrationProvider[] = [
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitlab-self-hosted",
|
||||
"azure-devops",
|
||||
@@ -14,6 +15,12 @@ export const gitIntegrationProviders: GitIntegrationProvider[] = [
|
||||
];
|
||||
|
||||
const defaults: Record<GitIntegrationProvider, Omit<GitIntegrationConfig, "provider">> = {
|
||||
github: {
|
||||
enabled: false,
|
||||
baseUrl: "https://github.com",
|
||||
username: "",
|
||||
tokenStored: false,
|
||||
},
|
||||
gitlab: {
|
||||
enabled: false,
|
||||
baseUrl: "https://gitlab.com",
|
||||
@@ -144,6 +151,7 @@ export function configuredIntegrationCount(settings: GitIntegrationSettings): nu
|
||||
|
||||
export function providerLabel(provider: GitIntegrationProvider): string {
|
||||
return {
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab.com",
|
||||
"gitlab-self-hosted": "GitLab Self-Managed",
|
||||
"azure-devops": "Azure DevOps",
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export type AppTheme = "system" | "light" | "dark";
|
||||
export type AppAppearance = "modern" | "classic" | "custom";
|
||||
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 {
|
||||
provider: GitIntegrationProvider;
|
||||
|
||||
Reference in New Issue
Block a user