feat(integrations): add review fetching and Linux keyring support

Add a cross-provider Review Center and improve credential handling.
Backend integrations fetch and normalize PRs from GitHub, GitLab,
Gitea, and Azure DevOps with improved timeouts and parsing. Credentials
now use a global lock and support secret-tool on Linux to avoid races.

- Normalize review data across providers (GitHub/GitLab/Gitea/Azure)
- Serialize credential access with OnceLock and use secret-tool on Linux
- Add frontend ReviewCenter component and related UI updates
This commit is contained in:
2026-09-06 22:53:44 +02:00
parent 2ec493e91f
commit a55a3ca300
11 changed files with 1666 additions and 15 deletions
+9 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Folder, GitBranch, Plus, X } from "@lucide/svelte";
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem {
path: string;
@@ -7,11 +7,12 @@
branch: string | null;
}
export let activeView: "management" | "repository" = "management";
export let activeView: "management" | "review-center" | "repository" = "management";
export let repoTabs: RepositoryTabItem[] = [];
export let isBusy: boolean = false;
export let language: "en" | "de" = "en";
export let onOpenManagement: () => void = () => {};
export let onOpenReviewCenter: () => void = () => {};
export let isActive: (path: string) => boolean = () => false;
export let onSelect: (path: string) => void | Promise<void> = () => {};
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
@@ -33,6 +34,12 @@
</button>
<div class="repo-tabs-scroll">
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
<GitPullRequest size={13} aria-hidden="true" />
<span>PR</span>
</button>
</div>
{#each repoTabs as repo (repo.path)}
<div
class="repo-tab-wrap"
@@ -237,7 +237,7 @@
<style>
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 70px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}
+454
View File
@@ -0,0 +1,454 @@
<script lang="ts">
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, Check, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown,
CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
} from "@lucide/svelte";
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
interface Props {
language: AppLanguage;
integrations: GitIntegrationSettings;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onOpenSettings: () => void;
}
let { language = "en", integrations, loadCredential, onOpenSettings = () => {} }: Props = $props();
let requests = $state<IntegrationReviewRequest[]>([]);
let loading = $state(false);
let errors = $state<Array<{ source: string; message: string }>>([]);
let query = $state("");
let stateFilter = $state<IntegrationReviewState>("open");
let selectedSourceId = $state("");
let selectedId = $state("");
let detailOpen = $state(false);
let actionMenuId = $state("");
let actionBusyId = $state("");
let actionNotice = $state("");
let detailLoadingId = $state("");
let commentDraft = $state("");
let commentPosting = $state(false);
let collapsedRepositories = $state(new Set<string>());
let loadGeneration = 0;
let loadedStates = $state(new Set<"open" | "merged" | "closed">());
const SOURCE_TIMEOUT_MS = 46_000;
const de = $derived(language === "de");
const sources = $derived(configuredIntegrationSources(integrations));
const activeSource = $derived(sources.find((source) => source.id === selectedSourceId) ?? null);
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
const filtered = $derived(requests.filter((request) => {
const haystack = `${request.title} ${request.repositoryName} ${request.author} ${request.number} ${request.sourceBranch} ${request.targetBranch}`.toLocaleLowerCase();
return request.state === stateFilter && (!normalizedQuery || haystack.includes(normalizedQuery));
}));
const groupedRequests = $derived.by(() => {
const groups = new Map<string, IntegrationReviewRequest[]>();
for (const request of filtered) {
const key = request.repositoryName || providerLabel(request.provider);
groups.set(key, [...(groups.get(key) ?? []), request]);
}
return [...groups.entries()].map(([name, items]) => ({ name, items }));
});
const selected = $derived(requests.find((request) => request.id === selectedId) ?? null);
const backendRestartRequired = $derived(errors.some((error) => /command.*not found|unknown command|not registered/i.test(error.message)));
onMount(() => {
const closeActionMenu = () => { actionMenuId = ""; };
window.addEventListener("click", closeActionMenu);
const source = sources[0];
if (source) {
selectedSourceId = source.id;
void loadRequests("open", source.id);
}
return () => window.removeEventListener("click", closeActionMenu);
});
function withTimeout<T>(promise: Promise<T>, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise<T> {
return new Promise<T>((resolve, reject) => {
const seconds = Math.round(timeoutMs / 1_000);
const timer = window.setTimeout(() => reject(new Error(de ? `${source} hat nach ${seconds} Sekunden nicht geantwortet.` : `${source} did not respond within ${seconds} seconds.`)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadRequests(state: "open" | "merged" | "closed" = stateFilter === "draft" ? "open" : stateFilter, sourceId = selectedSourceId) {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
const generation = ++loadGeneration;
loading = true;
errors = [];
requests = requests.filter((request) => state === "open" ? request.state !== "open" && request.state !== "draft" : request.state !== state);
selectedId = "";
detailOpen = false;
actionMenuId = "";
actionNotice = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
const loaded = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, state), source.label);
if (generation !== loadGeneration) return;
requests = [...requests, ...loaded].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt));
loadedStates = new Set([...loadedStates, state]);
const missingBranches = loaded.filter((request) => !request.sourceBranch || !request.targetBranch);
if (missingBranches.length) void enrichMissingBranches(missingBranches, source, credential, generation);
} catch (error) {
if (generation !== loadGeneration) return;
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
if (generation === loadGeneration) loading = false;
}
}
async function enrichMissingBranches(items: IntegrationReviewRequest[], source: (typeof sources)[number], credential: StoredCredential, generation: number) {
const queue = [...items];
const worker = async () => {
while (queue.length && generation === loadGeneration) {
const request = queue.shift();
if (!request) return;
try {
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
if (generation !== loadGeneration) return;
requests = requests.map((item) => item.id === details.id ? details : item);
} catch {
// Keep the request visible; selecting it retries the detail request and surfaces the error.
}
}
};
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
}
function selectSource(sourceId: string) {
if (sourceId === selectedSourceId) return;
selectedSourceId = sourceId;
stateFilter = "open";
requests = [];
errors = [];
selectedId = "";
detailOpen = false;
actionMenuId = "";
collapsedRepositories = new Set();
loadedStates = new Set();
void loadRequests("open", sourceId);
}
function selectState(state: IntegrationReviewState) {
stateFilter = state;
selectedId = "";
detailOpen = false;
const apiState = state === "draft" ? "open" : state;
if (!loadedStates.has(apiState)) void loadRequests(apiState);
}
function count(state: IntegrationReviewState): number {
return requests.filter((request) => request.state === state).length;
}
function countLabel(state: IntegrationReviewState): string {
return loadedStates.has(state === "draft" ? "open" : state) ? String(count(state)) : "";
}
function stateLabel(state: IntegrationReviewState): string {
if (state === "draft") return "Draft";
if (state === "merged") return de ? "Zusammengeführt" : "Merged";
if (state === "closed") return de ? "Geschlossen" : "Closed";
return "Open";
}
function formatDate(value: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function formatRelativeDate(value: string): string {
if (!value) return "";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "";
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) return de ? "gerade eben" : "just now";
if (minutes < 60) return de ? `vor ${minutes} Min.` : `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return de ? `vor ${hours} Std.` : `${hours}h ago`;
const days = Math.round(hours / 24);
return de ? `vor ${days} T.` : `${days}d ago`;
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2)).toLocaleUpperCase();
}
function actionLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "azure-devops" ? "Open in Azure" : `Open in ${providerLabel(provider)}`;
}
function requestTypeLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "gitlab" || provider === "gitlab-self-hosted" ? "Merge Request" : "Pull Request";
}
function toggleRepository(name: string) {
const next = new Set(collapsedRepositories);
if (next.has(name)) next.delete(name);
else next.add(name);
collapsedRepositories = next;
}
function collapseAll() {
collapsedRepositories = new Set(groupedRequests.map((group) => group.name));
}
function expandAll() {
collapsedRepositories = new Set();
}
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
if (selectedId !== request.id) commentDraft = "";
selectedId = request.id;
if (showDetail) detailOpen = true;
if (request.changedFiles === null && detailLoadingId !== request.id) void loadDetails(request);
}
function openDetail(request: IntegrationReviewRequest) {
selectRequest(request, true);
}
async function loadDetails(request: IntegrationReviewRequest) {
const source = activeSource;
if (!source) return;
detailLoadingId = request.id;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) return;
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label);
requests = requests.map((item) => item.id === details.id ? details : item);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { detailLoadingId = ""; }
}
function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; }
async function runPrimaryAction(request: IntegrationReviewRequest) {
if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge");
else if (request.state === "closed") await performReviewAction(request, "reopen");
else await openRequest(request);
}
function primaryActionLabel(request: IntegrationReviewRequest): string {
if (hasConflicts(request)) return de ? "Konflikt" : "Conflict";
if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
return actionLabel(request.provider);
}
async function openRequest(request: IntegrationReviewRequest | null = selected) {
if (!request?.webUrl) return;
try { await openInBrowser(request.webUrl); }
catch (error) { errors = [{ source: providerLabel(request.provider), message: error instanceof Error ? error.message : String(error) }]; }
}
function reviewActionLabel(action: IntegrationReviewAction): string {
if (action === "merge") return de ? "Zusammenführen" : "Merge";
if (action === "approve") return de ? "Freigeben" : "Approve";
if (action === "close") return de ? "Request schließen" : "Close request";
return de ? "Request wieder öffnen" : "Reopen request";
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
if (action !== "approve") {
const prompt = action === "merge"
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
: action === "close"
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
if (!window.confirm(prompt)) return;
}
actionMenuId = "";
actionNotice = "";
actionBusyId = request.id;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
requests = [];
loadedStates = new Set();
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
actionBusyId = "";
}
}
async function submitComment() {
const request = selected;
const source = activeSource;
const body = commentDraft.trim();
if (!request || !source || !body || commentPosting) return;
commentPosting = true;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(addIntegrationReviewComment(source.provider, source.baseUrl, credential.username, credential.password, request, body), source.label);
commentDraft = "";
await loadDetails(request);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { commentPosting = false; }
}
</script>
<section class="review-center" aria-label="Review Center">
<header class="review-header">
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1><span>{activeSource?.label ?? ""}</span></div>
</header>
{#if sources.length === 0}
<div class="empty-state"><div class="empty-icon"><GitPullRequest size={23} /></div><h2>{de ? "Keine Integration eingerichtet" : "No integration configured"}</h2><p>{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}</p><button class="primary" type="button" onclick={onOpenSettings}><Settings2 size={14} />{de ? "Integrationen öffnen" : "Open integrations"}</button></div>
{:else}
<nav class="state-tabs" aria-label={de ? "Request-Status" : "Request status"}>
{#each ["open", "draft", "merged", "closed"] as state}
<button class:active={stateFilter === state} type="button" onclick={() => selectState(state as IntegrationReviewState)}>{stateLabel(state as IntegrationReviewState)}<span>{countLabel(state as IntegrationReviewState)}</span></button>
{/each}
</nav>
<div class="review-toolbar">
<div class="group-actions">
<button type="button" onclick={collapseAll}><ChevronsDownUp size={14} />{de ? "Alle einklappen" : "Collapse all"}</button>
<button type="button" onclick={expandAll}><ChevronsUpDown size={14} />{de ? "Alle ausklappen" : "Expand all"}</button>
<button class="icon-button" title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"} type="button" onclick={() => loadRequests()} disabled={loading}><RefreshCw class={loading ? "spin" : ""} size={14} /></button>
</div>
<label class="review-search"><Search size={14} /><input bind:value={query} placeholder={de ? "Requests durchsuchen …" : "Search requests …"} /></label>
<select value={selectedSourceId} onchange={(event) => selectSource(event.currentTarget.value)} aria-label={de ? "Integration auswählen" : "Select integration"}>
{#each sources as source}<option value={source.id}>{source.label}</option>{/each}
</select>
</div>
{#if actionNotice}<div class="action-notice"><Check size={13} />{actionNotice}</div>{/if}
{#if errors.length > 0}
<details class="source-warning" open>
<summary><AlertTriangle size={14} /><span>{backendRestartRequired ? (de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend.") : (de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded.")}</span></summary>
<ul>{#each errors as error}<li><strong>{error.source}:</strong> {error.message}</li>{/each}</ul>
</details>
{/if}
<div class:inspector-open={detailOpen && selected} class="review-layout">
<main class="table-pane">
<div class="table-head">
<span>Status</span><span>{de ? "Request" : "Request"}</span><span>{de ? "Autor" : "Author"}</span><span>Collaborators</span><span>Repo/Branch</span><span>{de ? "Aktion" : "Action"}</span>
</div>
{#if loading && requests.length === 0}
<div class="loading-state"><LoaderCircle class="spin" size={17} />{de ? "Requests werden geladen …" : "Loading requests …"}</div>
{:else if groupedRequests.length === 0}
<div class="list-empty"><Inbox size={20} /><strong>{de ? "Keine passenden Requests" : "No matching requests"}</strong><span>{de ? "Passe Status oder Suche an." : "Adjust the status or search."}</span></div>
{:else}
<div class="request-groups">
{#each groupedRequests as group (group.name)}
<section class="request-group">
<button class="group-header" type="button" onclick={() => toggleRepository(group.name)}>
{#if collapsedRepositories.has(group.name)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
<GitBranch size={13} /><strong>{group.name}</strong><span>{group.items.length}</span>
</button>
{#if !collapsedRepositories.has(group.name)}
{#each group.items as request (request.id)}
<div class:selected={selectedId === request.id} class="request-row" role="button" tabindex="0" onclick={() => selectRequest(request)} onkeydown={(event) => { if (event.key === "Enter") selectRequest(request); }}>
<span class="request-status" class:open={request.state === "open"} class:draft={request.state === "draft"} class:merged={request.state === "merged"} class:closed={request.state === "closed"}>
{#if request.state === "merged"}<GitMerge size={13} />{:else if request.state === "closed"}<XCircle size={13} />{:else if request.state === "draft"}<CircleDotDashed size={13} />{:else}<GitPullRequest size={13} />{/if}
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
</span>
<span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? ""}</b><i>/</i><em>{request.deletions ?? ""}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {de ? "Dateien" : "files"}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span>
<span class="request-author"><i>{initials(request.author)}</i><span>{request.author || (de ? "Unbekannt" : "Unknown")}</span></span>
<span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span></span>{/if}</span>
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b></b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
<span class="row-actions">
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={hasConflicts(request) || !!actionBusyId} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
{#if actionMenuId === request.id}
<span class="action-menu" role="menu" tabindex="-1">
<button type="button" role="menuitem" onclick={() => openDetail(request)}><PanelRightOpen size={13} />{de ? "Request prüfen" : "Review request"}</button>
{#if request.state === "open" || request.state === "draft"}
<button type="button" role="menuitem" disabled={actionBusyId === request.id || hasConflicts(request)} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{hasConflicts(request) ? (de ? "Merge-Konflikt" : "Merge conflict") : reviewActionLabel("merge")}</button>
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><Check size={13} />{reviewActionLabel("approve")}</button>
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
{:else if request.state === "closed"}
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "reopen")}><RotateCcw size={13} />{reviewActionLabel("reopen")}</button>
{/if}
</span>
{/if}
</span>
<button class="panel-button" class:active={detailOpen && selectedId === request.id} title={de ? "Detailpanel öffnen" : "Open detail panel"} aria-label={de ? "Detailpanel öffnen" : "Open detail panel"} type="button" onclick={(event) => { event.stopPropagation(); openDetail(request); }}><PanelRightOpen size={14} /></button>
</span>
</div>
{/each}
{/if}
</section>
{/each}
</div>
{/if}
</main>
{#if detailOpen && selected}
<aside class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
<header class="detail-header">
<div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div>
<div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
</header>
<div class="detail-content">
<main class="detail-main">
<section class="detail-title"><span>#{selected.number}</span><h2>{selected.title}</h2><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span>{de ? "möchte" : "wants to merge"}</span></div><div class="branch-detail"><GitBranch size={15} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span></span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</div></section>
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
<section class="comments-section"><header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header><div class:conflict={hasConflicts(selected)} class="merge-summary">{#if detailLoadingId === selected.id}<LoaderCircle class="spin" size={14} /><span>{de ? "Merge-Status wird geladen …" : "Loading merge status …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<Check size={14} /><span>{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}</span>{/if}</div>{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><span class="avatar">{initials(comment.author)}</span><div><header><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time></header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><span class="avatar">{initials(selected.author)}</span><div><textarea bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><button type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></div></form></section>
</main>
<aside class="detail-sidebar">
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId || hasConflicts(selected)} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{hasConflicts(selected) ? (de ? "Merge-Konflikt" : "Merge conflict") : reviewActionLabel("merge")}</button><button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span></div></section>
</aside>
</div>
</aside>
{/if}
</div>
{/if}
</section>
<style>
.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,.review-center select{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-heading span{margin-left:3px;padding-left:12px;border-left:1px solid var(--color-border);color:var(--color-ink-faint);font-size:10.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 select{width:190px;height:30px;padding:0 28px 0 9px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}
.source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,#d3a64d 28%,var(--color-border));color:#d3a64d;background:color-mix(in srgb,#d3a64d 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,#63c783 28%,var(--color-border));color:#63c783;background:color-mix(in srgb,#63c783 6%,var(--app-bg));font-size:10.5px}
.review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:#63c783}.change-stats em{color:#e0737b;font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:#fff;background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.request-author>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:#63c783;border-color:#4fa565;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-button.conflict{color:#e0737b;border-color:#a74b55}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:#fff;background:color-mix(in srgb,var(--color-accent) 24%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:0 8px 22px #0008;background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:#e0737b}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)}
.detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.branch-detail{display:flex;align-items:center;gap:7px;padding:13px 0;border-bottom:1px solid var(--color-border-subtle)}.branch-detail>:global(svg){color:var(--color-accent)}.branch-detail code{min-width:0;overflow:hidden;padding:2px 5px;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,transparent);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-detail span{color:var(--color-ink-faint)}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:#63c783;border-color:#63c783}.detail-action.danger-action{color:#e0737b;border-color:color-mix(in srgb,#e0737b 75%,var(--color-border))}.open{color:#63c783}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:#e0737b}
.loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-primary);color:#fff;background:var(--color-primary)}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:760px){.review-toolbar{flex-wrap:wrap}.group-actions{order:2}.review-search{order:1;flex-basis:calc(100% - 200px)}.review-toolbar select{order:1}.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 15%;z-index:5;box-shadow:-12px 0 30px #0008}.state-tabs{gap:8px}.group-actions button{font-size:0}.group-actions button>:global(svg){margin:0}.table-head,.request-row{grid-template-columns:72px minmax(220px,1fr) 90px 160px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.branch-route{display:none}.request-groups{min-width:580px}}
.merge-summary{display:flex;min-height:45px;align-items:center;gap:7px;border-bottom:1px solid var(--color-border-subtle);color:#63c783}.merge-summary.conflict{color:#e0737b}.merge-summary>span,.merge-summary>strong{font-size:10.5px}
@media(max-width:1100px){.table-head,.request-row{grid-template-columns:78px minmax(270px,1fr) minmax(190px,1fr) 190px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.collaborators{display:none}.request-groups{min-width:780px}}
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer>div{display:grid;flex:1;justify-items:end;gap:7px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:-12px 0 30px #0008}}
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.branch-detail,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title>span{color:var(--color-ink-faint);font-size:10px}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-title .branch-detail{padding:8px 0 13px;border:0}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
.comment-list{align-content:start;grid-auto-rows:max-content}
.detail-panel{inset:0 0 0 auto;width:50%;min-width:760px;max-width:100%;height:100%;border-left:1px solid var(--color-border);box-shadow:-14px 0 30px #0007}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}
.provider-action.merge-action .action-toggle{border-color:#4fa565;color:#70d58d;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-action.merge-action .action-toggle:hover,.provider-action.merge-action .action-toggle.active{color:#fff;background:#397849}.provider-action.conflict-action .action-toggle{border-color:#a74b55;color:#e0737b;background:color-mix(in srgb,#a74b55 12%,var(--color-surface))}.panel-button{border-color:color-mix(in srgb,var(--color-accent) 45%,var(--color-border));color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:#fff;background:color-mix(in srgb,var(--color-accent) 25%,var(--color-surface))}
@media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}
</style>
+22
View File
@@ -17,6 +17,8 @@ import type {
GitIgnoreKind,
GitIntegrationProvider,
GitIntegrationRepository,
IntegrationReviewRequest,
IntegrationReviewAction,
GitLfsStatus,
GitRepositoryFile,
GitRemote,
@@ -59,6 +61,26 @@ export function listIntegrationRepositories(provider: GitIntegrationProvider, ba
return invoke<GitIntegrationRepository[]>("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null });
}
export function listIntegrationReviewRequests(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, state: "open" | "merged" | "closed"): Promise<IntegrationReviewRequest[]> {
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
}
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<void> {
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action });
}
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
return invoke<IntegrationReviewRequest>("get_integration_review_details", { provider, baseUrl, username, token, request });
}
export function addIntegrationReviewComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, body: string): Promise<void> {
return invoke<void>("add_integration_review_comment", { provider, baseUrl, username, token, request, body });
}
export function openInBrowser(url: string): Promise<void> {
return invoke<void>("open_in_browser", { url });
}
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
}
+4
View File
@@ -11,6 +11,10 @@ export function beginFrontendShutdown() {
frontendShuttingDown = true;
}
export function resumeFrontend() {
frontendShuttingDown = false;
}
// Telemetry must never contain repository contents or identifying local data.
function sanitize(message: string): string {
return message
+33
View File
@@ -65,6 +65,39 @@ export interface GitIntegrationRepository {
private: boolean;
}
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
export interface IntegrationReviewComment {
id: string;
author: string;
body: string;
createdAt: string;
}
export interface IntegrationReviewRequest {
id: string;
number: number;
provider: GitIntegrationProvider;
repositoryId: string;
repositoryName: string;
title: string;
description: string;
author: string;
state: IntegrationReviewState;
sourceBranch: string;
targetBranch: string;
webUrl: string;
createdAt: string;
updatedAt: string;
collaborators: string[];
additions: number | null;
deletions: number | null;
changedFiles: number | null;
mergeStatus: "" | "mergeable" | "conflicts" | "checking" | "blocked";
comments: IntegrationReviewComment[];
}
export interface CustomThemeColors {
background: string;
surface: string;