Merge remote-tracking branch 'origin/main' into issue-center
# Conflicts: # src-tauri/src/main.rs # src/lib/components/ReviewCenter.svelte # src/lib/git.ts
This commit is contained in:
@@ -14,11 +14,14 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
mod creation;
|
||||||
|
pub use creation::{create_integration_review_request, list_integration_repository_branches};
|
||||||
|
|
||||||
const PAGE_SIZE: usize = 100;
|
const PAGE_SIZE: usize = 100;
|
||||||
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
|
const REVIEW_REQUEST_TIMEOUT: Duration = Duration::from_secs(35);
|
||||||
const AZURE_PROJECT_WORKERS: usize = 8;
|
const AZURE_PROJECT_WORKERS: usize = 8;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct IntegrationRepository {
|
pub struct IntegrationRepository {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn creation_payload(provider: &str, source: &str, target: &str, title: &str, description: &str) -> Result<serde_json::Value, String> {
|
||||||
|
if title.trim().is_empty() || source.trim().is_empty() || target.trim().is_empty() {
|
||||||
|
return Err("Title, source branch and target branch are required.".into());
|
||||||
|
}
|
||||||
|
if source == target { return Err("Source and target branch must be different.".into()); }
|
||||||
|
Ok(match provider {
|
||||||
|
"github" | "gitea" => serde_json::json!({"head":source,"base":target,"title":title,"body":description}),
|
||||||
|
"gitlab" | "gitlab-self-hosted" => serde_json::json!({"source_branch":source,"target_branch":target,"title":title,"description":description}),
|
||||||
|
"azure-devops" => serde_json::json!({"sourceRefName":format!("refs/heads/{source}"),"targetRefName":format!("refs/heads/{target}"),"title":title,"description":description}),
|
||||||
|
_ => return Err("Unsupported integration provider.".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn creation_endpoint(provider: &str, base: &str, repository: &IntegrationRepository) -> Result<reqwest::Url, String> {
|
||||||
|
let base = if provider == "github" { github_api_base_url(base)? } else { normalized_base_url(base)? };
|
||||||
|
let mut url = reqwest::Url::parse(&format!("{base}/")).map_err(|e| e.to_string())?;
|
||||||
|
{
|
||||||
|
let mut path = url.path_segments_mut().map_err(|_| "Invalid integration URL.")?;
|
||||||
|
path.pop_if_empty();
|
||||||
|
match provider {
|
||||||
|
"github" | "gitea" => {
|
||||||
|
let parts: Vec<_> = repository.full_name.split('/').collect();
|
||||||
|
if parts.len() != 2 || parts.iter().any(|part| part.is_empty() || *part == "." || *part == "..") {
|
||||||
|
return Err("Invalid repository name.".into());
|
||||||
|
}
|
||||||
|
if provider == "gitea" { path.extend(["api", "v1"]); }
|
||||||
|
path.push("repos").extend(parts).push("pulls");
|
||||||
|
}
|
||||||
|
"gitlab" | "gitlab-self-hosted" => { path.extend(["api","v4","projects", &repository.id,"merge_requests"]); }
|
||||||
|
"azure-devops" => { path.extend(["_apis","git","repositories", &repository.id,"pullrequests"]); }
|
||||||
|
_ => return Err("Unsupported integration provider.".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if provider == "azure-devops" { url.query_pairs_mut().append_pair("api-version", "7.1"); }
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_integration_review_request(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository, source_branch: String, target_branch: String, title: String, description: String) -> Result<IntegrationReviewRequest, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
|
||||||
|
let source = source_branch.trim().strip_prefix("refs/heads/").unwrap_or(source_branch.trim());
|
||||||
|
let target = target_branch.trim().strip_prefix("refs/heads/").unwrap_or(target_branch.trim());
|
||||||
|
let payload = creation_payload(&provider, source, target, title.trim(), &description)?;
|
||||||
|
let endpoint = creation_endpoint(&provider, &base_url, &repository)?;
|
||||||
|
let client = client()?;
|
||||||
|
let request = client.post(endpoint).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
|
||||||
|
let request = match provider.as_str() {
|
||||||
|
"github" => request.bearer_auth(&token),
|
||||||
|
"gitea" => request.header("Authorization", format!("token {token}")),
|
||||||
|
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
|
||||||
|
"azure-devops" => request.basic_auth(if username.is_empty() { "gitty" } else { &username }, Some(&token)),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
// Never automatically retry creation: a lost response can still mean the PR was created.
|
||||||
|
let response = request.json(&payload).send().map_err(|_| "The creation response could not be received. Check the original repository before trying again.".to_string())?;
|
||||||
|
if !response.status().is_success() { return Err(response_error(response, &provider)); }
|
||||||
|
let mut value: serde_json::Value = response.json().map_err(|_| "The request was created, but its response could not be read. Refresh the Review Center before trying again.".to_string())?;
|
||||||
|
let mut review = match provider.as_str() {
|
||||||
|
"github" => {
|
||||||
|
value["pull_request"] = serde_json::json!({});
|
||||||
|
value["repository_url"] = serde_json::json!(format!("{}/repos/{}", github_api_base_url(&base_url)?, repository.full_name));
|
||||||
|
parse_github_review(&value).ok_or("Could not read the created PR.")?
|
||||||
|
}
|
||||||
|
"gitea" => {
|
||||||
|
value["pull_request"] = value.clone();
|
||||||
|
value["repository"] = serde_json::json!({"id":repository.id.parse::<u64>().unwrap_or_default(),"full_name":repository.full_name});
|
||||||
|
parse_gitea_review(&value).ok_or("Could not read the created PR.")?
|
||||||
|
}
|
||||||
|
"gitlab" | "gitlab-self-hosted" => parse_gitlab_review(&value, &provider),
|
||||||
|
"azure-devops" => parse_azure_review(&value, &repository),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
review.repository_name = repository.full_name;
|
||||||
|
review.source_branch = source.to_string();
|
||||||
|
review.target_branch = target.to_string();
|
||||||
|
Ok(review)
|
||||||
|
}).await.map_err(|e| format!("Could not create review request: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn creation_payloads_and_validation() {
|
||||||
|
for provider in ["github", "gitea", "gitlab", "gitlab-self-hosted", "azure-devops"] {
|
||||||
|
assert!(creation_payload(provider, "main", "main", "Test", "").is_err());
|
||||||
|
assert!(creation_payload(provider, "topic", "main", " ", "").is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(creation_payload("github", "feature/test", "main", "Test", "Body").unwrap()["head"], "feature/test");
|
||||||
|
assert_eq!(creation_payload("gitea", "topic", "main", "Test", "Body").unwrap()["body"], "Body");
|
||||||
|
assert_eq!(creation_payload("gitlab-self-hosted", "topic", "main", "Test", "").unwrap()["source_branch"], "topic");
|
||||||
|
assert_eq!(creation_payload("azure-devops", "topic", "main", "Test", "").unwrap()["targetRefName"], "refs/heads/main");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn creation_urls_preserve_prefixes_and_encode_paths() {
|
||||||
|
let repository: IntegrationRepository = serde_json::from_value(serde_json::json!({"id":"17","name":"repo","fullName":"owner/repo","description":"","cloneUrl":"","sshUrl":"","webUrl":"","updatedAt":"","private":false})).unwrap();
|
||||||
|
assert_eq!(creation_endpoint("github", "https://github.com", &repository).unwrap().as_str(), "https://api.github.com/repos/owner/repo/pulls");
|
||||||
|
assert_eq!(creation_endpoint("gitea", "https://git.example/sub", &repository).unwrap().path(), "/sub/api/v1/repos/owner/repo/pulls");
|
||||||
|
assert_eq!(creation_endpoint("gitlab", "https://git.example", &repository).unwrap().path(), "/api/v4/projects/17/merge_requests");
|
||||||
|
assert_eq!(creation_endpoint("azure-devops", "https://dev.azure.com/org", &repository).unwrap().as_str(), "https://dev.azure.com/org/_apis/git/repositories/17/pullrequests?api-version=7.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RepositoryBranches {
|
||||||
|
branches: Vec<String>,
|
||||||
|
default_branch: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_integration_repository_branches(provider: String, base_url: String, username: String, token: String, repository: IntegrationRepository) -> Result<RepositoryBranches, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
if token.trim().is_empty() { return Err("No token is stored for this integration.".into()); }
|
||||||
|
let client = client()?;
|
||||||
|
let mut metadata_url = creation_endpoint(&provider, &base_url, &repository)?;
|
||||||
|
metadata_url.path_segments_mut().map_err(|_| "Invalid repository URL")?.pop();
|
||||||
|
let get = |url: reqwest::Url| -> Result<Response, String> {
|
||||||
|
let request = client.get(url).header(USER_AGENT, "Gitty").header(ACCEPT, "application/json");
|
||||||
|
let request = match provider.as_str() {
|
||||||
|
"github" => request.bearer_auth(&token),
|
||||||
|
"gitea" => request.header("Authorization", format!("token {token}")),
|
||||||
|
"gitlab" | "gitlab-self-hosted" => request.header("PRIVATE-TOKEN", &token),
|
||||||
|
"azure-devops" => request.basic_auth(&username, Some(&token)),
|
||||||
|
_ => return Err("Unsupported integration provider.".into()),
|
||||||
|
};
|
||||||
|
let response = request.send().map_err(|e| format!("Could not load branches: {e}"))?;
|
||||||
|
if !response.status().is_success() { return Err(response_error(response, &provider)); }
|
||||||
|
Ok(response)
|
||||||
|
};
|
||||||
|
let metadata: serde_json::Value = get(metadata_url.clone())?.json().map_err(|e| format!("Invalid repository response: {e}"))?;
|
||||||
|
let default_branch = value_string(&metadata, &[if provider == "azure-devops" { "defaultBranch" } else { "default_branch" }]).trim_start_matches("refs/heads/").to_string();
|
||||||
|
let mut branches = BTreeSet::new();
|
||||||
|
let mut continuation = String::new();
|
||||||
|
for page in 1..=1000 {
|
||||||
|
let mut url = metadata_url.clone();
|
||||||
|
{
|
||||||
|
let mut path = url.path_segments_mut().map_err(|_| "Invalid repository URL")?;
|
||||||
|
if provider.starts_with("gitlab") { path.push("repository"); }
|
||||||
|
path.push(if provider == "azure-devops" { "refs" } else { "branches" });
|
||||||
|
}
|
||||||
|
if provider == "azure-devops" {
|
||||||
|
url.query_pairs_mut().append_pair("filter", "heads/").append_pair("$top", "100");
|
||||||
|
if !continuation.is_empty() { url.query_pairs_mut().append_pair("continuationToken", &continuation); }
|
||||||
|
} else {
|
||||||
|
url.query_pairs_mut().append_pair("page", &page.to_string()).append_pair(if provider == "gitea" { "limit" } else { "per_page" }, "100");
|
||||||
|
}
|
||||||
|
let response = get(url)?;
|
||||||
|
let next = response.headers().get("x-ms-continuationtoken").and_then(|h| h.to_str().ok()).unwrap_or_default().to_string();
|
||||||
|
let data: serde_json::Value = response.json().map_err(|e| format!("Invalid branch response: {e}"))?;
|
||||||
|
let items = if provider == "azure-devops" { data.get("value") } else { Some(&data) }.and_then(serde_json::Value::as_array).ok_or("Invalid branch list.")?;
|
||||||
|
for item in items {
|
||||||
|
let name = value_string(item, &["name"]);
|
||||||
|
let name = if provider == "azure-devops" { name.strip_prefix("refs/heads/").unwrap_or(&name) } else { &name };
|
||||||
|
if !name.is_empty() { branches.insert(name.to_string()); }
|
||||||
|
}
|
||||||
|
if (provider == "azure-devops" && next.is_empty()) || (provider != "azure-devops" && items.len() < 100) {
|
||||||
|
return Ok(RepositoryBranches { branches: branches.into_iter().collect(), default_branch });
|
||||||
|
}
|
||||||
|
if provider == "azure-devops" && next == continuation { return Err("The server repeated its branch pagination token.".into()); }
|
||||||
|
continuation = next;
|
||||||
|
}
|
||||||
|
Err("The repository has too many branches to load completely.".into())
|
||||||
|
}).await.map_err(|e| format!("Could not load branches: {e}"))?
|
||||||
|
}
|
||||||
@@ -437,6 +437,8 @@ async fn main() {
|
|||||||
cred_load,
|
cred_load,
|
||||||
cred_save,
|
cred_save,
|
||||||
cred_delete,
|
cred_delete,
|
||||||
|
list_integration_repository_branches,
|
||||||
|
create_integration_review_request,
|
||||||
list_integration_repositories,
|
list_integration_repositories,
|
||||||
list_integration_review_requests,
|
list_integration_review_requests,
|
||||||
list_integration_issues,
|
list_integration_issues,
|
||||||
|
|||||||
@@ -5572,6 +5572,7 @@
|
|||||||
<IssueCenter language={appLanguage} integrations={gitIntegrationSettings} loadCredential={loadStoredCredential} onOpenSettings={openAppSettings} />
|
<IssueCenter language={appLanguage} integrations={gitIntegrationSettings} loadCredential={loadStoredCredential} onOpenSettings={openAppSettings} />
|
||||||
{:else if activeView === "review-center"}
|
{:else if activeView === "review-center"}
|
||||||
<ReviewCenter
|
<ReviewCenter
|
||||||
|
localRepositoryPath={activeRepoPath}
|
||||||
language={appLanguage}
|
language={appLanguage}
|
||||||
integrations={gitIntegrationSettings}
|
integrations={gitIntegrationSettings}
|
||||||
initialQuery={reviewCenterInitialQuery}
|
initialQuery={reviewCenterInitialQuery}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte";
|
||||||
|
import { createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
|
||||||
|
import { integrationCredentialKey } from "../integrations";
|
||||||
|
import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
|
||||||
|
|
||||||
|
let { source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: {
|
||||||
|
source: GitIntegrationSource; de: boolean; localRepositoryPath?: string;
|
||||||
|
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
|
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
|
||||||
|
} = $props();
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let repositories = $state<GitIntegrationRepository[]>([]);
|
||||||
|
let repositoryId = $state("");
|
||||||
|
let sourceBranch = $state("");
|
||||||
|
let targetBranch = $state("");
|
||||||
|
let branches = $state<string[]>([]);
|
||||||
|
let defaultBranch = $state("");
|
||||||
|
let branchesLoading = $state(false);
|
||||||
|
let branchError = $state("");
|
||||||
|
let branchGeneration = 0;
|
||||||
|
const branchOptions = $derived(branches.map(name => ({value:name,label:name,group:name === defaultBranch ? (de ? "Standardbranch" : "Default branch") : undefined})));
|
||||||
|
$effect(() => {
|
||||||
|
const repository = repositories.find(item => item.id === repositoryId);
|
||||||
|
void loadRepositoryBranches(repository);
|
||||||
|
});
|
||||||
|
async function loadRepositoryBranches(repository?: GitIntegrationRepository) {
|
||||||
|
const generation = ++branchGeneration;
|
||||||
|
branches = []; sourceBranch = ""; targetBranch = ""; defaultBranch = ""; branchError = "";
|
||||||
|
branchesLoading = !!repository;
|
||||||
|
if (!repository) return;
|
||||||
|
try {
|
||||||
|
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||||
|
const result = await listIntegrationRepositoryBranches(source.provider, source.baseUrl, credential.username, credential.password, repository);
|
||||||
|
if (generation !== branchGeneration) return;
|
||||||
|
branches = result.branches;
|
||||||
|
defaultBranch = result.defaultBranch;
|
||||||
|
targetBranch = branches.includes(defaultBranch) ? defaultBranch : "";
|
||||||
|
// Only suggest a local branch when its remote matches this exact repository.
|
||||||
|
if (localRepositoryPath) {
|
||||||
|
try {
|
||||||
|
const remotes = await listRemotes(localRepositoryPath);
|
||||||
|
const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
|
||||||
|
const matches = remotes.some(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url) || clean(url) === clean(remote.push_url)));
|
||||||
|
if (matches) {
|
||||||
|
const localBranches = await listBranches(localRepositoryPath);
|
||||||
|
if (generation !== branchGeneration) return;
|
||||||
|
const current = localBranches.find(branch => branch.current)?.name;
|
||||||
|
if (current && branches.includes(current) && current !== targetBranch) sourceBranch = current;
|
||||||
|
}
|
||||||
|
} catch { /* Remote branch selection remains available without local metadata. */ }
|
||||||
|
}
|
||||||
|
if (generation !== branchGeneration) return;
|
||||||
|
if (!sourceBranch && branches.length === 2 && targetBranch) sourceBranch = branches.find(name => name !== targetBranch) ?? "";
|
||||||
|
} catch (cause) {
|
||||||
|
if (generation === branchGeneration) branchError = String(cause);
|
||||||
|
} finally { if (generation === branchGeneration) branchesLoading = false; }
|
||||||
|
}
|
||||||
|
let title = $state("");
|
||||||
|
let description = $state("");
|
||||||
|
let loading = $state(true);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
const mr = $derived(source.provider.startsWith("gitlab"));
|
||||||
|
const heading = $derived(de ? (mr ? "Merge Request erstellen" : "Pull Request erstellen") : (mr ? "Create merge request" : "Create pull request"));
|
||||||
|
const normalizeBranch = (branch: string) => branch.trim().replace(/^refs\/heads\//, "");
|
||||||
|
const sameBranch = $derived(!!sourceBranch.trim() && normalizeBranch(sourceBranch) === normalizeBranch(targetBranch));
|
||||||
|
const valid = $derived(!branchesLoading && !branchError && branches.includes(sourceBranch) && branches.includes(targetBranch) && repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
|
||||||
|
|
||||||
|
onMount(() => { dialog.showModal(); void loadRepositories(); });
|
||||||
|
async function loadRepositories() {
|
||||||
|
loading = true; error = "";
|
||||||
|
try {
|
||||||
|
repositories = (await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId)).sort((a,b) => a.fullName.localeCompare(b.fullName));
|
||||||
|
if (repositories.length === 1) repositoryId = repositories[0].id;
|
||||||
|
} catch (cause) { error = String(cause); }
|
||||||
|
finally { loading = false; }
|
||||||
|
}
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || !valid) return;
|
||||||
|
const repository = repositories.find(item => item.id === repositoryId);
|
||||||
|
if (!repository) return;
|
||||||
|
busy = true; error = "";
|
||||||
|
try {
|
||||||
|
const credential = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||||
|
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
|
||||||
|
const request = await createIntegrationReviewRequest(source.provider, source.baseUrl, credential.username, credential.password, repository, normalizeBranch(sourceBranch), normalizeBranch(targetBranch), title.trim(), description);
|
||||||
|
onCreated(request);
|
||||||
|
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog bind:this={dialog} aria-labelledby="create-review-title" oncancel={(event) => { event.preventDefault(); if (!busy) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); } }}>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<header><div class="heading-icon"><GitPullRequest size={19} /></div><div><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
|
||||||
|
<div class="body">
|
||||||
|
{#if error}<div class="error" role="alert">{error}{#if !repositories.length && !loading}<button type="button" onclick={loadRepositories}>{de ? "Erneut laden" : "Retry"}</button>{/if}</div>{/if}
|
||||||
|
<div class="repository-field"><div class="field-heading"><span>Repository</span><small>{loading ? "…" : `${repositories.length} ${de ? "verfügbar" : "available"}`}</small></div>
|
||||||
|
<SelectMenu value={repositoryId} options={repositories.map(repository => ({value:repository.id,label:repository.fullName}))} disabled={loading || busy} ariaLabel="Repository" placeholder={loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")} searchable searchPlaceholder={de ? "Repositories durchsuchen …" : "Search repositories…"} emptyText={de ? "Keine passenden Repositories" : "No matching repositories"} onChange={value => repositoryId = value}/>
|
||||||
|
</div>
|
||||||
|
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if}
|
||||||
|
<div class="branches">
|
||||||
|
<div class="repository-field"><span>{de ? "Quellbranch" : "Source branch"}</span><SelectMenu value={sourceBranch} options={branchOptions.map(option => ({...option,disabled:option.value === targetBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Quellbranch" : "Source branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Quellbranch auswählen" : "Select source branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => sourceBranch = value}/></div>
|
||||||
|
<ArrowRight size={16}/>
|
||||||
|
<div class="repository-field"><span>{de ? "Zielbranch" : "Target branch"}</span><SelectMenu value={targetBranch} options={branchOptions.map(option => ({...option,disabled:option.value === sourceBranch}))} disabled={busy || branchesLoading || !repositoryId} ariaLabel={de ? "Zielbranch" : "Target branch"} placeholder={branchesLoading ? (de ? "Lädt …" : "Loading…") : (de ? "Zielbranch auswählen" : "Select target branch")} searchable searchPlaceholder={de ? "Branches durchsuchen …" : "Search branches…"} emptyText={de ? "Keine Branches gefunden" : "No branches found"} onChange={value => targetBranch = value}/></div>
|
||||||
|
</div>
|
||||||
|
{#if branchError}<div class="error" role="alert">{branchError}<button type="button" onclick={() => loadRepositoryBranches(repositories.find(item => item.id === repositoryId))}>{de ? "Erneut laden" : "Retry"}</button></div>{:else if repositoryId && !branchesLoading && !branches.length}<p>{de ? "Dieses Repository hat noch keine Branches." : "This repository has no branches yet."}</p>{/if}
|
||||||
|
{#if sameBranch}<p class="validation">{de ? "Quell- und Zielbranch müssen unterschiedlich sein." : "Source and target branches must be different."}</p>{/if}
|
||||||
|
<p class="hint">{de ? "Beide Branches müssen bereits in diesem Repository gepusht sein." : "Both branches must already be pushed to this repository."}</p>
|
||||||
|
<label>{de ? "Titel" : "Title"}<input bind:value={title} disabled={busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
|
||||||
|
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Beschreibe deine Änderungen … (Markdown unterstützt)" : "Describe your changes… (Markdown supported)"}></textarea></label>
|
||||||
|
</div>
|
||||||
|
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={busy || loading || !valid}>{#if busy}<LoaderCircle class="spin" size={15}/>{:else}<GitPullRequest size={15}/>{/if}{busy ? (de ? "Wird erstellt …" : "Creating…") : heading}</button></footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.repository-field{min-width:0;display:grid;gap:9px}.field-heading{display:flex;align-items:center;justify-content:space-between;font-weight:600}.field-heading small{font-size:10px;font-weight:400;color:var(--color-ink-faint)}
|
||||||
|
dialog{margin:auto;width:min(640px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-family:inherit;font-size:12px;overflow:auto}dialog::backdrop{background:#0007;backdrop-filter:blur(3px)}header{display:flex;align-items:center;gap:12px;padding:22px 26px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border-subtle)}h2{margin:0;font-size:16px;font-weight:650}p{margin:5px 0 0;color:var(--color-ink-muted)}.heading-icon{display:grid;place-items:center;width:38px;height:38px;border-radius:10px;background:color-mix(in srgb,var(--color-accent) 12%,transparent);color:var(--color-accent)}button,input,textarea{font:inherit}button{display:inline-flex;justify-content:center;align-items:center;gap:8px;border:1px solid var(--color-border-subtle);border-radius:7px;padding:9px 13px;background:var(--color-surface);color:var(--color-ink);cursor:pointer}button:disabled{opacity:.5;cursor:default}.close{margin-left:auto;border:0;padding:6px}.body{display:grid;gap:20px;padding:24px 26px}label{display:grid;gap:8px;font-weight:600;min-width:0}input,textarea{box-sizing:border-box;width:100%;padding:10px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--app-bg);color:var(--color-ink);font-weight:400}input:focus,textarea:focus{outline:2px solid var(--color-accent);outline-offset:1px}textarea{resize:vertical;min-height:110px;line-height:1.6}.branches{padding:16px;background:color-mix(in srgb,var(--color-accent) 3%,var(--app-bg));border:1px solid var(--color-border-subtle);display:grid;grid-template-columns:minmax(0,1fr) 16px minmax(0,1fr);gap:12px;align-items:end}.branches>:global(svg){margin-bottom:12px;color:var(--color-accent)}.hint{margin-top:-8px;font-size:11px;line-height:1.5}.error,.validation{color:var(--color-danger,#e76767);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger,#e76767) 9%,transparent);overflow-wrap:anywhere}.error button{margin-left:8px}footer{display:flex;justify-content:flex-end;gap:9px;padding:16px 26px;background:var(--app-dialog-chrome);border-top:1px solid var(--color-border-subtle)}.primary{background:var(--color-accent);border-color:var(--color-accent);color:white}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
|
||||||
|
</style>
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
|
localRepositoryPath?: string;
|
||||||
integrations: GitIntegrationSettings;
|
integrations: GitIntegrationSettings;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
initialSourceId?: string;
|
initialSourceId?: string;
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
onPushLocalResolution?: () => void | Promise<void>;
|
onPushLocalResolution?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
let { localRepositoryPath = "", language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
|
||||||
|
let createOpen = $state(false);
|
||||||
let requests = $state<IntegrationReviewRequest[]>([]);
|
let requests = $state<IntegrationReviewRequest[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let errors = $state<Array<{ source: string; message: string }>>([]);
|
let errors = $state<Array<{ source: string; message: string }>>([]);
|
||||||
@@ -384,9 +386,24 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if createOpen && activeSource}
|
||||||
|
<CreateReviewDialog {localRepositoryPath} source={activeSource} {de} {loadCredential} onClose={() => createOpen = false} onCreated={(request) => {
|
||||||
|
++loadGeneration;
|
||||||
|
loading = false;
|
||||||
|
requests = [request, ...requests.filter(item => item.id !== request.id)];
|
||||||
|
stateFilter = request.state;
|
||||||
|
query = "";
|
||||||
|
collapsedRepositories = new Set();
|
||||||
|
errors = [];
|
||||||
|
createOpen = false;
|
||||||
|
selectRequest(request, true);
|
||||||
|
}}/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
|
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
|
||||||
<header class="review-header">
|
<header class="review-header">
|
||||||
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
|
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
|
||||||
|
{#if activeSource}<button class="create-review" type="button" disabled={loading} onclick={() => { detailOpen = false; createOpen = true; }}><GitPullRequest size={14}/>{activeSource.provider.startsWith("gitlab") ? (de ? "MR erstellen" : "Create MR") : (de ? "PR erstellen" : "Create PR")}</button>{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if sources.length === 0}
|
{#if sources.length === 0}
|
||||||
@@ -514,6 +531,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.create-review{margin-left:auto;display:flex;align-items:center;gap:7px;padding:7px 11px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-accent);color:white;cursor:pointer}.create-review:disabled{opacity:.5;cursor:default}
|
||||||
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
|
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
|
||||||
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
|
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
|
||||||
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
|
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
|
||||||
@@ -556,14 +574,14 @@
|
|||||||
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}
|
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}
|
||||||
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
|
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
|
||||||
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
|
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
|
||||||
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.request-groups{min-width:900px}.detail-panel{width:58%;min-width:680px}}
|
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.table-head,.request-groups{min-width:980px}.detail-panel{width:58%;min-width:680px}}
|
||||||
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
|
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
|
||||||
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
|
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
|
||||||
|
|
||||||
/* Accepted Review Center concept — faithful final layout. */
|
/* Accepted Review Center concept — faithful final layout. */
|
||||||
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
|
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
|
||||||
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
|
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
|
||||||
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
|
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.table-head,.request-groups{min-width:980px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
|
||||||
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
|
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
|
||||||
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
|
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
|
||||||
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
|
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { Check, ChevronDown } from "@lucide/svelte";
|
import { Check, ChevronDown, Search } from "@lucide/svelte";
|
||||||
|
|
||||||
export interface SelectMenuOption {
|
export interface SelectMenuOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -17,6 +17,9 @@
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
showSelectedGroup?: boolean;
|
showSelectedGroup?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +31,16 @@
|
|||||||
ariaLabel = "",
|
ariaLabel = "",
|
||||||
class: className = "",
|
class: className = "",
|
||||||
showSelectedGroup = false,
|
showSelectedGroup = false,
|
||||||
|
searchable = false,
|
||||||
|
searchPlaceholder = "Search…",
|
||||||
|
emptyText = "No results",
|
||||||
onChange,
|
onChange,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let search = $state("");
|
||||||
|
let searchInput = $state<HTMLInputElement>();
|
||||||
|
const visibleOptions = $derived(options.filter(option => !searchable || `${option.label} ${option.group ?? ""}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
|
||||||
|
|
||||||
let root = $state<HTMLDivElement>();
|
let root = $state<HTMLDivElement>();
|
||||||
let trigger = $state<HTMLButtonElement>();
|
let trigger = $state<HTMLButtonElement>();
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -39,10 +49,10 @@
|
|||||||
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
|
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
let selectedOption = $derived(options.find((option) => option.value === value));
|
let selectedOption = $derived(options.find((option) => option.value === value));
|
||||||
let enabledIndices = $derived(options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
|
let enabledIndices = $derived(visibleOptions.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
|
||||||
|
|
||||||
function groupCount(group: string): number {
|
function groupCount(group: string): number {
|
||||||
return options.filter((option) => option.group === group).length;
|
return visibleOptions.filter((option) => option.group === group).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function positionMenu() {
|
function positionMenu() {
|
||||||
@@ -50,8 +60,8 @@
|
|||||||
const rect = trigger.getBoundingClientRect();
|
const rect = trigger.getBoundingClientRect();
|
||||||
const viewportGap = 8;
|
const viewportGap = 8;
|
||||||
const menuGap = 5;
|
const menuGap = 5;
|
||||||
const groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
|
const groupHeaderCount = new Set(visibleOptions.map((option) => option.group).filter(Boolean)).size;
|
||||||
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
|
const desiredHeight = Math.min(300, visibleOptions.length * 32 + (searchable ? 46 : 0) + groupHeaderCount * 36 + 12);
|
||||||
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
||||||
const spaceAbove = rect.top - viewportGap;
|
const spaceAbove = rect.top - viewportGap;
|
||||||
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
||||||
@@ -63,12 +73,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function show() {
|
async function show() {
|
||||||
if (disabled || enabledIndices.length === 0) return;
|
if (disabled || !options.some(option => !option.disabled)) return;
|
||||||
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
|
search = "";
|
||||||
|
const selectedIndex = visibleOptions.findIndex((option) => option.value === value && !option.disabled);
|
||||||
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
|
||||||
open = true;
|
open = true;
|
||||||
await tick();
|
await tick();
|
||||||
positionMenu();
|
positionMenu();
|
||||||
|
if (searchable) searchInput?.focus();
|
||||||
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function choose(index: number) {
|
function choose(index: number) {
|
||||||
const option = options[index];
|
const option = visibleOptions[index];
|
||||||
if (!option || option.disabled) return;
|
if (!option || option.disabled) return;
|
||||||
onChange(option.value);
|
onChange(option.value);
|
||||||
close();
|
close();
|
||||||
@@ -102,6 +114,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
const editingSearch = event.target === searchInput;
|
||||||
|
if (editingSearch && [" ", "Home", "End"].includes(event.key)) return;
|
||||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveActive(event.key === "ArrowDown" ? 1 : -1);
|
moveActive(event.key === "ArrowDown" ? 1 : -1);
|
||||||
@@ -148,9 +162,14 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
|
<div class="select-menu-popup" class:searchable style={menuStyle}>
|
||||||
{#each options as option, index (`${option.value}:${index}`)}
|
{#if searchable}
|
||||||
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
|
<div class="select-search"><Search size={14} aria-hidden="true"/><input bind:this={searchInput} bind:value={search} placeholder={searchPlaceholder} aria-label={searchPlaceholder} role="combobox" aria-expanded={open} aria-controls={menuId} aria-autocomplete="list" aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined} onkeydown={handleKeydown} oninput={async () => { await tick(); activeIndex = enabledIndices[0] ?? -1; positionMenu(); }}/></div>
|
||||||
|
{/if}
|
||||||
|
<div id={menuId} class="select-options" role="listbox" aria-label={ariaLabel || undefined}>
|
||||||
|
{#each visibleOptions as option, index (`${option.value}:${index}`)}
|
||||||
|
|
||||||
|
{#if option.group && (index === 0 || visibleOptions[index - 1]?.group !== option.group)}
|
||||||
<div class="select-menu-group" role="presentation">
|
<div class="select-menu-group" role="presentation">
|
||||||
<span>{option.group}</span>
|
<span>{option.group}</span>
|
||||||
<small>{groupCount(option.group)}</small>
|
<small>{groupCount(option.group)}</small>
|
||||||
@@ -172,6 +191,17 @@
|
|||||||
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
|
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if searchable && !visibleOptions.length}<div class="select-empty" role="status">{emptyText}</div>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.select-options{display:grid;gap:2px;min-height:0}
|
||||||
|
.select-menu-popup.searchable{display:flex;flex-direction:column;overflow:hidden}
|
||||||
|
.searchable .select-options{overflow:auto}
|
||||||
|
.select-search{display:flex;flex-shrink:0;align-items:center;gap:9px;margin:2px 3px 5px;padding:0 8px;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink-faint)}
|
||||||
|
.select-search input{width:100%;min-width:0;height:36px;padding:0;border:0;background:transparent;color:var(--color-ink);font:inherit;outline:none;box-shadow:none}
|
||||||
|
.select-empty{padding:15px 12px;color:var(--color-ink-muted);font-size:12px}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user