diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 8bbfaae..13a9403 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -2878,6 +2878,17 @@ fn cred_entry(key: &str) -> Result { keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}")) } +pub(crate) fn load_stored_credential(key: &str) -> Result, String> { + let entry = cred_entry(key)?; + match entry.get_password() { + Ok(json) => serde_json::from_str::(&json) + .map(Some) + .map_err(|err| format!("Stored credentials unreadable: {err}")), + Err(keyring::Error::NoEntry) => Ok(None), + Err(err) => Err(format!("Keychain access failed: {err}")), + } +} + /// Returns the remote URL used for auth key derivation (upstream remote of the /// current branch, falling back to `origin`, then the first configured remote). #[tauri::command(async)] @@ -3061,16 +3072,7 @@ fn push_args_for_repo_to( #[tauri::command(async)] pub fn cred_load(key: String) -> Result, String> { - let entry = cred_entry(&key)?; - match entry.get_password() { - Ok(json) => { - let cred = serde_json::from_str::(&json) - .map_err(|err| format!("Stored credentials unreadable: {err}"))?; - Ok(Some(cred)) - } - Err(keyring::Error::NoEntry) => Ok(None), - Err(err) => Err(format!("Keychain access failed: {err}")), - } + load_stored_credential(&key) } #[tauri::command(async)] diff --git a/src-tauri/src/integrations.rs b/src-tauri/src/integrations.rs new file mode 100644 index 0000000..24da132 --- /dev/null +++ b/src-tauri/src/integrations.rs @@ -0,0 +1,363 @@ +use crate::git::load_stored_credential; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{ACCEPT, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +const PAGE_SIZE: usize = 100; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IntegrationRepository { + pub id: String, + pub name: String, + pub full_name: String, + pub description: String, + pub clone_url: String, + pub ssh_url: String, + pub web_url: String, + pub updated_at: String, + pub private: bool, +} + +#[derive(Debug, Deserialize)] +struct GitLabProject { + id: u64, + name: String, + path_with_namespace: String, + #[serde(default)] + description: Option, + http_url_to_repo: String, + #[serde(default)] + ssh_url_to_repo: String, + #[serde(default)] + web_url: String, + #[serde(default)] + last_activity_at: String, + #[serde(default)] + visibility: String, +} + +#[derive(Debug, Deserialize)] +struct GiteaRepository { + id: u64, + name: String, + full_name: String, + #[serde(default)] + description: 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 AzureRepositoryList { + #[serde(default)] + value: Vec, +} + +#[derive(Debug, Deserialize)] +struct AzureRepository { + id: String, + name: String, + project: AzureProject, + #[serde(default, rename = "remoteUrl")] + remote_url: String, + #[serde(default, rename = "sshUrl")] + ssh_url: String, + #[serde(default, rename = "webUrl")] + web_url: String, +} + +#[derive(Debug, Deserialize)] +struct AzureProject { + name: String, +} + +fn integration_key(provider: &str, account_id: Option<&str>) -> Result { + if provider == "azure-devops" { + if let Some(account_id) = account_id.filter(|value| !value.is_empty()) { + if account_id.len() > 80 + || !account_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + { + return Err("Invalid integration account identifier.".to_string()); + } + if account_id != "default" { + return Ok(format!("integration:{provider}:{account_id}")); + } + } + } + Ok(format!("integration:{provider}")) +} + +fn client() -> Result { + Client::builder() + .timeout(Duration::from_secs(25)) + .build() + .map_err(|err| format!("Could not initialize the integration client: {err}")) +} + +fn normalized_base_url(base_url: &str) -> Result { + let base_url = base_url.trim().trim_end_matches('/'); + if !(base_url.starts_with("https://") || base_url.starts_with("http://")) { + return Err("The integration URL must start with http:// or https://.".to_string()); + } + Ok(base_url.to_string()) +} + +fn response_error(response: Response, provider: &str) -> String { + let status = response.status(); + let detail = response.text().ok().and_then(|body| { + serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value + .get("message") + .or_else(|| value.get("error")) + .and_then(|value| value.as_str()) + .map(str::to_string) + }) + }); + match detail { + Some(detail) if !detail.trim().is_empty() => { + format!("{provider} returned {status}: {detail}") + } + _ => format!("{provider} returned {status}."), + } +} + +fn gitlab_repositories( + client: &Client, + base_url: &str, + token: &str, +) -> Result, String> { + let mut repositories = Vec::new(); + let mut page = 1usize; + loop { + let response = client + .get(format!("{base_url}/api/v4/projects")) + .header(USER_AGENT, "Gitty") + .header(ACCEPT, "application/json") + .header("PRIVATE-TOKEN", token) + .query(&[ + ("membership", "true"), + ("simple", "true"), + ("order_by", "last_activity_at"), + ("sort", "desc"), + ("per_page", &PAGE_SIZE.to_string()), + ("page", &page.to_string()), + ]) + .send() + .map_err(|err| format!("Could not reach GitLab: {err}"))?; + if !response.status().is_success() { + return Err(response_error(response, "GitLab")); + } + let next_page = response + .headers() + .get("x-next-page") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + let projects = response + .json::>() + .map_err(|err| format!("GitLab returned an unreadable repository list: {err}"))?; + repositories.extend(projects.into_iter().map(|project| IntegrationRepository { + id: project.id.to_string(), + name: project.name, + full_name: project.path_with_namespace, + description: project.description.unwrap_or_default(), + clone_url: project.http_url_to_repo, + ssh_url: project.ssh_url_to_repo, + web_url: project.web_url, + updated_at: project.last_activity_at, + private: project.visibility == "private", + })); + if next_page.is_empty() { + break; + } + page = next_page.parse().unwrap_or(page + 1); + } + Ok(repositories) +} + +fn gitea_repositories( + client: &Client, + base_url: &str, + token: &str, +) -> Result, String> { + let mut repositories = Vec::new(); + let mut page = 1usize; + loop { + let response = client + .get(format!("{base_url}/api/v1/user/repos")) + .header(USER_AGENT, "Gitty") + .header(ACCEPT, "application/json") + .header("Authorization", format!("token {token}")) + .query(&[ + ("limit", PAGE_SIZE.to_string()), + ("page", page.to_string()), + ("sort", "updated".to_string()), + ]) + .send() + .map_err(|err| format!("Could not reach Gitea: {err}"))?; + if !response.status().is_success() { + return Err(response_error(response, "Gitea")); + } + let page_repositories = response + .json::>() + .map_err(|err| format!("Gitea 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, + 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 azure_repositories( + client: &Client, + base_url: &str, + username: &str, + token: &str, +) -> Result, String> { + let response = client + .get(format!("{base_url}/_apis/git/repositories")) + .header(USER_AGENT, "Gitty") + .header(ACCEPT, "application/json") + .basic_auth(username, Some(token)) + .query(&[("api-version", "7.1")]) + .send() + .map_err(|err| format!("Could not reach Azure DevOps: {err}"))?; + if !response.status().is_success() { + return Err(response_error(response, "Azure DevOps")); + } + let repositories = response + .json::() + .map_err(|err| format!("Azure DevOps returned an unreadable repository list: {err}"))?; + Ok(repositories + .value + .into_iter() + .map(|repository| IntegrationRepository { + id: repository.id, + full_name: format!("{}/{}", repository.project.name, repository.name), + name: repository.name, + description: String::new(), + clone_url: repository.remote_url, + ssh_url: repository.ssh_url, + web_url: repository.web_url, + updated_at: String::new(), + private: true, + }) + .collect()) +} + +#[tauri::command] +pub async fn list_integration_repositories( + provider: String, + base_url: String, + account_id: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let credential_key = integration_key(&provider, account_id.as_deref())?; + let credential = load_stored_credential(&credential_key)? + .ok_or_else(|| "No token is stored for this integration.".to_string())?; + let base_url = normalized_base_url(&base_url)?; + let client = client()?; + match provider.as_str() { + "gitlab" | "gitlab-self-hosted" => { + gitlab_repositories(&client, &base_url, &credential.password) + } + "azure-devops" => azure_repositories( + &client, + &base_url, + if credential.username.trim().is_empty() { + "gitty" + } else { + &credential.username + }, + &credential.password, + ), + "gitea" => gitea_repositories(&client, &base_url, &credential.password), + _ => Err("Unsupported integration provider.".to_string()), + } + }) + .await + .map_err(|err| format!("Could not load integration repositories: {err}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_urls_are_normalized_and_validated() { + assert_eq!( + normalized_base_url(" https://gitlab.example.com/ ").unwrap(), + "https://gitlab.example.com" + ); + assert!(normalized_base_url("gitlab.example.com").is_err()); + } + + #[test] + fn integration_credential_keys_match_the_frontend() { + assert_eq!(integration_key("gitea", None).unwrap(), "integration:gitea"); + assert_eq!( + integration_key("azure-devops", Some("org-123")).unwrap(), + "integration:azure-devops:org-123" + ); + assert_eq!( + integration_key("azure-devops", Some("default")).unwrap(), + "integration:azure-devops" + ); + assert!(integration_key("azure-devops", Some("../invalid")).is_err()); + } + + #[test] + fn provider_repository_payloads_deserialize() { + let gitlab: Vec = serde_json::from_str( + r#"[{"id":7,"name":"app","path_with_namespace":"team/app","description":"Demo","http_url_to_repo":"https://gitlab.test/team/app.git","ssh_url_to_repo":"git@gitlab.test:team/app.git","web_url":"https://gitlab.test/team/app","last_activity_at":"2026-08-29T10:00:00Z","visibility":"private"}]"#, + ) + .unwrap(); + assert_eq!(gitlab[0].path_with_namespace, "team/app"); + assert_eq!(gitlab[0].visibility, "private"); + + let gitea: Vec = serde_json::from_str( + r#"[{"id":8,"name":"api","full_name":"team/api","description":"","clone_url":"https://gitea.test/team/api.git","ssh_url":"git@gitea.test:team/api.git","html_url":"https://gitea.test/team/api","updated_at":"2026-08-29T11:00:00Z","private":false}]"#, + ) + .unwrap(); + assert_eq!(gitea[0].full_name, "team/api"); + assert!(!gitea[0].private); + + let azure: AzureRepositoryList = serde_json::from_str( + r#"{"value":[{"id":"repo-id","name":"web","project":{"name":"Platform"},"remoteUrl":"https://dev.azure.com/org/Platform/_git/web","sshUrl":"git@ssh.dev.azure.com:v3/org/Platform/web","webUrl":"https://dev.azure.com/org/Platform/_git/web"}]}"#, + ) + .unwrap(); + assert_eq!(azure.value[0].project.name, "Platform"); + assert_eq!( + azure.value[0].remote_url, + "https://dev.azure.com/org/Platform/_git/web" + ); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8c3c827..f83c949 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,6 +3,7 @@ mod badge; mod external_tools; mod git; +mod integrations; mod telemetry; use badge::set_sync_badge; @@ -32,6 +33,7 @@ use git::{ stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, untrack_paths, update_remote, }; +use integrations::list_integration_repositories; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{Emitter, Manager}; @@ -432,6 +434,7 @@ async fn main() { cred_load, cred_save, cred_delete, + list_integration_repositories, set_sync_badge, close_splashscreen, set_telemetry_enabled, diff --git a/src/App.svelte b/src/App.svelte index fc95917..99a406e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@ { if (!isBusy) cloneDialogOpen = false; }} /> {/if} diff --git a/src/lib/components/AppSettingsDialog.svelte b/src/lib/components/AppSettingsDialog.svelte index 2a93ef8..73b0c59 100644 --- a/src/lib/components/AppSettingsDialog.svelte +++ b/src/lib/components/AppSettingsDialog.svelte @@ -6,11 +6,13 @@ ChevronDown, ChevronRight, CircleDashed, + CloudCog, Code2, FolderOpen, GitCompare, GitMerge, Languages, + KeyRound, Palette, RefreshCw, RotateCw, @@ -29,6 +31,7 @@ type ExternalToolKind, type ExternalToolPreset, } from "../externalTools"; + import { configuredIntegrationCount, defaultGitIntegrationSettings } from "../integrations"; import type { AnalyticsSettings, AppAppearance, @@ -37,11 +40,14 @@ CustomThemeColors, DetectedExternalTool, ExternalToolsSettings, + GitIntegrationSecretUpdate, + GitIntegrationSettings, ToolOpenMode, } from "../types"; + import IntegrationSettingsPage from "./IntegrationSettingsPage.svelte"; import SelectMenu from "./SelectMenu.svelte"; - type SettingsPage = "general" | "tools"; + type SettingsPage = "general" | "integrations" | "tools"; interface Props { analytics: AnalyticsSettings; @@ -51,11 +57,12 @@ language: AppLanguage; autoRefresh: boolean; externalTools: ExternalToolsSettings; + integrations: GitIntegrationSettings; detectedTools: DetectedExternalTool[]; detectionPending: boolean; detectionUnavailable: boolean; onRefreshDetectedTools: () => void | Promise; - onSave: (settings: AnalyticsSettings, theme: AppTheme, appearance: AppAppearance, customTheme: CustomThemeColors, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void; + onSave: (settings: AnalyticsSettings, theme: AppTheme, appearance: AppAppearance, customTheme: CustomThemeColors, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings, integrations: GitIntegrationSettings, integrationSecrets: GitIntegrationSecretUpdate[]) => void | Promise; onClose: () => void; } @@ -67,6 +74,7 @@ language = "en", autoRefresh = true, externalTools, + integrations, detectedTools = [], detectionPending = false, detectionUnavailable = false, @@ -77,7 +85,7 @@ const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"]; - let activePage = $state("tools"); + let activePage = $state("integrations"); let activeToolKind = $state("editor"); let advancedOpen = $state(false); let analyticsEnabled = $state(true); @@ -87,6 +95,9 @@ let selectedLanguage = $state("en"); let autoRefreshEnabled = $state(true); let tools = $state(defaultExternalToolsSettings()); + let integrationDraft = $state(defaultGitIntegrationSettings()); + let integrationSecretUpdates = $state([]); + let saving = $state(false); const isGerman = $derived(selectedLanguage === "de"); $effect(() => { @@ -97,14 +108,21 @@ selectedLanguage = language; autoRefreshEnabled = autoRefresh; tools = structuredClone(externalTools); + integrationDraft = structuredClone(integrations); }); - function save() { - onSave({ - ...analytics, - enabled: analyticsEnabled, - noticeSeen: true, - }, selectedTheme, selectedAppearance, $state.snapshot(customColors), selectedLanguage, autoRefreshEnabled, $state.snapshot(tools)); + async function save() { + if (saving) return; + saving = true; + try { + await onSave({ + ...analytics, + enabled: analyticsEnabled, + noticeSeen: true, + }, selectedTheme, selectedAppearance, $state.snapshot(customColors), selectedLanguage, autoRefreshEnabled, $state.snapshot(tools), $state.snapshot(integrationDraft), $state.snapshot(integrationSecretUpdates)); + } finally { + saving = false; + } } function resetCustomColors() { @@ -315,10 +333,22 @@ {#if !detectionUnavailable}{detectedTools.length}{/if} +
-
@@ -399,7 +429,7 @@ - {:else} + {:else if activePage === "tools"}

