Compare commits

..
10 Commits
Author SHA1 Message Date
Christoph 9c93d5a978 feat(integrations): add Git hosting providers and Clone UI
Add integrations for GitHub, GitLab (cloud & self-hosted), Azure DevOps,
and Gitea, storing personal access tokens in the operating system
keychain. Azure DevOps supports multiple independently configurable
organizations, and the Clone → Integrations tab loads, filters, sorts,
and clones repositories using stored credentials. Update the API
contract, help overlay, README, changelog, and application version
metadata to document and ship the feature.

- Add list_integration_repositories API command and related types
- New Clone → Integrations UI with search, refresh, and direct clone
- Store tokens in OS keychain and support multiple Azure DevOps orgs
2026-08-30 00:05:01 +02:00
Christoph c242a72edd 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.
2026-08-29 23:55:27 +02:00
Christoph 91263547db 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.
2026-08-29 23:25:51 +02:00
Christoph 823a50ce85 style(repo-loading): refine overlay visuals and theming
Update the repository loading overlay and related UI styles to use
design tokens, improve contrast, and reduce visual bulk. Sizes,
spacing, radii, and icon dimensions were tuned to create a more
compact, consistent card. Backgrounds, grid textures, and shadows
were switched to CSS variables and color-mix; subtle backdrop-filter
and opacity tweaks improve legibility across themes.

- Replace hardcoded colors with theme variables and color-mix
- Reduce component dimensions and tighten spacing for a compact UI
- Add backdrop blur, adjust grid opacity, and simplify shadows
2026-08-29 22:52:14 +02:00
Christoph 22da397e39 fix(git): make unsetting upstream idempotent
Saving sync settings with no upstream could cause a fatal Git
error when unsetting upstream on a branch that never had tracking
information. This change guards the operation by checking for an existing
merge configuration before unsetting, making it idempotent. A test was
added to verify that clearing an unconfigured upstream is a no-op.

- Add test ensuring clearing an unconfigured upstream is a no-op
2026-08-26 00:35:30 +02:00
Christoph f160e48777 Update version to 2026.8.7 2026-08-23 23:41:30 +02:00
Christoph 4533f8aa38 style(theme): add dialog & panel CSS vars and refactor status panel styles
publish / Build and publish Ubuntu AppImage (release) Successful in 19m39s
publish / Build and publish Windows installer (release) Successful in 22m14s
publish / Build and publish AUR packages (release) Successful in 48m41s
Add CSS custom properties for dialog backdrop, shadows, and panel
highlights to centralize visual theming. Refactor the status panel
overlay to consume these tokens and use color-mix instead of hardcoded
rgba values. This improves visual consistency and lets overlays adapt
cleanly to appearance changes and theme variants.

