feat(integrations): add Git hosting integrations and repo listing

Add support for integrating with external Git hosts (GitLab, Gitea,
and Azure DevOps). The backend gains a client to fetch paginated
repository lists, normalise base URLs, and surface provider errors.
Credentials are loaded from the OS keychain and a Tauri command is
exposed for the frontend to list integration repositories.

- Implement integration client with pagination, deserialization,
  and provider-specific handling.
- Centralise keychain credential loading and expose listing command.
- Update UI to manage integration metadata, persist settings, and
  save/remove tokens to the OS keychain for cloning and operations.
This commit is contained in:
2026-08-29 23:25:51 +02:00
parent 823a50ce85
commit 91263547db
10 changed files with 1190 additions and 130 deletions
+65 -2
View File
@@ -117,6 +117,7 @@
launchExternalMerge,
launchExternalTool,
credLoad,
credDelete,
credSave,
getFilePatch,
readConflict,
@@ -158,6 +159,9 @@
ExplorerNodeKind,
ExternalDiffScope,
ExternalToolsSettings,
GitIntegrationSecretUpdate,
GitIntegrationSettings,
GitIntegrationProvider,
GitBlameLine,
GitBranch as GitBranchInfo,
GitCommit,
@@ -190,6 +194,11 @@
normaliseExternalToolsSettings,
resolveDetectedExternalToolPrograms,
} from "./lib/externalTools";
import {
defaultGitIntegrationSettings,
integrationCredentialKey,
normaliseGitIntegrationSettings,
} from "./lib/integrations";
import {
orgKeyFromUrl,
@@ -259,6 +268,7 @@
const CUSTOM_THEME_KEY = "gitlite.customTheme.v1";
const APP_LANGUAGE_KEY = "gitlite.language.v1";
const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.v1";
const GIT_INTEGRATIONS_SETTINGS_KEY = "gitlite.integrations.v1";
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
@@ -372,6 +382,7 @@
let customTheme: CustomThemeColors = loadCustomTheme();
let appLanguage: AppLanguage = loadLanguagePreference();
let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
let gitIntegrationSettings: GitIntegrationSettings = loadGitIntegrationSettings();
let externalToolsConfigured = hasStoredExternalToolsSettings();
let detectedExternalTools: DetectedExternalTool[] = [];
let externalToolsDetectionPending = true;
@@ -1260,7 +1271,33 @@
if (appTheme === "system") applyThemePreference(appTheme);
}
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextAppearance: AppAppearance, nextCustomTheme: CustomThemeColors, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
async function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextAppearance: AppAppearance, nextCustomTheme: CustomThemeColors, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings, nextIntegrations: GitIntegrationSettings, integrationSecrets: GitIntegrationSecretUpdate[]) {
const integrationsToSave = structuredClone(nextIntegrations);
try {
for (const update of integrationSecrets) {
const key = integrationCredentialKey(update.provider, update.accountId);
const azureOrganization = update.provider === "azure-devops" && update.accountId
? integrationsToSave.azureDevOpsOrganizations.find((organization) => organization.id === update.accountId)
: undefined;
const providerConfig = integrationsToSave.providers[update.provider];
if (update.removeToken) {
await credDelete(key);
if (azureOrganization) azureOrganization.tokenStored = false;
else providerConfig.tokenStored = false;
} else if (update.token) {
const username = (azureOrganization?.username ?? providerConfig.username).trim() || "oauth2";
await credSave(key, username, update.token, "token");
if (azureOrganization) azureOrganization.tokenStored = true;
else providerConfig.tokenStored = true;
}
}
} catch (error) {
errorMessage = appLanguage === "de"
? `Integration konnte nicht gespeichert werden: ${String(error)}`
: `Could not save integration: ${String(error)}`;
return;
}
const autoRefreshWasEnabled = autoRefreshEnabled;
analyticsSettings = next;
appTheme = nextTheme;
@@ -1269,12 +1306,14 @@
appLanguage = nextLanguage;
autoRefreshEnabled = nextAutoRefresh;
externalToolsSettings = nextExternalTools;
gitIntegrationSettings = integrationsToSave;
persistAnalyticsSettings(next);
persistThemePreference(nextTheme);
persistAppearancePreference(nextAppearance, nextCustomTheme);
persistLanguagePreference(nextLanguage);
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
persistExternalToolsSettings(nextExternalTools);
persistGitIntegrationSettings(integrationsToSave);
externalToolsConfigured = true;
setTelemetryEnabled(next.enabled);
appSettingsOpen = false;
@@ -2578,6 +2617,11 @@
trackEvent("clone_dialog_opened");
}
function cloneFromDialog(remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) {
const credentialKey = provider ? integrationCredentialKey(provider, accountId) : undefined;
void cloneRepo(remoteUrl, parentPath, directoryName, undefined, undefined, credentialKey, false, provider ? "token" : "credentials");
}
function openRepoManagement() {
if (isBusy) return;
activeView = "management";
@@ -4587,6 +4631,22 @@
}
}
function loadGitIntegrationSettings(): GitIntegrationSettings {
try {
return normaliseGitIntegrationSettings(JSON.parse(localStorage.getItem(GIT_INTEGRATIONS_SETTINGS_KEY) ?? "null"));
} catch {
return defaultGitIntegrationSettings();
}
}
function persistGitIntegrationSettings(next: GitIntegrationSettings) {
try {
localStorage.setItem(GIT_INTEGRATIONS_SETTINGS_KEY, JSON.stringify(next));
} catch {
// Metadata persistence is best-effort; tokens remain in the OS keychain.
}
}
function hasStoredExternalToolsSettings(): boolean {
try {
return localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) != null;
@@ -5710,6 +5770,7 @@
language={appLanguage}
autoRefresh={autoRefreshEnabled}
externalTools={externalToolsSettings}
integrations={gitIntegrationSettings}
detectedTools={detectedExternalTools}
detectionPending={externalToolsDetectionPending}
detectionUnavailable={externalToolsDetectionUnavailable}
@@ -6043,7 +6104,9 @@
<CloneRepositoryDialog
isBusy={operation === "Cloning repository"}
error={cloneDialogError}
onClone={cloneRepo}
language={appLanguage}
integrations={gitIntegrationSettings}
onClone={cloneFromDialog}
onClose={() => { if (!isBusy) cloneDialogOpen = false; }}
/>
{/if}