{isGerman ? "Externe Tools" : "External tools"}

@@ -518,6 +548,19 @@
{/if} + {:else} +
+
+

{isGerman ? "Integrationen" : "Integrations"}

+

{isGerman ? "Verbinde Gitty mit deinen Git-Hosting-Diensten." : "Connect Gitty to your Git hosting services."}

+
+
+ { integrationDraft = next; }} + onSecretsChange={(updates) => { integrationSecretUpdates = updates; }} + /> {/if}
@@ -526,7 +569,7 @@ {isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}
- +
@@ -663,7 +706,8 @@ .app-settings-head { min-height: 58px; padding: 10px 12px; } .app-settings-mark { width: 34px; height: 34px; } .settings-nav button small, .settings-nav button em { display: none; } - .settings-nav > button { grid-template-columns: auto minmax(0, 1fr); } + .settings-nav > button { grid-template-columns: auto minmax(0, 1fr); gap: 5px; padding-inline: 6px; } + .settings-nav button strong { font-size: 10px; } .settings-page-head { align-items: stretch; flex-direction: column; } .tool-rescan-button { align-self: flex-start; } .general-settings-grid { grid-template-columns: 1fr; } diff --git a/src/lib/components/CloneRepositoryDialog.svelte b/src/lib/components/CloneRepositoryDialog.svelte index da4aeea..fc8e41e 100644 --- a/src/lib/components/CloneRepositoryDialog.svelte +++ b/src/lib/components/CloneRepositoryDialog.svelte @@ -1,22 +1,24 @@