- Centralize dialog and panel visuals with new CSS variables
- Replace fixed rgba values with tokenized colors and color-mix
- Move borders, shadows, and gradients to use the new theme tokens
2026-08-23 23:38:42 +02:00
Christoph f0bd74be4e Merge pull request 'feat(history): make branch filter groups collapsible' (#28) from new-desgin into main
publish / Build and publish Windows installer (release) Failing after 13s
publish / Build and publish AUR packages (release) Canceled after 0s
publish / Build and publish Ubuntu AppImage (release) Canceled after 54s
Reviewed-on: #28
2026-08-23 21:28:46 +00:00
Christoph 79f4ec21e4 feat(history): make branch filter groups collapsible
Add collapsible local and remote branch groups to the branch
visibility dialog, including toggle buttons and selection counts.
Introduce comprehensive dialog styling and responsive rules to match
the app UI. Opening the dialog now defaults to local open and remote
closed for faster access.

- Add CSS for branch-filter dialog layout, theming, and responsiveness
- Implement group toggles with chevrons and visible selected counts
- Default to local group open and remote group closed on dialog open
2026-08-23 23:20:13 +02:00
Christoph 44547f507a Merge pull request 'New desgin' (#27) from new-desgin into main
Reviewed-on: #27
2026-08-23 21:15:39 +00:00
22 changed files with 2218 additions and 260 deletions
+49
View File
@@ -4,6 +4,53 @@ All notable user-facing changes to Gitty are documented in this file.
The project uses calendar-style versions in the form `YYYY.M.PATCH`. The project uses calendar-style versions in the form `YYYY.M.PATCH`.
## [2026.8.8] - 2026-08-29
### Added
- Git hosting integrations for GitHub, GitLab.com, GitLab Self-Managed,
Azure DevOps, and Gitea. Personal access tokens are stored separately in
the operating system keychain.
- Azure DevOps supports multiple independently configurable organizations,
each with its own display name, organization URL, username, and token.
- The Clone dialog has an Integrations tab that loads all repositories
available to the selected account, supports filtering and refresh, sorts
repositories alphabetically, and clones the selected repository directly
with its stored credentials.
### Changed
- Repository tabs use a more compact Git-client-style bar. Close buttons
remain visible and turn red only while hovered.
- The integration repository list uses a narrow custom scrollbar that grows
only slightly on hover and no longer covers repository names or metadata.
- Repository loading and status overlays use theme-aware design tokens with
improved contrast and a more compact presentation.
### Fixed
- Clearing sync settings on a branch that never had an upstream is now a safe
no-op instead of failing with a fatal Git error.
## [2026.8.7] - 2026-08-23
### Added
- Appearance settings now offer Modern, Classic, and Custom styles. Custom
themes can define their own persisted color palette, while a complete light
theme is available alongside the refreshed dark appearance.
- The branch visibility dialog groups local and remote branches into
collapsible sections with selected-branch counts. Local branches open by
default, while the remote group starts collapsed for quicker navigation.
### Changed
- Dark-theme colors, surfaces, controls, and focus outlines have been refined
for clearer interactive boundaries and more consistent contrast throughout
the application.
- The branch visibility dialog follows the responsive layout and visual
language of the rest of Gitty more closely.
## [2026.8.6] - 2026-08-18 ## [2026.8.6] - 2026-08-18
### Changed ### Changed
@@ -243,6 +290,8 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22 [2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21 [2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20 [2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
[2026.8.8]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.8
[2026.8.7]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.7
[2026.8.6]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.6 [2026.8.6]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.6
[2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5 [2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5
[2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4 [2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4
+16
View File
@@ -20,6 +20,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
- 🔄 Pull, Push & Fetch - 🔄 Pull, Push & Fetch
- 🔀 Merge & Rebase - 🔀 Merge & Rebase
- 📦 Repository management - 📦 Repository management
- ☁️ GitHub, GitLab, Azure DevOps, and Gitea integrations
- 🗄️ Git LFS detection, tracking and object management - 🗄️ Git LFS detection, tracking and object management
- 🎨 Modern and intuitive UI - 🎨 Modern and intuitive UI
@@ -103,6 +104,21 @@ the running window when Gitty is already open.
--- ---
## Git hosting integrations
Gitty connects to GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and
Gitea from **Settings → Integrations**. Each connection uses a personal access
token that is stored in the operating system keychain instead of application
settings. Azure DevOps can manage multiple organizations with separate URLs,
usernames, and tokens.
After enabling a connection, open **Clone → Integrations** to load the
repositories available to that account. Repositories are sorted
alphabetically and can be filtered, refreshed, selected, and cloned directly
with the stored credentials.
---
## Development ## Development
Start the complete desktop application in development mode with: Start the complete desktop application in development mode with:
+15
View File
@@ -101,6 +101,20 @@ interface GitLfsFile {
oid: string; oid: string;
version: string; version: string;
} }
type GitIntegrationProvider = "github" | "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea";
interface IntegrationRepository {
id: string;
name: string;
fullName: string;
description: string;
cloneUrl: string;
sshUrl: string;
webUrl: string;
updatedAt: string;
private: boolean;
}
``` ```
## Commands ## Commands
@@ -110,6 +124,7 @@ The command list below includes the repository-management and synchronization AP
- `open_repository(path: string): Promise<GitStatus>` - `open_repository(path: string): Promise<GitStatus>`
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>` - `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
- `clone_repository(...): Promise<RepositoryBundle>` - `clone_repository(...): Promise<RepositoryBundle>`
- `list_integration_repositories(provider: GitIntegrationProvider, baseUrl: string, accountId?: string): Promise<IntegrationRepository[]>`; loads credentials from the operating system keychain and returns every repository accessible through the configured Git hosting account.
- `get_status(path: string): Promise<GitStatus>` - `get_status(path: string): Promise<GitStatus>`
- `git_lfs_status(path: string): Promise<GitLfsStatus>` - `git_lfs_status(path: string): Promise<GitLfsStatus>`
- `git_lfs_install(path: string): Promise<GitLfsStatus>` - `git_lfs_install(path: string): Promise<GitLfsStatus>`
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.8.6", "version": "2026.8.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.8.6", "version": "2026.8.8",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.8.6", "version": "2026.8.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+38 -11
View File
@@ -836,7 +836,13 @@ pub fn set_branch_upstream(
)?; )?;
} }
None => { None => {
run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?; // Saving sync settings with "No upstream" must be idempotent. Git
// exits with a fatal error when --unset-upstream is used on a
// branch that never had tracking information, which is the normal
// state immediately after adding the first remote.
if git_config_value(&repo, &format!("branch.{branch}.merge")).is_some() {
run_git(&repo, ["branch", "--unset-upstream", branch.as_str()])?;
}
} }
} }
status_for_repo(&repo) status_for_repo(&repo)
@@ -2872,6 +2878,17 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}")) keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
} }
pub(crate) fn load_stored_credential(key: &str) -> Result<Option<StoredCredential>, String> {
let entry = cred_entry(key)?;
match entry.get_password() {
Ok(json) => serde_json::from_str::<StoredCredential>(&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 /// Returns the remote URL used for auth key derivation (upstream remote of the
/// current branch, falling back to `origin`, then the first configured remote). /// current branch, falling back to `origin`, then the first configured remote).
#[tauri::command(async)] #[tauri::command(async)]
@@ -3055,16 +3072,7 @@ fn push_args_for_repo_to(
#[tauri::command(async)] #[tauri::command(async)]
pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> { pub fn cred_load(key: String) -> Result<Option<StoredCredential>, String> {
let entry = cred_entry(&key)?; load_stored_credential(&key)
match entry.get_password() {
Ok(json) => {
let cred = serde_json::from_str::<StoredCredential>(&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}")),
}
} }
#[tauri::command(async)] #[tauri::command(async)]
@@ -7661,6 +7669,25 @@ mod tests {
assert_eq!(remote.upstream, None); assert_eq!(remote.upstream, None);
} }
#[test]
fn clearing_an_unconfigured_upstream_is_a_noop() {
let repo = init_temp_repo("unset_missing_upstream");
commit_initial_file(&repo.path);
let branch = git_output_test(&repo.path, ["branch", "--show-current"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let status = set_branch_upstream(
repo.path.to_string_lossy().to_string(),
branch.clone(),
None,
)
.expect("saving an empty upstream should not fail");
assert_eq!(status.current_branch.as_deref(), Some(branch.as_str()));
assert_eq!(status.upstream, None);
assert!(git_config_value(&repo.path, &format!("branch.{branch}.merge")).is_none());
}
#[test] #[test]
fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() { fn commit_notes_can_be_created_updated_and_deleted_without_changing_commit() {
let repo = init_temp_repo("commit_notes_crud"); let repo = init_temp_repo("commit_notes_crud");
+457
View File
@@ -0,0 +1,457 @@
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<String>,
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 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,
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<AzureRepository>,
}
#[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<String, String> {
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, String> {
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<String, String> {
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 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| {
serde_json::from_str::<serde_json::Value>(&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<Vec<IntegrationRepository>, 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::<Vec<GitLabProject>>()
.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 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,
token: &str,
) -> Result<Vec<IntegrationRepository>, 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::<Vec<GiteaRepository>>()
.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<Vec<IntegrationRepository>, 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::<AzureRepositoryList>()
.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<String>,
) -> Result<Vec<IntegrationRepository>, 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() {
"github" => github_repositories(&client, &base_url, &credential.password),
"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());
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(),
"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 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"}]"#,
)
.unwrap();
assert_eq!(gitlab[0].path_with_namespace, "team/app");
assert_eq!(gitlab[0].visibility, "private");
let gitea: Vec<GiteaRepository> = 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"
);
}
}
+3
View File
@@ -3,6 +3,7 @@
mod badge; mod badge;
mod external_tools; mod external_tools;
mod git; mod git;
mod integrations;
mod telemetry; mod telemetry;
use badge::set_sync_badge; use badge::set_sync_badge;
@@ -32,6 +33,7 @@ use git::{
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
untrack_paths, update_remote, untrack_paths, update_remote,
}; };
use integrations::list_integration_repositories;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
@@ -432,6 +434,7 @@ async fn main() {
cred_load, cred_load,
cred_save, cred_save,
cred_delete, cred_delete,
list_integration_repositories,
set_sync_badge, set_sync_badge,
close_splashscreen, close_splashscreen,
set_telemetry_enabled, set_telemetry_enabled,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.8.6", "version": "2026.8.8",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev", "beforeDevCommand": "npm run prepare:lfs && npm run dev",
+71 -3
View File
@@ -117,6 +117,7 @@
launchExternalMerge, launchExternalMerge,
launchExternalTool, launchExternalTool,
credLoad, credLoad,
credDelete,
credSave, credSave,
getFilePatch, getFilePatch,
readConflict, readConflict,
@@ -158,6 +159,9 @@
ExplorerNodeKind, ExplorerNodeKind,
ExternalDiffScope, ExternalDiffScope,
ExternalToolsSettings, ExternalToolsSettings,
GitIntegrationSecretUpdate,
GitIntegrationSettings,
GitIntegrationProvider,
GitBlameLine, GitBlameLine,
GitBranch as GitBranchInfo, GitBranch as GitBranchInfo,
GitCommit, GitCommit,
@@ -190,6 +194,11 @@
normaliseExternalToolsSettings, normaliseExternalToolsSettings,
resolveDetectedExternalToolPrograms, resolveDetectedExternalToolPrograms,
} from "./lib/externalTools"; } from "./lib/externalTools";
import {
defaultGitIntegrationSettings,
integrationCredentialKey,
normaliseGitIntegrationSettings,
} from "./lib/integrations";
import { import {
orgKeyFromUrl, orgKeyFromUrl,
@@ -259,6 +268,7 @@
const CUSTOM_THEME_KEY = "gitlite.customTheme.v1"; const CUSTOM_THEME_KEY = "gitlite.customTheme.v1";
const APP_LANGUAGE_KEY = "gitlite.language.v1"; const APP_LANGUAGE_KEY = "gitlite.language.v1";
const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.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 AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1"; const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
@@ -372,6 +382,7 @@
let customTheme: CustomThemeColors = loadCustomTheme(); let customTheme: CustomThemeColors = loadCustomTheme();
let appLanguage: AppLanguage = loadLanguagePreference(); let appLanguage: AppLanguage = loadLanguagePreference();
let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings(); let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
let gitIntegrationSettings: GitIntegrationSettings = loadGitIntegrationSettings();
let externalToolsConfigured = hasStoredExternalToolsSettings(); let externalToolsConfigured = hasStoredExternalToolsSettings();
let detectedExternalTools: DetectedExternalTool[] = []; let detectedExternalTools: DetectedExternalTool[] = [];
let externalToolsDetectionPending = true; let externalToolsDetectionPending = true;
@@ -1179,7 +1190,8 @@
root.dataset.appearance = next; root.dataset.appearance = next;
const customProperties = [ const customProperties = [
"--app-bg", "--app-button-bg", "--app-input-bg", "--app-dialog-bg", "--app-dialog-chrome", "--app-bg", "--app-button-bg", "--app-input-bg", "--app-dialog-bg", "--app-dialog-chrome",
"--app-settings-row-bg", "--color-surface", "--color-surface-alt", "--color-surface-dim", "--app-dialog-backdrop", "--app-dialog-shadow", "--app-panel-shadow", "--app-settings-row-bg",
"--color-surface", "--color-surface-alt", "--color-surface-dim",
"--color-surface-hover", "--color-surface-raised", "--color-surface-solid", "--color-border", "--color-surface-hover", "--color-surface-raised", "--color-surface-solid", "--color-border",
"--color-border-subtle", "--color-border-input", "--color-primary", "--color-primary-dark", "--color-border-subtle", "--color-border-input", "--color-primary", "--color-primary-dark",
"--color-accent", "--color-ink", "--color-ink-muted", "--color-ink-faint", "--color-ink-dim", "--color-accent", "--color-ink", "--color-ink-muted", "--color-ink-faint", "--color-ink-dim",
@@ -1195,6 +1207,9 @@
"--app-input-bg": `color-mix(in srgb, ${surface} 88%, white)`, "--app-input-bg": `color-mix(in srgb, ${surface} 88%, white)`,
"--app-dialog-bg": surface, "--app-dialog-bg": surface,
"--app-dialog-chrome": `color-mix(in srgb, ${surface} 90%, ${background})`, "--app-dialog-chrome": `color-mix(in srgb, ${surface} 90%, ${background})`,
"--app-dialog-backdrop": `color-mix(in srgb, ${background} 72%, transparent)`,
"--app-dialog-shadow": `0 24px 68px color-mix(in srgb, ${text} 24%, transparent), 0 2px 12px color-mix(in srgb, ${text} 12%, transparent)`,
"--app-panel-shadow": `0 18px 48px color-mix(in srgb, ${text} 18%, transparent), inset 0 1px 0 color-mix(in srgb, ${surface} 88%, white)`,
"--app-settings-row-bg": `color-mix(in srgb, ${surface} 92%, ${background})`, "--app-settings-row-bg": `color-mix(in srgb, ${surface} 92%, ${background})`,
"--color-surface": surface, "--color-surface": surface,
"--color-surface-alt": `color-mix(in srgb, ${surface} 82%, ${background})`, "--color-surface-alt": `color-mix(in srgb, ${surface} 82%, ${background})`,
@@ -1256,7 +1271,34 @@
if (appTheme === "system") applyThemePreference(appTheme); 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 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;
}
}
} catch (error) {
errorMessage = appLanguage === "de"
? `Integration konnte nicht gespeichert werden: ${String(error)}`
: `Could not save integration: ${String(error)}`;
return;
}
const autoRefreshWasEnabled = autoRefreshEnabled; const autoRefreshWasEnabled = autoRefreshEnabled;
analyticsSettings = next; analyticsSettings = next;
appTheme = nextTheme; appTheme = nextTheme;
@@ -1265,12 +1307,14 @@
appLanguage = nextLanguage; appLanguage = nextLanguage;
autoRefreshEnabled = nextAutoRefresh; autoRefreshEnabled = nextAutoRefresh;
externalToolsSettings = nextExternalTools; externalToolsSettings = nextExternalTools;
gitIntegrationSettings = integrationsToSave;
persistAnalyticsSettings(next); persistAnalyticsSettings(next);
persistThemePreference(nextTheme); persistThemePreference(nextTheme);
persistAppearancePreference(nextAppearance, nextCustomTheme); persistAppearancePreference(nextAppearance, nextCustomTheme);
persistLanguagePreference(nextLanguage); persistLanguagePreference(nextLanguage);
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh); persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
persistExternalToolsSettings(nextExternalTools); persistExternalToolsSettings(nextExternalTools);
persistGitIntegrationSettings(integrationsToSave);
externalToolsConfigured = true; externalToolsConfigured = true;
setTelemetryEnabled(next.enabled); setTelemetryEnabled(next.enabled);
appSettingsOpen = false; appSettingsOpen = false;
@@ -2574,6 +2618,11 @@
trackEvent("clone_dialog_opened"); 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() { function openRepoManagement() {
if (isBusy) return; if (isBusy) return;
activeView = "management"; activeView = "management";
@@ -4583,6 +4632,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 { function hasStoredExternalToolsSettings(): boolean {
try { try {
return localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) != null; return localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) != null;
@@ -5706,6 +5771,7 @@
language={appLanguage} language={appLanguage}
autoRefresh={autoRefreshEnabled} autoRefresh={autoRefreshEnabled}
externalTools={externalToolsSettings} externalTools={externalToolsSettings}
integrations={gitIntegrationSettings}
detectedTools={detectedExternalTools} detectedTools={detectedExternalTools}
detectionPending={externalToolsDetectionPending} detectionPending={externalToolsDetectionPending}
detectionUnavailable={externalToolsDetectionUnavailable} detectionUnavailable={externalToolsDetectionUnavailable}
@@ -6039,7 +6105,9 @@
<CloneRepositoryDialog <CloneRepositoryDialog
isBusy={operation === "Cloning repository"} isBusy={operation === "Cloning repository"}
error={cloneDialogError} error={cloneDialogError}
onClone={cloneRepo} language={appLanguage}
integrations={gitIntegrationSettings}
onClone={cloneFromDialog}
onClose={() => { if (!isBusy) cloneDialogOpen = false; }} onClose={() => { if (!isBusy) cloneDialogOpen = false; }}
/> />
{/if} {/if}
+449 -1
View File
@@ -6475,6 +6475,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
display: grid; display: grid;
place-items: center; place-items: center;
min-width: 0; min-width: 0;
overflow: hidden;
color: var(--color-primary); color: var(--color-primary);
background: var(--color-surface-dim); background: var(--color-surface-dim);
} }
@@ -6496,7 +6497,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
border-radius: 50%; border-radius: 50%;
background: var(--color-surface-solid); background: var(--color-surface-solid);
box-shadow: 0 0 0 4px var(--color-surface-dim); box-shadow: 0 0 0 2px var(--color-surface-dim);
} }
.status-lane-head { .status-lane-head {
display: flex; display: flex;
@@ -8523,3 +8524,450 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
:root[data-appearance="custom"] .graph-gutter { :root[data-appearance="custom"] .graph-gutter {
background: var(--color-surface-dim); background: var(--color-surface-dim);
} }
/* Branch visibility dialog ---------------------------------------------
Local and remote branches share one visual and interaction language. */
.branch-filter-backdrop,
:root[data-theme="light"] .branch-filter-backdrop {
padding: 24px;
background: var(--app-dialog-backdrop);
backdrop-filter: blur(5px);
}
.branch-filter-dialog,
:root[data-theme="light"] .branch-filter-dialog {
width: min(560px, 100%);
max-height: min(680px, calc(100vh - 48px));
border-color: var(--color-border);
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.branch-filter-dialog-head {
min-height: 64px;
padding: 12px 14px;
border-bottom-color: var(--color-border);
background: var(--app-dialog-chrome);
}
.branch-filter-dialog-head h3 {
color: var(--color-ink);
font-size: 15px;
}
.branch-filter-summary {
min-height: 48px;
padding: 8px 14px;
border-bottom-color: var(--color-border);
color: var(--color-ink-muted);
background: var(--color-surface-dim);
}
.branch-filter-actions button {
min-height: 28px;
border-color: var(--color-border);
color: var(--color-ink-muted);
background: var(--color-surface-raised);
}
.branch-filter-actions button:hover:not(:disabled) {
border-color: var(--color-border-input);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.branch-filter-dialog-list {
gap: 8px;
padding: 10px;
background: var(--color-surface-alt);
}
.branch-filter-group {
display: grid;
min-width: 0;
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-surface);
}
.branch-filter-group-toggle {
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 36px;
padding: 0 10px;
border: 0;
color: var(--color-ink-muted);
background: var(--color-surface-dim);
text-align: left;
}
.branch-filter-group-toggle:hover:not(:disabled) {
color: var(--color-ink);
background: var(--color-surface-hover);
}
.branch-filter-group-toggle > svg {
color: var(--color-accent);
}
.branch-filter-group-toggle > span {
font-size: 10px;
font-weight: 850;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.branch-filter-group-toggle > em {
min-width: 34px;
padding: 2px 5px;
border: 1px solid var(--color-border-subtle);
color: var(--color-ink-faint);
background: var(--color-surface-raised);
font-size: 9px;
font-style: normal;
font-weight: 800;
text-align: center;
}
.branch-filter-group-list {
display: grid;
gap: 4px;
padding: 6px;
border-top: 1px solid var(--color-border-subtle);
}
.branch-filter-option,
:root[data-theme="light"] .branch-filter-option,
:root[data-theme="light"] .branch-filter-option.remote {
min-height: 34px;
border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border));
color: var(--color-ink);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised));
opacity: 1;
}
.branch-filter-option:hover,
:root[data-theme="light"] .branch-filter-option:hover {
border-color: color-mix(in srgb, var(--color-accent) 52%, var(--color-border));
color: var(--color-ink);
background: color-mix(in srgb, var(--color-accent) 13%, var(--color-surface-hover));
}
.branch-filter-option.muted,
:root[data-theme="light"] .branch-filter-option.muted {
border-color: var(--color-border-subtle);
color: var(--color-ink-faint);
background: var(--color-surface-raised);
opacity: 0.72;
}
.branch-filter-option input {
accent-color: var(--color-accent);
}
@media (max-width: 560px) {
.branch-filter-backdrop { padding: 8px; }
.branch-filter-summary { align-items: stretch; flex-direction: column; }
.branch-filter-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* Authentication and token dialogs ------------------------------------
Keep credentials, access tokens and API-key forms on the active theme
instead of the legacy violet/black palette. */
.cred-card,
:root[data-theme="light"] .cred-card {
border-color: var(--color-border-input);
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.cred-hero,
:root[data-theme="light"] .cred-hero {
border-bottom-color: var(--color-border);
background:
radial-gradient(circle at 8% 0%, color-mix(in srgb, var(--color-accent) 14%, transparent), transparent 44%),
var(--app-dialog-chrome);
}
.cred-hero-icon,
:root[data-theme="light"] .cred-hero-icon {
border-color: color-mix(in srgb, var(--color-accent) 42%, var(--color-border));
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface-raised));
box-shadow: 0 10px 24px color-mix(in srgb, var(--color-accent) 18%, transparent);
}
.cred-hero-label,
:root[data-theme="light"] .cred-hero-label {
color: var(--color-accent);
}
.cred-hero-title,
:root[data-theme="light"] .cred-hero-title {
color: var(--color-ink);
}
.cred-hero-copy,
:root[data-theme="light"] .cred-hero-copy {
color: var(--color-ink-muted);
}
.cred-security-note,
:root[data-theme="light"] .cred-security-note {
border-color: color-mix(in srgb, var(--color-accent) 32%, var(--color-border));
color: var(--color-ink-muted);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised));
}
.cred-security-note svg,
:root[data-theme="light"] .cred-security-note svg {
color: var(--color-accent);
}
.cred-close,
:root[data-theme="light"] .cred-close {
border-color: var(--color-border);
color: var(--color-ink-faint);
background: var(--color-surface-raised);
}
.cred-close:hover:not(:disabled),
:root[data-theme="light"] .cred-close:hover:not(:disabled) {
border-color: var(--color-border-input);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.cred-body,
:root[data-theme="light"] .cred-body {
background: var(--app-dialog-bg);
}
.cred-segment,
:root[data-theme="light"] .cred-segment {
border-color: var(--color-border);
background: var(--color-surface-alt);
}
.cred-seg-btn,
:root[data-theme="light"] .cred-seg-btn {
color: var(--color-ink-faint);
}
.cred-seg-btn:hover:not(.active),
:root[data-theme="light"] .cred-seg-btn:hover:not(.active) {
color: var(--color-ink);
background: var(--color-surface-hover);
}
.cred-seg-btn.active,
:root[data-theme="light"] .cred-seg-btn.active {
border-color: color-mix(in srgb, var(--color-accent) 46%, var(--color-border));
color: var(--color-ink);
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface-raised));
box-shadow: inset 0 -2px 0 var(--color-accent);
}
.cred-input input,
.cred-expiry input[type="date"],
:root[data-theme="light"] .cred-input input,
:root[data-theme="light"] .cred-expiry input[type="date"] {
border-color: var(--color-border-input);
color: var(--color-ink);
background: var(--app-input-bg);
color-scheme: var(--app-color-scheme);
}
.cred-input input::placeholder,
:root[data-theme="light"] .cred-input input::placeholder {
color: var(--color-ink-faint);
opacity: 0.72;
}
.cred-token-hint,
:root[data-theme="light"] .cred-token-hint {
border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border));
color: var(--color-ink-muted);
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-dim));
}
.cred-token-hint code,
:root[data-theme="light"] .cred-token-hint code {
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface-raised));
}
.cred-footer {
border-top-color: var(--color-border-subtle);
}
.cred-save input[type="checkbox"] {
accent-color: var(--color-accent);
}
.cred-save span {
color: var(--color-ink-muted);
}
.cred-cancel {
border-color: var(--color-border);
color: var(--color-ink-muted);
background: var(--color-surface-raised);
}
.cred-submit {
border-color: color-mix(in srgb, var(--color-accent) 62%, var(--color-border));
color: var(--color-surface-solid);
background: var(--color-primary-dark);
box-shadow: none;
}
.cred-submit:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-surface-solid);
background: var(--color-primary);
}
@media (max-width: 560px) {
.cred-card { width: calc(100vw - 16px); max-height: calc(100vh - 16px); }
.cred-hero { padding: 16px; }
.cred-body { padding: 16px; }
.cred-security-note { align-items: flex-start; border-radius: 8px; padding-block: 7px; }
.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;
}
+3 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { BookOpen, Database, Plus, X } from "@lucide/svelte"; import { Folder, GitBranch, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem { interface RepositoryTabItem {
path: string; path: string;
@@ -28,8 +28,7 @@
disabled={isBusy} disabled={isBusy}
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"} title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
> >
<BookOpen size={14} aria-hidden="true" /> <Folder size={15} aria-hidden="true" />
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
</button> </button>
<div class="repo-tabs-scroll"> <div class="repo-tabs-scroll">
@@ -47,7 +46,7 @@
disabled={isBusy} disabled={isBusy}
title={repo.path} title={repo.path}
> >
<Database size={15} aria-hidden="true" /> <GitBranch size={13} aria-hidden="true" />
<span>{repo.name}</span> <span>{repo.name}</span>
</button> </button>
<button <button
+58 -14
View File
@@ -6,11 +6,13 @@
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CircleDashed, CircleDashed,
CloudCog,
Code2, Code2,
FolderOpen, FolderOpen,
GitCompare, GitCompare,
GitMerge, GitMerge,
Languages, Languages,
KeyRound,
Palette, Palette,
RefreshCw, RefreshCw,
RotateCw, RotateCw,
@@ -29,6 +31,7 @@
type ExternalToolKind, type ExternalToolKind,
type ExternalToolPreset, type ExternalToolPreset,
} from "../externalTools"; } from "../externalTools";
import { configuredIntegrationCount, defaultGitIntegrationSettings } from "../integrations";
import type { import type {
AnalyticsSettings, AnalyticsSettings,
AppAppearance, AppAppearance,
@@ -37,11 +40,14 @@
CustomThemeColors, CustomThemeColors,
DetectedExternalTool, DetectedExternalTool,
ExternalToolsSettings, ExternalToolsSettings,
GitIntegrationSecretUpdate,
GitIntegrationSettings,
ToolOpenMode, ToolOpenMode,
} from "../types"; } from "../types";
import IntegrationSettingsPage from "./IntegrationSettingsPage.svelte";
import SelectMenu from "./SelectMenu.svelte"; import SelectMenu from "./SelectMenu.svelte";
type SettingsPage = "general" | "tools"; type SettingsPage = "general" | "integrations" | "tools";
interface Props { interface Props {
analytics: AnalyticsSettings; analytics: AnalyticsSettings;
@@ -51,11 +57,12 @@
language: AppLanguage; language: AppLanguage;
autoRefresh: boolean; autoRefresh: boolean;
externalTools: ExternalToolsSettings; externalTools: ExternalToolsSettings;
integrations: GitIntegrationSettings;
detectedTools: DetectedExternalTool[]; detectedTools: DetectedExternalTool[];
detectionPending: boolean; detectionPending: boolean;
detectionUnavailable: boolean; detectionUnavailable: boolean;
onRefreshDetectedTools: () => void | Promise<void>; onRefreshDetectedTools: () => void | Promise<void>;
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<void>;
onClose: () => void; onClose: () => void;
} }
@@ -67,6 +74,7 @@
language = "en", language = "en",
autoRefresh = true, autoRefresh = true,
externalTools, externalTools,
integrations,
detectedTools = [], detectedTools = [],
detectionPending = false, detectionPending = false,
detectionUnavailable = false, detectionUnavailable = false,
@@ -77,7 +85,7 @@
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"]; const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
let activePage = $state<SettingsPage>("tools"); let activePage = $state<SettingsPage>("integrations");
let activeToolKind = $state<ExternalToolKind>("editor"); let activeToolKind = $state<ExternalToolKind>("editor");
let advancedOpen = $state(false); let advancedOpen = $state(false);
let analyticsEnabled = $state(true); let analyticsEnabled = $state(true);
@@ -87,6 +95,9 @@
let selectedLanguage = $state<AppLanguage>("en"); let selectedLanguage = $state<AppLanguage>("en");
let autoRefreshEnabled = $state(true); let autoRefreshEnabled = $state(true);
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings()); let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
let integrationDraft = $state<GitIntegrationSettings>(defaultGitIntegrationSettings());
let integrationSecretUpdates = $state<GitIntegrationSecretUpdate[]>([]);
let saving = $state(false);
const isGerman = $derived(selectedLanguage === "de"); const isGerman = $derived(selectedLanguage === "de");
$effect(() => { $effect(() => {
@@ -97,14 +108,21 @@
selectedLanguage = language; selectedLanguage = language;
autoRefreshEnabled = autoRefresh; autoRefreshEnabled = autoRefresh;
tools = structuredClone(externalTools); tools = structuredClone(externalTools);
integrationDraft = structuredClone(integrations);
}); });
function save() { async function save() {
onSave({ if (saving) return;
...analytics, saving = true;
enabled: analyticsEnabled, try {
noticeSeen: true, await onSave({
}, selectedTheme, selectedAppearance, $state.snapshot(customColors), selectedLanguage, autoRefreshEnabled, $state.snapshot(tools)); ...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() { function resetCustomColors() {
@@ -315,10 +333,22 @@
</span> </span>
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if} {#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
</button> </button>
<button type="button" class:active={activePage === "integrations"} onclick={() => { activePage = "integrations"; }}>
<CloudCog size={16} aria-hidden="true" />
<span>
<strong>{isGerman ? "Integrationen" : "Integrations"}</strong>
<small>GitHub, GitLab, Azure DevOps & Gitea</small>
</span>
<em>{configuredIntegrationCount(integrationDraft)}</em>
</button>
<div class="settings-nav-note"> <div class="settings-nav-note">
<ShieldCheck size={15} aria-hidden="true" /> {#if activePage === "integrations"}<KeyRound size={15} aria-hidden="true" />{:else}<ShieldCheck size={15} aria-hidden="true" />{/if}
<p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p> <p>
{activePage === "integrations"
? (isGerman ? "Tokens werden sicher im Schlüsselbund des Betriebssystems gespeichert." : "Tokens are stored securely in the operating system keychain.")
: (isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell.")}
</p>
</div> </div>
</nav> </nav>
@@ -399,7 +429,7 @@
</label> </label>
</section> </section>
</div> </div>
{:else} {:else if activePage === "tools"}
<div class="settings-page-head tools-page-head"> <div class="settings-page-head tools-page-head">
<div> <div>
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3> <h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
@@ -518,6 +548,19 @@
</div> </div>
{/if} {/if}
</section> </section>
{:else}
<div class="settings-page-head">
<div>
<h3>{isGerman ? "Integrationen" : "Integrations"}</h3>
<p>{isGerman ? "Verbinde Gitty mit deinen Git-Hosting-Diensten." : "Connect Gitty to your Git hosting services."}</p>
</div>
</div>
<IntegrationSettingsPage
language={selectedLanguage}
settings={integrationDraft}
onChange={(next) => { integrationDraft = next; }}
onSecretsChange={(updates) => { integrationSecretUpdates = updates; }}
/>
{/if} {/if}
</div> </div>
</div> </div>
@@ -526,7 +569,7 @@
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span> <span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
<div> <div>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button> <button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button> <button class="btn-primary" type="submit" disabled={saving}><Check size={16} aria-hidden="true" />{saving ? (isGerman ? "Wird gespeichert…" : "Saving…") : (isGerman ? "Änderungen speichern" : "Save changes")}</button>
</div> </div>
</footer> </footer>
</form> </form>
@@ -663,7 +706,8 @@
.app-settings-head { min-height: 58px; padding: 10px 12px; } .app-settings-head { min-height: 58px; padding: 10px 12px; }
.app-settings-mark { width: 34px; height: 34px; } .app-settings-mark { width: 34px; height: 34px; }
.settings-nav button small, .settings-nav button em { display: none; } .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; } .settings-page-head { align-items: stretch; flex-direction: column; }
.tool-rescan-button { align-self: flex-start; } .tool-rescan-button { align-self: flex-start; }
.general-settings-grid { grid-template-columns: 1fr; } .general-settings-grid { grid-template-columns: 1fr; }
+325 -102
View File
@@ -1,22 +1,24 @@
<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 { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte"; import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
import { listIntegrationRepositories } from "../git";
import { configuredIntegrationSources } from "../integrations";
import type { AppLanguage, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
type CloneSource = "url" | "integrations";
interface Props { interface Props {
isBusy: boolean; isBusy: boolean;
error: string; error: string;
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void; language: AppLanguage;
integrations: GitIntegrationSettings;
onClone: (remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) => void;
onClose: () => void; onClose: () => void;
} }
let { let { isBusy = false, error = "", language = "en", integrations, onClone = () => {}, onClose = () => {} }: Props = $props();
isBusy = false, let source = $state<CloneSource>("url");
error = "",
onClone = () => {},
onClose = () => {},
}: Props = $props();
let remoteUrl = $state(""); let remoteUrl = $state("");
let parentPath = $state(""); let parentPath = $state("");
let directoryName = $state(""); let directoryName = $state("");
@@ -25,29 +27,140 @@
let browseError = $state(""); let browseError = $state("");
let visibleError = $state(""); let visibleError = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined; let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
let selectedSourceId = $state("");
let selectedRepositoryId = $state("");
let repositorySearch = $state("");
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;
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl)); const isGerman = $derived(language === "de");
let canSubmit = $derived( const configuredSources = $derived(configuredIntegrationSources(integrations));
!isBusy && const activeSource = $derived(configuredSources.find((candidate) => candidate.id === selectedSourceId));
remoteUrl.trim().length > 0 && const activeRepositories = $derived(activeSource ? repositoriesBySource[activeSource.id] ?? [] : []);
parentPath.trim().length > 0, const filteredRepositories = $derived.by(() => {
); const query = repositorySearch.trim().toLocaleLowerCase();
if (!query) return activeRepositories;
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
});
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0);
$effect(() => { $effect(() => {
const nextError = error || browseError; const nextError = error || browseError;
if (errorHideTimer) clearTimeout(errorHideTimer); if (errorHideTimer) clearTimeout(errorHideTimer);
visibleError = nextError; visibleError = nextError;
if (nextError) { if (nextError) errorHideTimer = setTimeout(() => { visibleError = ""; }, 6000);
errorHideTimer = setTimeout(() => { });
visibleError = "";
}, 6000); $effect(() => {
} filteredRepositories.length;
loadingSourceId;
repositoryError;
scheduleRepositoryScrollbarUpdate();
});
onMount(() => {
window.addEventListener("resize", scheduleRepositoryScrollbarUpdate);
return () => window.removeEventListener("resize", scheduleRepositoryScrollbarUpdate);
}); });
onDestroy(() => { onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer); 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, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? ""; const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
@@ -64,112 +177,222 @@
if (isBusy) return; if (isBusy) return;
browseError = ""; browseError = "";
try { try {
const selected = await openDialog({ const selected = await openDialog({ title: isGerman ? "Zielordner zum Klonen auswählen" : "Select clone destination", directory: true, multiple: false, defaultPath: parentPath.trim() || undefined });
title: "Select clone destination", if (typeof selected === "string") parentPath = selected;
directory: true, } catch (error) { browseError = errorToMessage(error); }
multiple: false,
defaultPath: parentPath.trim() || undefined,
});
if (typeof selected !== "string") return;
parentPath = selected;
} catch (error) {
browseError = errorToMessage(error);
}
} }
function handleRemoteInput(event: Event) { function setRemoteUrl(nextRemoteUrl: string) {
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value; remoteUrl = nextRemoteUrl;
if (directoryNameEdited) return; if (directoryNameEdited) return;
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl); directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
directoryName = directoryAutoName; directoryName = directoryAutoName;
} }
function handleRemoteInput(event: Event) {
setRemoteUrl((event.currentTarget as HTMLInputElement).value);
selectedRepositoryId = "";
}
function handleDirectoryInput(event: Event) { function handleDirectoryInput(event: Event) {
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value; const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName; directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
} }
function submit(event: SubmitEvent) { function selectRepository(repository: GitIntegrationRepository) {
event.preventDefault(); selectedRepositoryId = repository.id;
if (!canSubmit) return; setRemoteUrl(repository.cloneUrl);
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
} }
function sortRepositories(repositories: GitIntegrationRepository[]): GitIntegrationRepository[] {
return [...repositories].sort((left, right) => left.fullName.localeCompare(
right.fullName,
isGerman ? "de" : "en",
{ numeric: true, sensitivity: "base" },
));
}
async function loadRepositories(integrationSource: GitIntegrationSource, force = false) {
selectedSourceId = integrationSource.id;
selectedRepositoryId = "";
repositorySearch = "";
repositoryError = "";
if (!force && repositoriesBySource[integrationSource.id]) return;
const requestId = ++repositoryRequestId;
loadingSourceId = integrationSource.id;
try {
const repositories = await listIntegrationRepositories(integrationSource.provider, integrationSource.baseUrl, integrationSource.accountId);
if (requestId === repositoryRequestId) repositoriesBySource = { ...repositoriesBySource, [integrationSource.id]: sortRepositories(repositories) };
} catch (error) {
if (requestId === repositoryRequestId) repositoryError = errorToMessage(error);
} finally {
if (requestId === repositoryRequestId) loadingSourceId = "";
}
}
function showIntegrations() {
source = "integrations";
const nextSource = configuredSources.find((candidate) => candidate.id === selectedSourceId) ?? configuredSources[0];
if (nextSource) void loadRepositories(nextSource);
}
function showUrlInput() {
source = "url";
selectedRepositoryId = "";
}
function formatUpdatedAt(value: string): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat(isGerman ? "de-DE" : "en-US", { dateStyle: "medium" }).format(date);
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (canSubmit) onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim(), source === "integrations" ? activeSource?.provider : undefined, source === "integrations" ? activeSource?.accountId : undefined);
}
</script> </script>
<div class="dialog-backdrop" role="presentation"> <div class="dialog-backdrop" role="presentation">
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1"> <div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
<div> <div><span class="eyebrow">Repository Management</span><h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{isGerman ? "Repository klonen" : "Clone repository"}</h2></div>
<span class="eyebrow">Repository Management</span> <button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header> </header>
<form class="clone-dialog-form" onsubmit={submit}> <form class="clone-dialog-form" onsubmit={submit}>
<label class="clone-dialog-field"> <div class="clone-source-tabs" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
<span>Remote URL</span> <button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} />URL</button>
<!-- svelte-ignore a11y_autofocus --> <button type="button" role="tab" aria-selected={source === "integrations"} class:active={source === "integrations"} onclick={showIntegrations}><Cloud size={15} />{isGerman ? "Integrationen" : "Integrations"}{#if configuredSources.length}<em>{configuredSources.length}</em>{/if}</button>
<input </div>
bind:value={remoteUrl}
oninput={handleRemoteInput}
autocomplete="off"
spellcheck="false"
placeholder="https://github.com/org/project.git"
disabled={isBusy}
autofocus
/>
</label>
<label class="clone-dialog-field"> {#if source === "url"}
<span>Destination</span> <label class="clone-dialog-field">
<div class="clone-dialog-path-field"> <span>{isGerman ? "Remote-URL" : "Remote URL"}</span>
<input <!-- svelte-ignore a11y_autofocus -->
bind:value={parentPath} <input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
autocomplete="off" </label>
spellcheck="false" {:else}
placeholder="Choose parent folder" <section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
disabled={isBusy} {#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 GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}> {:else}
<FolderOpen size={14} aria-hidden="true" /> <div class="integration-provider-tabs" role="tablist" aria-label={isGerman ? "Konfigurierte Anbieter" : "Configured providers"}>
Browse {#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}
</button> </div>
</div> <div class="repository-toolbar">
</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>
<label class="clone-dialog-field"> </div>
<span>Folder name</span> <div class="repository-list-shell">
<input <div id="integration-repository-list" class="repository-list" bind:this={repositoryListElement} onscroll={updateRepositoryScrollbar} aria-live="polite">
bind:value={directoryName} {#if loadingSourceId}
oninput={handleDirectoryInput} <div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
autocomplete="off" {:else if repositoryError}
spellcheck="false" <div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
placeholder={directorySuggestion || "Optional"} {:else if filteredRepositories.length === 0}
disabled={isBusy} <div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
/> {:else}
</label> {#each filteredRepositories as repository (repository.id)}
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
{#if visibleError} <span class="repository-option-icon"><GitBranch size={16} /></span>
<div class="clone-dialog-error" role="alert">{visibleError}</div> <span class="repository-option-copy"><strong>{repository.fullName}</strong><small>{repository.description || repository.cloneUrl}</small></span>
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
</button>
{/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>
{/if} {/if}
<div class="clone-dialog-actions"> <div class="clone-target-grid">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}> <label class="clone-dialog-field"><span>{isGerman ? "Ziel" : "Destination"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
Cancel <label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
</button>
<button class="btn-primary" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Download size={16} aria-hidden="true" />
{/if}
Clone
</button>
</div> </div>
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
<div class="clone-dialog-actions"><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
</form> </form>
</div> </div>
</div> </div>
<style>
.clone-repository-dialog { width: min(760px, calc(100vw - 32px)); }
.clone-source-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--app-settings-row-bg); }
.clone-source-tabs button { min-height: 36px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 11px; font-weight: 800; }
.clone-source-tabs button.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-raised); box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.clone-source-tabs button.active :global(svg) { color: var(--color-accent); }
.clone-source-tabs em { display: grid; place-items: center; min-width: 19px; height: 18px; padding: 0 5px; border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; font-style: normal; }
.integration-browser { display: grid; gap: 9px; min-height: 270px; padding: 11px; border: 1px solid var(--color-border-subtle); border-radius: 10px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.integration-provider-tabs { display: flex; gap: 5px; overflow-x: auto; }
.integration-provider-tabs button { flex: 0 0 auto; min-height: 29px; padding: 0 9px; border-color: transparent; color: var(--color-ink-dim); background: transparent; font-size: 10px; font-weight: 750; }
.integration-provider-tabs button.active { border-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); }
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
.repository-toolbar label { position: relative; min-width: 0; }
.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-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); }
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-hover)); box-shadow: inset 2px 0 0 var(--color-accent); }
.repository-option-icon { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--color-border-subtle); border-radius: 7px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); }
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 160px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
.repository-state { gap: 7px; font-size: 10.5px; }
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
.repository-state-error strong { color: #e86060; }
.repository-state-error span { max-width: 520px; line-height: 1.45; }
.integration-empty { min-height: 235px; gap: 8px; }
.integration-empty :global(svg) { color: var(--color-accent); }
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .75fr); gap: 10px; }
@media (max-width: 620px) { .clone-repository-dialog { width: min(620px, calc(100vw - 20px)); } .clone-target-grid { grid-template-columns: 1fr; } .repository-option-meta { display: none; } }
</style>
+82
View File
@@ -635,6 +635,19 @@
"Öffne das Tab-Kontextmenü, um ein Repository aus der aktuellen Arbeitsfläche zu entfernen, ohne Dateien zu löschen.", "Öffne das Tab-Kontextmenü, um ein Repository aus der aktuellen Arbeitsfläche zu entfernen, ohne Dateien zu löschen.",
], ],
}, },
{
id: "app-integrations",
title: "Git-Hosting-Integrationen und Clone",
summary: "Gitty verbindet sich mit GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps und Gitea. Danach kannst du deine verfügbaren Repositories direkt im Clone-Dialog durchsuchen und laden.",
steps: [
"Öffne Einstellungen → Integrationen, wähle einen Anbieter und trage Server-URL, Benutzername sowie einen Personal Access Token ein.",
"Für Azure DevOps kannst du mehrere Organisationen anlegen. Jede Organisation besitzt einen eigenen Anzeigenamen, eine Organisations-URL und einen separat gespeicherten Token.",
"Aktiviere die Integration und speichere die Einstellungen. Tokens werden getrennt von den App-Einstellungen im Schlüsselbund des Betriebssystems abgelegt.",
"Öffne Clone → Integrationen und wähle das gewünschte Konto. Gitty lädt alle zugänglichen Repositories und sortiert sie alphabetisch.",
"Filtere bei Bedarf nach Name oder Beschreibung, wähle ein Repository und einen Zielordner und starte den Clone direkt mit den gespeicherten Zugangsdaten.",
],
note: "Vergib Tokens nur die benötigten Leserechte und eine möglichst kurze Laufzeit. Entfernst du einen gespeicherten Token in Gitty, wird die betroffene Integration automatisch deaktiviert.",
},
{ {
id: "app-commit-detail", id: "app-commit-detail",
title: "Saubere Commits in Gitty erstellen", title: "Saubere Commits in Gitty erstellen",
@@ -1097,6 +1110,19 @@
"Use the tab context menu to remove a repository from the workspace without deleting its files.", "Use the tab context menu to remove a repository from the workspace without deleting its files.",
], ],
}, },
{
id: "app-integrations",
title: "Git hosting integrations and Clone",
summary: "Gitty connects to GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and Gitea. You can then browse and clone the repositories available to your accounts directly from the Clone dialog.",
steps: [
"Open Settings → Integrations, select a provider, and enter its server URL, username, and personal access token.",
"Azure DevOps supports multiple organizations. Every organization has its own display name, organization URL, and separately stored token.",
"Enable the integration and save the settings. Tokens are kept in the operating system keychain rather than application settings.",
"Open Clone → Integrations and select an account. Gitty loads every accessible repository and sorts the list alphabetically.",
"Filter by name or description when needed, select a repository and destination, and clone it directly with the stored credentials.",
],
note: "Give tokens only the required read permissions and the shortest practical lifetime. Removing a stored token in Gitty automatically disables the affected integration.",
},
{ {
id: "app-commit-detail", id: "app-commit-detail",
title: "Create clean commits in Gitty", title: "Create clean commits in Gitty",
@@ -1522,6 +1548,34 @@
label: "Neu in Gitty", label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.", description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [ sections: [
{
id: "changelog-2026-8-8",
title: "Version 2026.8.8",
summary: "Dieses Release verbindet Gitty mit den wichtigsten Git-Hosting-Diensten und macht das Klonen aus deinen eigenen Repository-Listen deutlich schneller.",
steps: [
"Neue Integrationen für GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps und Gitea lassen sich zentral in den Einstellungen verwalten. Personal Access Tokens werden sicher im Schlüsselbund des Betriebssystems gespeichert.",
"Azure DevOps unterstützt mehrere Organisationen mit jeweils eigenem Anzeigenamen, eigener Organisations-URL, eigenem Benutzernamen und Token.",
"Der Clone-Dialog besitzt einen Integrationen-Reiter. Er lädt alle zugänglichen Repositories des gewählten Kontos, sortiert sie alphabetisch und unterstützt Suche, Aktualisieren und direktes Klonen mit den gespeicherten Zugangsdaten.",
"Die Repository-Tab-Leiste ist kompakter und näher an klassischen Git-Clients gestaltet. Das Schließen-X bleibt sichtbar und wird nur beim Überfahren rot.",
"Eine schmale eigene Scrollbar im Repository-Browser überdeckt weder Namen noch Metadaten und wird beim Überfahren nur leicht breiter.",
"Repository-Loading- und Status-Flächen reagieren konsistenter auf das aktive Theme und sind kompakter und kontrastreicher.",
"Das Entfernen eines nicht vorhandenen Upstreams ist jetzt ein sicherer No-op und löst keinen fatalen Git-Fehler mehr aus.",
],
note: "Die Integrationen verwenden HTTPS und Personal Access Tokens. Welche Repositories sichtbar sind, richtet sich nach den Berechtigungen des jeweiligen Tokens und Kontos.",
},
{
id: "changelog-2026-8-7",
title: "Version 2026.8.7",
summary: "Dieses Release erweitert die Darstellungseinstellungen und macht die Branch-Auswahl bei vielen lokalen und entfernten Branches übersichtlicher.",
steps: [
"In den Einstellungen stehen die Darstellungsstile Aktuell, Klassisch und Eigene zur Verfügung. Beim eigenen Stil lässt sich eine individuelle Farbpalette konfigurieren und dauerhaft speichern.",
"Ein vollständiges helles Theme ergänzt die überarbeitete dunkle Darstellung. Farben, Flächen, Bedienelemente und Fokusrahmen besitzen klarere Grenzen und konsistentere Kontraste.",
"Der Dialog zur Branch-Sichtbarkeit trennt lokale und entfernte Branches in auf- und zuklappbare Gruppen und zeigt für jede Gruppe die Anzahl der ausgewählten Branches.",
"Beim Öffnen ist die lokale Gruppe ausgeklappt und die Remote-Gruppe zunächst geschlossen, damit häufig verwendete Branches schneller erreichbar sind.",
"Die Branch-Auswahl passt sich kleineren Fenstergrößen besser an und folgt dem visuellen Stil der übrigen Gitty-Dialoge.",
],
note: "Darstellungsstil und eigene Farben werden lokal gespeichert und beim nächsten Start automatisch wieder angewendet.",
},
{ {
id: "changelog-2026-8-6", id: "changelog-2026-8-6",
title: "Version 2026.8.6", title: "Version 2026.8.6",
@@ -1652,6 +1706,34 @@
label: "What's new", label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.", description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [ sections: [
{
id: "changelog-2026-8-8",
title: "Version 2026.8.8",
summary: "This release connects Gitty to the major Git hosting services and makes cloning from your own repository lists substantially faster.",
steps: [
"New integrations for GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and Gitea can be managed centrally in Settings. Personal access tokens are stored securely in the operating system keychain.",
"Azure DevOps supports multiple organizations, each with its own display name, organization URL, username, and token.",
"The Clone dialog has an Integrations tab. It loads every repository accessible to the selected account, sorts the list alphabetically, and supports search, refresh, and direct cloning with stored credentials.",
"The repository tab bar is more compact and closer to familiar Git clients. Its close button remains visible and turns red only while hovered.",
"A narrow custom scrollbar in the repository browser no longer covers names or metadata and grows only slightly on hover.",
"Repository loading and status surfaces respond more consistently to the active theme with improved contrast and a more compact presentation.",
"Clearing a missing upstream is now a safe no-op instead of producing a fatal Git error.",
],
note: "Integrations use HTTPS and personal access tokens. The repositories shown depend on the permissions granted to the selected account and token.",
},
{
id: "changelog-2026-8-7",
title: "Version 2026.8.7",
summary: "This release expands appearance settings and makes branch selection easier to navigate in repositories with many local and remote branches.",
steps: [
"Settings now provide Modern, Classic, and Custom appearance styles. Custom mode supports an individual color palette that is persisted across restarts.",
"A complete light theme complements the refreshed dark appearance. Colors, surfaces, controls, and focus outlines have clearer boundaries and more consistent contrast.",
"The branch visibility dialog separates local and remote branches into collapsible groups and displays the number of selected branches for each group.",
"The local group opens by default while the remote group starts collapsed, keeping frequently used branches quicker to reach.",
"The branch selector responds better to smaller window sizes and follows the visual language of the other Gitty dialogs.",
],
note: "The selected appearance style and custom colors are stored locally and restored automatically on the next start.",
},
{ {
id: "changelog-2026-8-6", id: "changelog-2026-8-6",
title: "Version 2026.8.6", title: "Version 2026.8.6",
+58 -24
View File
@@ -117,6 +117,8 @@
let customVisibleBranches = $state<Set<string>>(new Set()); let customVisibleBranches = $state<Set<string>>(new Set());
let loadedVisibilityRepository = $state(""); let loadedVisibilityRepository = $state("");
let branchDialogOpen = $state(false); let branchDialogOpen = $state(false);
let localBranchGroupOpen = $state(true);
let remoteBranchGroupOpen = $state(false);
let expandedRefsCommitHash = $state(""); let expandedRefsCommitHash = $state("");
let panelElement = $state<HTMLElement | null>(null); let panelElement = $state<HTMLElement | null>(null);
let contextCommit = $state<GitCommit | null>(null); let contextCommit = $state<GitCommit | null>(null);
@@ -428,6 +430,8 @@
} }
function openBranchDialog() { function openBranchDialog() {
localBranchGroupOpen = true;
remoteBranchGroupOpen = false;
branchDialogOpen = true; branchDialogOpen = true;
} }
@@ -1202,32 +1206,62 @@
<div class="branch-filter-dialog-list"> <div class="branch-filter-dialog-list">
{#if localBranchNames.length > 0} {#if localBranchNames.length > 0}
<span class="branch-filter-group-label">Local</span> <section class="branch-filter-group">
{#each localBranchNames as branch} <button
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}> class="branch-filter-group-toggle"
<input type="button"
type="checkbox" aria-expanded={localBranchGroupOpen}
checked={branchIsVisible(branch)} onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
onchange={() => toggleGraphBranch(branch)} >
/> {#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<GitBranch size={14} aria-hidden="true" /> <span>Local</span>
<span>{branch}</span> <em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
</label> </button>
{/each} {#if localBranchGroupOpen}
<div class="branch-filter-group-list">
{#each localBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
</div>
{/if}
</section>
{/if} {/if}
{#if remoteBranchNames.length > 0} {#if remoteBranchNames.length > 0}
<span class="branch-filter-group-label">Remote</span> <section class="branch-filter-group">
{#each remoteBranchNames as branch} <button
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}> class="branch-filter-group-toggle"
<input type="button"
type="checkbox" aria-expanded={remoteBranchGroupOpen}
checked={branchIsVisible(branch)} onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
onchange={() => toggleGraphBranch(branch)} >
/> {#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<GitBranch size={14} aria-hidden="true" /> <span>Remote</span>
<span>{branch}</span> <em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
</label> </button>
{/each} {#if remoteBranchGroupOpen}
<div class="branch-filter-group-list">
{#each remoteBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
</div>
{/if}
</section>
{/if} {/if}
</div> </div>
</div> </div>
@@ -0,0 +1,290 @@
<script lang="ts">
import { Building2, CheckCircle2, CircleDashed, Eye, EyeOff, KeyRound, Plus, Server, Trash2 } from "@lucide/svelte";
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";
interface Props {
language: AppLanguage;
settings: GitIntegrationSettings;
onChange: (settings: GitIntegrationSettings) => void;
onSecretsChange: (updates: GitIntegrationSecretUpdate[]) => void;
}
let { language, settings, onChange, onSecretsChange }: Props = $props();
let selected = $state<GitIntegrationProvider>("github");
let selectedAzureOrganizationId = $state("");
let tokenValues = $state<Record<string, string>>({});
let secretUpdates = $state<Record<string, GitIntegrationSecretUpdate>>({});
let showToken = $state(false);
const isGerman = $derived(language === "de");
const selectedAzureOrganization = $derived(settings.azureDevOpsOrganizations.find((organization) => organization.id === selectedAzureOrganizationId));
const current = $derived<GitIntegrationConfig | AzureDevOpsOrganization | undefined>(selected === "azure-devops" ? selectedAzureOrganization : settings.providers[selected]);
const currentAccountId = $derived(selected === "azure-devops" ? selectedAzureOrganization?.id : undefined);
const azureDevOpsIcon: SimpleIcon = {
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> = { github: siGithub, gitlab: siGitlab, "gitlab-self-hosted": siGitlab, "azure-devops": azureDevOpsIcon, gitea: siGitea };
function providerDescription(provider: GitIntegrationProvider): string {
const descriptions = isGerman
? { 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;
}
function emitSecrets() {
onSecretsChange(Object.values(secretUpdates));
}
function updateCurrent(patch: Partial<GitIntegrationConfig & AzureDevOpsOrganization>) {
if (!current) return;
if (selected === "azure-devops" && selectedAzureOrganization) {
onChange({
...settings,
azureDevOpsOrganizations: settings.azureDevOpsOrganizations.map((organization) => organization.id === selectedAzureOrganization.id ? { ...organization, ...patch } : organization),
});
return;
}
onChange({ ...settings, providers: { ...settings.providers, [selected]: { ...settings.providers[selected], ...patch } } });
}
function setToken(value: string) {
if (!current) return;
const id = secretId(selected, currentAccountId);
tokenValues[id] = value;
if (value.trim()) secretUpdates[id] = { provider: selected, accountId: currentAccountId, token: value };
else delete secretUpdates[id];
tokenValues = { ...tokenValues };
secretUpdates = { ...secretUpdates };
emitSecrets();
}
function forgetToken() {
if (!current) return;
const id = secretId(selected, currentAccountId);
tokenValues[id] = "";
secretUpdates[id] = { provider: selected, accountId: currentAccountId, removeToken: true };
tokenValues = { ...tokenValues };
secretUpdates = { ...secretUpdates };
updateCurrent({ enabled: false, tokenStored: false });
emitSecrets();
}
function pendingRemoval(provider: GitIntegrationProvider, accountId?: string): boolean {
return secretUpdates[secretId(provider, accountId)]?.removeToken === true;
}
function tokenValue(): string {
return tokenValues[secretId(selected, currentAccountId)] ?? "";
}
function selectProvider(provider: GitIntegrationProvider) {
selected = provider;
showToken = false;
if (provider === "azure-devops" && !settings.azureDevOpsOrganizations.some((organization) => organization.id === selectedAzureOrganizationId)) {
selectedAzureOrganizationId = settings.azureDevOpsOrganizations[0]?.id ?? "";
}
}
function createOrganizationId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `org-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function addAzureOrganization() {
const id = createOrganizationId();
const number = settings.azureDevOpsOrganizations.length + 1;
const organization: AzureDevOpsOrganization = {
id,
name: isGerman ? `Organisation ${number}` : `Organization ${number}`,
enabled: true,
baseUrl: "https://dev.azure.com/",
username: "",
tokenStored: false,
};
onChange({ ...settings, azureDevOpsOrganizations: [...settings.azureDevOpsOrganizations, organization] });
selectedAzureOrganizationId = id;
showToken = false;
}
function removeAzureOrganization(organization: AzureDevOpsOrganization) {
const id = secretId("azure-devops", organization.id);
if (organization.tokenStored) secretUpdates[id] = { provider: "azure-devops", accountId: organization.id, removeToken: true };
else delete secretUpdates[id];
delete tokenValues[id];
secretUpdates = { ...secretUpdates };
tokenValues = { ...tokenValues };
const remaining = settings.azureDevOpsOrganizations.filter((candidate) => candidate.id !== organization.id);
onChange({ ...settings, azureDevOpsOrganizations: remaining });
selectedAzureOrganizationId = remaining[0]?.id ?? "";
showToken = false;
emitSecrets();
}
function organizationDisplayName(organization: AzureDevOpsOrganization): string {
return organization.name.trim() || organizationNameFromUrl(organization.baseUrl) || (isGerman ? "Unbenannte Organisation" : "Unnamed organization");
}
function isOrganizationConfigured(organization: AzureDevOpsOrganization): boolean {
return organization.tokenStored && organization.baseUrl.trim().length > 0 && !pendingRemoval("azure-devops", organization.id);
}
function isConfigured(provider: GitIntegrationProvider): boolean {
if (provider === "azure-devops") return settings.azureDevOpsOrganizations.some(isOrganizationConfigured);
const config = settings.providers[provider];
return config.tokenStored && config.baseUrl.trim().length > 0 && !pendingRemoval(provider);
}
function currentConfigured(): boolean {
if (!current) return false;
return current.tokenStored && current.baseUrl.trim().length > 0 && !pendingRemoval(selected, currentAccountId);
}
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";
}
</script>
<div class="integration-layout">
<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:${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>
{/each}
</div>
<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:${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>
{:else}
<span class="integration-status" class:configured={currentConfigured()}>{#if currentConfigured()}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}{currentConfigured() ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}</span>
{/if}
</header>
{#if selected === "azure-devops"}
<div class="azure-organizations">
<div class="azure-organizations-head"><div><strong>{isGerman ? "Organisationen" : "Organizations"}</strong><small>{isGerman ? "Jede Organisation verwendet einen eigenen Token." : "Each organization uses its own token."}</small></div><button type="button" onclick={addAzureOrganization}><Plus size={14} />{isGerman ? "Hinzufügen" : "Add"}</button></div>
{#if settings.azureDevOpsOrganizations.length === 0}
<div class="azure-organizations-empty"><Building2 size={22} /><span>{isGerman ? "Noch keine Azure-DevOps-Organisation angelegt." : "No Azure DevOps organization has been added yet."}</span><button type="button" onclick={addAzureOrganization}><Plus size={14} />{isGerman ? "Erste Organisation anlegen" : "Add first organization"}</button></div>
{:else}
<div class="azure-organization-list" role="tablist" aria-label={isGerman ? "Azure-DevOps-Organisationen" : "Azure DevOps organizations"}>
{#each settings.azureDevOpsOrganizations as organization (organization.id)}
<div class="azure-organization-row" class:active={selectedAzureOrganizationId === organization.id}>
<button type="button" role="tab" aria-selected={selectedAzureOrganizationId === organization.id} onclick={() => { selectedAzureOrganizationId = organization.id; showToken = false; }}>
<span><strong>{organizationDisplayName(organization)}</strong><small>{organization.baseUrl}</small></span><i class:configured={isOrganizationConfigured(organization)}></i>
</button>
<button class="azure-remove" type="button" onclick={() => removeAzureOrganization(organization)} title={isGerman ? "Organisation entfernen" : "Remove organization"} aria-label={`${organizationDisplayName(organization)} ${isGerman ? "entfernen" : "remove"}`}><Trash2 size={13} /></button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if current}
{#if selected === "azure-devops"}
<label class="integration-field"><span><Building2 size={13} />{isGerman ? "Anzeigename" : "Display name"}</span><input value={selectedAzureOrganization?.name ?? ""} oninput={(event) => updateCurrent({ name: event.currentTarget.value })} placeholder={isGerman ? "z. B. Contoso Platform" : "e.g. Contoso Platform"} /></label>
{/if}
<label class="integration-field">
<span><Server size={13} />{selected === "azure-devops" ? (isGerman ? "Organisations-URL" : "Organization URL") : (isGerman ? "Server-URL" : "Server URL")}</span>
<input value={current.baseUrl} oninput={(event) => updateCurrent({ baseUrl: event.currentTarget.value })} placeholder={baseUrlPlaceholder()} spellcheck="false" inputmode="url" />
<small>{isGerman ? "Basis-URL ohne Repository-Pfad." : "Base URL without a repository path."}</small>
</label>
<label class="integration-field"><span>{isGerman ? "Benutzername oder E-Mail" : "Username or email"}</span><input value={current.username} oninput={(event) => updateCurrent({ username: event.currentTarget.value })} autocomplete="off" placeholder={selected === "azure-devops" ? "name@example.com" : (isGerman ? "Benutzername" : "Username")} spellcheck="false" /></label>
<label class="integration-field">
<span><KeyRound size={13} />Personal Access Token</span>
<div class="token-row"><input type={showToken ? "text" : "password"} value={tokenValue()} oninput={(event) => setToken(event.currentTarget.value)} autocomplete="new-password" placeholder={current.tokenStored && !pendingRemoval(selected, currentAccountId) ? (isGerman ? "Token ist sicher gespeichert" : "Token is stored securely") : (isGerman ? "Token einfügen" : "Paste token")} spellcheck="false" /><button type="button" onclick={() => { showToken = !showToken; }} title={showToken ? (isGerman ? "Token ausblenden" : "Hide token") : (isGerman ? "Token anzeigen" : "Show token")} aria-label={showToken ? (isGerman ? "Token ausblenden" : "Hide token") : (isGerman ? "Token anzeigen" : "Show token")}>{#if showToken}<EyeOff size={15} />{:else}<Eye size={15} />{/if}</button></div>
<small>{isGerman ? "Der Token wird separat im Schlüsselbund des Betriebssystems gespeichert." : "The token is stored separately in the operating system keychain."}</small>
</label>
<div class="integration-actions">
<label class="integration-enabled"><span><strong>{selected === "azure-devops" ? (isGerman ? "Organisation aktivieren" : "Enable organization") : (isGerman ? "Integration aktivieren" : "Enable integration")}</strong><small>{isGerman ? "Für Hosting- und Clone-Funktionen verwenden." : "Use for hosting and clone features."}</small></span><input type="checkbox" checked={current.enabled} onchange={(event) => updateCurrent({ enabled: event.currentTarget.checked })} /></label>
{#if current.tokenStored && !pendingRemoval(selected, currentAccountId)}<button class="forget-token" type="button" onclick={forgetToken}><Trash2 size={14} />{isGerman ? "Gespeicherten Token entfernen" : "Remove stored token"}</button>{/if}
</div>
{/if}
</section>
</div>
<style>
.integration-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 14px; min-height: 420px; }
.integration-providers { display: flex; flex-direction: column; gap: 5px; }
.integration-providers > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 64px; padding: 9px 10px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-ink-dim); background: var(--app-settings-row-bg); text-align: left; }
.integration-providers > button:hover { color: var(--color-ink); border-color: var(--color-border); background: var(--color-surface-hover); }
.integration-providers > button.active { border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
.provider-logo { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid color-mix(in srgb, var(--provider-color) 34%, var(--color-border)); border-radius: 8px; color: var(--provider-color); background: color-mix(in srgb, var(--provider-color) 10%, transparent); }
.provider-logo svg { width: 17px; height: 17px; fill: currentColor; }
.provider-logo-large { width: 42px; height: 42px; border-radius: 10px; }
.provider-logo-large svg { width: 22px; height: 22px; }
.provider-copy { display: grid; min-width: 0; gap: 3px; }
.provider-copy strong { color: inherit; font-size: 11px; }
.provider-copy small { overflow: hidden; color: var(--color-ink-faint); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.provider-state { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ink-faint); }
.provider-state.configured { background: var(--color-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 12%, transparent); }
.integration-config { display: grid; align-content: start; gap: 13px; min-width: 0; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.integration-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding-bottom: 13px; border-bottom: 1px solid var(--color-border-subtle); }
.integration-summary h4 { margin: 0; color: var(--color-ink); font-size: 14px; }
.integration-summary p { margin: 3px 0 0; color: var(--color-ink-dim); font-size: 10.5px; }
.integration-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
.integration-status.configured { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
.azure-organizations { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
.azure-organizations-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.azure-organizations-head > div { display: grid; gap: 2px; }
.azure-organizations-head strong { color: var(--color-ink); font-size: 10.5px; }
.azure-organizations-head small { color: var(--color-ink-faint); font-size: 8.5px; }
.azure-organizations-head button, .azure-organizations-empty button { min-height: 27px; padding: 0 8px; font-size: 9.5px; font-weight: 750; }
.azure-organization-list { display: grid; gap: 5px; max-height: 142px; overflow: auto; }
.azure-organization-row { display: grid; grid-template-columns: minmax(0, 1fr) 30px; gap: 4px; border: 1px solid var(--color-border-subtle); border-radius: 7px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.azure-organization-row.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); box-shadow: inset 2px 0 0 var(--color-accent); }
.azure-organization-row > button:first-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; min-height: 42px; padding: 5px 8px; border: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
.azure-organization-row > button:first-child span { display: grid; min-width: 0; gap: 2px; }
.azure-organization-row strong, .azure-organization-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.azure-organization-row strong { color: var(--color-ink); font-size: 10px; }
.azure-organization-row small { color: var(--color-ink-faint); font-size: 8.5px; }
.azure-organization-row i { width: 6px; height: 6px; border-radius: 50%; background: var(--color-ink-faint); }
.azure-organization-row i.configured { background: var(--color-success); }
.azure-remove { min-height: 30px; align-self: center; padding: 0; border: 0; color: var(--color-ink-faint); background: transparent; }
.azure-remove:hover { color: #e86060; background: color-mix(in srgb, #e86060 8%, transparent); }
.azure-organizations-empty { display: grid; place-items: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
.azure-organizations-empty > :global(svg) { color: var(--color-accent); }
.azure-organizations-empty span { font-size: 9.5px; }
.integration-field { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.integration-field > span { display: flex; align-items: center; gap: 5px; }
.integration-field input { height: 36px; border-color: var(--color-border); background: var(--color-surface-raised); font-size: 11px; }
.integration-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
.token-row { display: grid; grid-template-columns: minmax(0, 1fr) 36px; gap: 6px; }
.token-row button { display: grid; place-items: center; min-height: 36px; padding: 0; }
.integration-actions { display: grid; gap: 10px; padding-top: 2px; }
.integration-enabled { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.integration-enabled > span { display: grid; gap: 3px; }
.integration-enabled strong { color: var(--color-ink); font-size: 10.5px; }
.integration-enabled small { color: var(--color-ink-faint); font-size: 9px; }
.integration-enabled input { width: 32px; height: 18px; accent-color: var(--color-accent); }
.forget-token { justify-self: start; min-height: 28px; color: #e86060; font-size: 10px; }
@media (max-width: 680px) { .integration-layout { grid-template-columns: 1fr; min-height: 0; } .integration-providers { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .integration-providers > button { min-height: 54px; } .provider-copy small { display: none; } }
@media (max-width: 430px) { .integration-providers { grid-template-columns: 1fr; } .integration-summary { grid-template-columns: auto minmax(0, 1fr); } .integration-status { grid-column: 1 / -1; justify-self: start; } .azure-organizations-head { align-items: stretch; flex-direction: column; } .azure-organizations-head button { align-self: start; } }
</style>
+52 -80
View File
@@ -41,17 +41,24 @@
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
background: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%); background:
radial-gradient(
circle at 50% 42%,
color-mix(in srgb, var(--color-accent) 13%, transparent),
transparent 38%
),
var(--app-dialog-backdrop);
backdrop-filter: blur(5px);
animation: overlay-in 180ms ease; animation: overlay-in 180ms ease;
} }
.repo-loading-grid { .repo-loading-grid {
position: absolute; position: absolute;
inset: 0; inset: 0;
opacity: 0.28; opacity: 0.34;
background-image: background-image:
linear-gradient(rgba(111, 140, 255, 0.09) 1px, transparent 1px), linear-gradient(color-mix(in srgb, var(--color-primary) 9%, transparent) 1px, transparent 1px),
linear-gradient(90deg, rgba(77, 182, 214, 0.07) 1px, transparent 1px); linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 8%, transparent) 1px, transparent 1px);
background-size: 42px 42px; background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 0%, transparent 68%); mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
animation: grid-drift 10s linear infinite; animation: grid-drift 10s linear infinite;
@@ -62,23 +69,21 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 18px; gap: 14px;
width: min(420px, calc(100vw - 42px)); width: min(360px, calc(100vw - 32px));
padding: 30px 34px 28px; padding: 26px 30px 24px;
border: 1px solid rgba(90, 111, 154, 0.28); border: 1px solid var(--color-border-input);
border-radius: 16px; border-radius: 14px;
background: background:
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)), var(--app-panel-highlight),
var(--color-surface-raised); var(--app-dialog-bg);
box-shadow: box-shadow: var(--app-dialog-shadow);
0 26px 78px rgba(0, 0, 0, 0.54),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
} }
.repo-loading-mark { .repo-loading-mark {
position: relative; position: relative;
width: 168px; width: 116px;
height: 168px; height: 116px;
display: grid; display: grid;
place-items: center; place-items: center;
isolation: isolate; isolation: isolate;
@@ -86,25 +91,25 @@
.repo-loading-halo { .repo-loading-halo {
position: absolute; position: absolute;
inset: 10px; inset: 8px;
border: 1px solid rgba(111, 140, 255, 0.22); border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
border-radius: 34px; border-radius: 24px;
transform: rotate(45deg); transform: rotate(45deg);
} }
.repo-loading-halo.halo-one { .repo-loading-halo.halo-one {
animation: halo-breathe 2.4s ease-in-out infinite; animation: halo-breathe 2.4s ease-in-out infinite;
} }
.repo-loading-halo.halo-two { .repo-loading-halo.halo-two {
inset: 24px; inset: 18px;
border-color: rgba(77, 182, 214, 0.24); border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
animation: halo-breathe 2.4s ease-in-out infinite reverse; animation: halo-breathe 2.4s ease-in-out infinite reverse;
} }
.repo-loading-traces { .repo-loading-traces {
position: absolute; position: absolute;
inset: -18px; inset: -14px;
width: 204px; width: 144px;
height: 204px; height: 144px;
overflow: visible; overflow: visible;
z-index: 0; z-index: 0;
} }
@@ -114,18 +119,18 @@
stroke-linecap: round; stroke-linecap: round;
stroke-dasharray: 165; stroke-dasharray: 165;
stroke-dashoffset: 165; stroke-dashoffset: 165;
filter: drop-shadow(0 0 8px rgba(77, 182, 214, 0.32)); filter: drop-shadow(0 0 7px color-mix(in srgb, var(--color-accent) 38%, transparent));
animation: trace-draw 2.6s ease-in-out infinite; animation: trace-draw 2.6s ease-in-out infinite;
} }
.repo-loading-traces .trace-main { .repo-loading-traces .trace-main {
stroke: #6f8cff; stroke: var(--color-primary);
} }
.repo-loading-traces .trace-branch { .repo-loading-traces .trace-branch {
stroke: #4db6d6; stroke: var(--color-accent);
animation-delay: 0.28s; animation-delay: 0.28s;
} }
.repo-loading-traces .trace-cut { .repo-loading-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42); stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
stroke-dasharray: 128; stroke-dasharray: 128;
stroke-dashoffset: 128; stroke-dashoffset: 128;
animation-delay: 0.55s; animation-delay: 0.55s;
@@ -134,12 +139,12 @@
.repo-loading-icon { .repo-loading-icon {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 132px; width: 82px;
height: 132px; height: 82px;
object-fit: contain; object-fit: contain;
filter: filter:
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55)) drop-shadow(0 10px 16px color-mix(in srgb, var(--color-ink) 24%, transparent))
drop-shadow(0 0 18px rgba(77, 182, 214, 0.2)); drop-shadow(0 0 14px color-mix(in srgb, var(--color-accent) 24%, transparent));
animation: icon-float 2.4s ease-in-out infinite; animation: icon-float 2.4s ease-in-out infinite;
} }
@@ -153,16 +158,16 @@
.repo-loading-label { .repo-loading-label {
color: var(--color-ink); color: var(--color-ink);
font-size: 15px; font-size: 14px;
font-weight: 800; font-weight: 750;
} }
.repo-loading-name { .repo-loading-name {
max-width: 260px; max-width: 250px;
overflow: hidden; overflow: hidden;
color: var(--color-ink-faint); color: var(--color-ink-faint);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12.5px; font-size: 12px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -172,60 +177,20 @@
width: min(230px, 100%); width: min(230px, 100%);
height: 4px; height: 4px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border-subtle);
border-radius: 999px; border-radius: 999px;
background: rgba(111, 140, 255, 0.14); background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
} }
.repo-loading-bar span { .repo-loading-bar span {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 46%; width: 46%;
border-radius: inherit; border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent); background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
box-shadow: 0 0 16px rgba(77, 182, 214, 0.26); box-shadow: 0 0 14px color-mix(in srgb, var(--color-accent) 32%, transparent);
animation: bar-slide 1.35s ease-in-out infinite; animation: bar-slide 1.35s ease-in-out infinite;
} }
:global(:root[data-theme="light"]) .repo-loading {
background:
radial-gradient(circle at 50% 42%, rgba(49, 95, 214, 0.12), transparent 34%),
linear-gradient(135deg, #f7f9fd 0%, #eef3f9 48%, #f8fafc 100%);
}
:global(:root[data-theme="light"]) .repo-loading-grid {
opacity: 0.38;
background-image:
linear-gradient(rgba(49, 95, 214, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 143, 181, 0.08) 1px, transparent 1px);
}
:global(:root[data-theme="light"]) .repo-loading-card {
border-color: rgba(61, 89, 142, 0.2);
background:
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,252,0.94)),
var(--color-surface-raised);
box-shadow:
0 26px 72px rgba(28,44,74,0.18),
inset 0 1px 0 rgba(255,255,255,0.9);
}
:global(:root[data-theme="light"]) .repo-loading-traces .trace {
filter: drop-shadow(0 0 8px rgba(49,95,214,0.22));
}
:global(:root[data-theme="light"]) .repo-loading-traces .trace-cut {
stroke: rgba(49,95,214,0.34);
}
:global(:root[data-theme="light"]) .repo-loading-icon {
filter:
drop-shadow(0 18px 24px rgba(28,44,74,0.2))
drop-shadow(0 0 18px rgba(49,95,214,0.18));
}
:global(:root[data-theme="light"]) .repo-loading-bar {
background: rgba(49,95,214,0.12);
}
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } @keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } } @keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
@keyframes icon-float { @keyframes icon-float {
@@ -254,4 +219,11 @@
.repo-loading-bar span { animation: none; } .repo-loading-bar span { animation: none; }
.repo-loading-traces .trace { stroke-dashoffset: 0; opacity: 0.72; } .repo-loading-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
} }
@media (max-width: 520px) {
.repo-loading-card {
width: calc(100vw - 20px);
padding: 22px 20px 20px;
}
}
</style> </style>
+22 -17
View File
@@ -660,8 +660,12 @@
display: grid; display: grid;
place-items: center; place-items: center;
background: background:
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%), radial-gradient(
rgba(7, 10, 16, 0.62); circle at 50% 38%,
color-mix(in srgb, var(--color-accent) 13%, transparent),
transparent 55%
),
var(--app-dialog-backdrop);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
animation: status-panel-overlay-in 120ms ease; animation: status-panel-overlay-in 120ms ease;
} }
@@ -673,13 +677,13 @@
gap: 12px; gap: 12px;
width: min(300px, calc(100% - 32px)); width: min(300px, calc(100% - 32px));
padding: 26px 28px 26px; padding: 26px 28px 26px;
border: 1px solid rgba(90, 111, 154, 0.28); border: 1px solid var(--color-border-input);
border-radius: 16px; border-radius: 16px;
background: background:
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)), var(--app-panel-highlight),
var(--color-surface-raised); var(--app-dialog-bg);
color: var(--color-ink); color: var(--color-ink);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05); box-shadow: var(--app-dialog-shadow);
} }
.status-panel-overlay-mark { .status-panel-overlay-mark {
@@ -694,7 +698,7 @@
.status-panel-overlay-halo { .status-panel-overlay-halo {
position: absolute; position: absolute;
inset: 6px; inset: 6px;
border: 1px solid rgba(111, 140, 255, 0.22); border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
border-radius: 22px; border-radius: 22px;
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -703,7 +707,7 @@
} }
.status-panel-overlay-halo.halo-two { .status-panel-overlay-halo.halo-two {
inset: 16px; inset: 16px;
border-color: rgba(77, 182, 214, 0.24); border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse; animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
} }
@@ -721,16 +725,16 @@
stroke-linecap: round; stroke-linecap: round;
stroke-dasharray: 165; stroke-dasharray: 165;
stroke-dashoffset: 165; stroke-dashoffset: 165;
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32)); filter: drop-shadow(0 0 6px color-mix(in srgb, var(--color-accent) 38%, transparent));
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite; animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
} }
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; } .status-panel-overlay-traces .trace-main { stroke: var(--color-primary); }
.status-panel-overlay-traces .trace-branch { .status-panel-overlay-traces .trace-branch {
stroke: #4db6d6; stroke: var(--color-accent);
animation-delay: 0.28s; animation-delay: 0.28s;
} }
.status-panel-overlay-traces .trace-cut { .status-panel-overlay-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42); stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
stroke-dasharray: 128; stroke-dasharray: 128;
stroke-dashoffset: 128; stroke-dashoffset: 128;
animation-delay: 0.55s; animation-delay: 0.55s;
@@ -743,8 +747,8 @@
height: 64px; height: 64px;
object-fit: contain; object-fit: contain;
filter: filter:
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5)) drop-shadow(0 8px 12px color-mix(in srgb, var(--color-ink) 24%, transparent))
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2)); drop-shadow(0 0 10px color-mix(in srgb, var(--color-accent) 24%, transparent));
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite; animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
} }
@@ -765,15 +769,16 @@
height: 4px; height: 4px;
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: rgba(111, 140, 255, 0.14); border: 1px solid var(--color-border-subtle);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
} }
.status-panel-overlay-bar span { .status-panel-overlay-bar span {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 46%; width: 46%;
border-radius: inherit; border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent); background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26); box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 32%, transparent);
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite; animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
} }
+6
View File
@@ -15,6 +15,8 @@ import type {
GitCommit, GitCommit,
GitCommitComparison, GitCommitComparison,
GitIgnoreKind, GitIgnoreKind,
GitIntegrationProvider,
GitIntegrationRepository,
GitLfsStatus, GitLfsStatus,
GitRepositoryFile, GitRepositoryFile,
GitRemote, GitRemote,
@@ -54,6 +56,10 @@ export function detectExternalTools(): Promise<DetectedExternalTool[]> {
return invoke<DetectedExternalTool[]>("detect_external_tools"); return invoke<DetectedExternalTool[]>("detect_external_tools");
} }
export function listIntegrationRepositories(provider: GitIntegrationProvider, baseUrl: string, accountId?: string): Promise<GitIntegrationRepository[]> {
return invoke<GitIntegrationRepository[]>("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null });
}
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> { export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
return invoke<void>("launch_external_tool", { path, file: file ?? null, command }); return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
} }
+169
View File
@@ -0,0 +1,169 @@
import type {
AzureDevOpsOrganization,
GitIntegrationConfig,
GitIntegrationProvider,
GitIntegrationSource,
GitIntegrationSettings,
} from "./types";
export const gitIntegrationProviders: GitIntegrationProvider[] = [
"github",
"gitlab",
"gitlab-self-hosted",
"azure-devops",
"gitea",
];
const defaults: Record<GitIntegrationProvider, Omit<GitIntegrationConfig, "provider">> = {
github: {
enabled: false,
baseUrl: "https://github.com",
username: "",
tokenStored: false,
},
gitlab: {
enabled: false,
baseUrl: "https://gitlab.com",
username: "",
tokenStored: false,
},
"gitlab-self-hosted": {
enabled: false,
baseUrl: "",
username: "",
tokenStored: false,
},
"azure-devops": {
enabled: false,
baseUrl: "https://dev.azure.com/",
username: "",
tokenStored: false,
},
gitea: {
enabled: false,
baseUrl: "",
username: "",
tokenStored: false,
},
};
export function defaultGitIntegrationSettings(): GitIntegrationSettings {
return {
providers: Object.fromEntries(
gitIntegrationProviders.map((provider) => [provider, { provider, ...defaults[provider] }]),
) as GitIntegrationSettings["providers"],
azureDevOpsOrganizations: [],
};
}
function normaliseAzureOrganization(value: unknown): AzureDevOpsOrganization | null {
if (!value || typeof value !== "object") return null;
const stored = value as Partial<AzureDevOpsOrganization>;
const id = typeof stored.id === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(stored.id) ? stored.id : "";
if (!id) return null;
return {
id,
name: typeof stored.name === "string" ? stored.name : "",
enabled: stored.enabled === true,
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : "",
username: typeof stored.username === "string" ? stored.username : "",
tokenStored: stored.tokenStored === true,
};
}
export function normaliseGitIntegrationSettings(value: unknown): GitIntegrationSettings {
const fallback = defaultGitIntegrationSettings();
if (!value || typeof value !== "object") return fallback;
const storedProviders = (value as Partial<GitIntegrationSettings>).providers;
if (!storedProviders || typeof storedProviders !== "object") return fallback;
for (const provider of gitIntegrationProviders) {
const stored = storedProviders[provider] as Partial<GitIntegrationConfig> | undefined;
if (!stored || typeof stored !== "object") continue;
fallback.providers[provider] = {
provider,
enabled: stored.enabled === true,
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : fallback.providers[provider].baseUrl,
username: typeof stored.username === "string" ? stored.username : "",
tokenStored: stored.tokenStored === true,
};
}
const storedOrganizations = (value as Partial<GitIntegrationSettings>).azureDevOpsOrganizations;
if (Array.isArray(storedOrganizations)) {
const seen = new Set<string>();
fallback.azureDevOpsOrganizations = storedOrganizations
.map(normaliseAzureOrganization)
.filter((organization): organization is AzureDevOpsOrganization => {
if (!organization || seen.has(organization.id)) return false;
seen.add(organization.id);
return true;
});
} else {
const legacy = fallback.providers["azure-devops"];
const hasLegacyConfiguration = legacy.enabled || legacy.tokenStored || legacy.username.trim().length > 0 || !/^https:\/\/dev\.azure\.com\/?$/i.test(legacy.baseUrl.trim());
if (hasLegacyConfiguration) {
fallback.azureDevOpsOrganizations = [{
id: "default",
name: "Azure DevOps",
enabled: legacy.enabled,
baseUrl: legacy.baseUrl,
username: legacy.username,
tokenStored: legacy.tokenStored,
}];
}
}
return fallback;
}
export function integrationCredentialKey(provider: GitIntegrationProvider, accountId?: string): string {
if (provider === "azure-devops" && accountId && accountId !== "default") {
return `integration:${provider}:${accountId}`;
}
return `integration:${provider}`;
}
export function configuredIntegrationSources(settings: GitIntegrationSettings): GitIntegrationSource[] {
const sources: GitIntegrationSource[] = [];
for (const provider of gitIntegrationProviders) {
if (provider === "azure-devops") continue;
const config = settings.providers[provider];
if (config.enabled && config.tokenStored && config.baseUrl.trim()) {
sources.push({ id: provider, provider, label: providerLabel(provider), baseUrl: config.baseUrl });
}
}
for (const organization of settings.azureDevOpsOrganizations) {
if (!organization.enabled || !organization.tokenStored || !organization.baseUrl.trim()) continue;
sources.push({
id: `azure-devops:${organization.id}`,
provider: "azure-devops",
accountId: organization.id,
label: organization.name.trim() || organizationNameFromUrl(organization.baseUrl) || "Azure DevOps",
baseUrl: organization.baseUrl,
});
}
return sources;
}
export function configuredIntegrationCount(settings: GitIntegrationSettings): number {
return configuredIntegrationSources(settings).length;
}
export function providerLabel(provider: GitIntegrationProvider): string {
return {
github: "GitHub",
gitlab: "GitLab.com",
"gitlab-self-hosted": "GitLab Self-Managed",
"azure-devops": "Azure DevOps",
gitea: "Gitea",
}[provider];
}
export function organizationNameFromUrl(value: string): string {
try {
const url = new URL(value);
return url.pathname.split("/").filter(Boolean)[0] ?? "";
} catch {
return "";
}
}
+51
View File
@@ -16,6 +16,57 @@ 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 = "github" | "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea";
export interface GitIntegrationConfig {
provider: GitIntegrationProvider;
enabled: boolean;
baseUrl: string;
username: string;
tokenStored: boolean;
}
export interface GitIntegrationSettings {
providers: Record<GitIntegrationProvider, GitIntegrationConfig>;
azureDevOpsOrganizations: AzureDevOpsOrganization[];
}
export interface AzureDevOpsOrganization {
id: string;
name: string;
enabled: boolean;
baseUrl: string;
username: string;
tokenStored: boolean;
}
export interface GitIntegrationSecretUpdate {
provider: GitIntegrationProvider;
accountId?: string;
token?: string;
removeToken?: boolean;
}
export interface GitIntegrationSource {
id: string;
provider: GitIntegrationProvider;
accountId?: string;
label: string;
baseUrl: string;
}
export interface GitIntegrationRepository {
id: string;
name: string;
fullName: string;
description: string;
cloneUrl: string;
sshUrl: string;
webUrl: string;
updatedAt: string;
private: boolean;
}
export interface CustomThemeColors { export interface CustomThemeColors {
background: string; background: string;
surface: string; surface: string;