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,
|
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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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>
|
<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">
|
||||||
|
<div id="integration-repository-list" class="repository-list" bind:this={repositoryListElement} onscroll={updateRepositoryScrollbar} aria-live="polite">
|
||||||
{#if loadingSourceId}
|
{#if loadingSourceId}
|
||||||
<div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
|
<div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
|
||||||
{:else if repositoryError}
|
{:else if repositoryError}
|
||||||
@@ -197,6 +302,33 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user