Introduce semantic theme variables (e.g. --color-success, --color-danger, --color-warning, --color-info) and a set of overlay/tint/shadow variables (--app-hover-tint, --app-raise-tint, --app-soft-tint, --app-overlay-shadow, --app-menu-shadow, --app-float-shadow, etc.) and use them throughout app.css in place of many hard-coded color, background and shadow values. This change: - replaces literal color tokens used for badges, pills, buttons, menus, toasts, borders and file icons with the new semantic variables - switches several box-shadow and overlay usages to the new shadow vars - harmonizes light-theme surface, border and scrollbar values to explicit variables for easier maintenance No structural or behavioral changes; this is purely a visual/theming refactor to make future theme adjustments and dark/light parity simpler.
183 lines
17 KiB
Svelte
183 lines
17 KiB
Svelte
<script lang="ts">
|
|
import SelectMenu from "./SelectMenu.svelte";
|
|
import { onMount } from "svelte";
|
|
import { GitBranch, GitPullRequest, LockKeyhole, X, LoaderCircle, ArrowRight, Sparkles } from "@lucide/svelte";
|
|
import { credLoad, pullRequestAiGenerate, createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
|
|
import { integrationCredentialKey } from "../integrations";
|
|
import type { AiSettings, GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
|
|
|
|
let { aiSettings, source, de, localRepositoryPath = "", loadCredential, onClose, onCreated }: {
|
|
aiSettings: AiSettings;
|
|
source: GitIntegrationSource; de: boolean; localRepositoryPath?: string;
|
|
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
|
onClose: () => void; onCreated: (request: IntegrationReviewRequest) => void;
|
|
} = $props();
|
|
let dialog: HTMLDialogElement;
|
|
let titleInput: HTMLInputElement;
|
|
let repositories = $state<GitIntegrationRepository[]>([]);
|
|
|
|
/** Repository options grouped by owner, like the clone dialog's list. */
|
|
const repositoryOptions = $derived(repositories.map((repository) => {
|
|
const separator = repository.fullName.lastIndexOf("/");
|
|
return {
|
|
value: repository.id,
|
|
label: separator > 0 ? repository.fullName.slice(separator + 1) : repository.fullName,
|
|
group: separator > 0 ? repository.fullName.slice(0, separator) : source.label,
|
|
};
|
|
}));
|
|
|
|
function repositoryById(id: string): GitIntegrationRepository | undefined {
|
|
return repositories.find((repository) => repository.id === id);
|
|
}
|
|
|
|
function formatUpdatedAt(value: string): string {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium" }).format(date);
|
|
}
|
|
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 generating = $state(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(); titleInput.focus({ preventScroll: true }); 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 generateDraft() {
|
|
if (busy || generating || branchesLoading || !sourceBranch || !targetBranch || sameBranch) return;
|
|
const repository = repositories.find(item => item.id === repositoryId);
|
|
if (!repository) return;
|
|
generating = true; error = "";
|
|
const context = `${repositoryId}:${sourceBranch}:${targetBranch}`;
|
|
try {
|
|
if (!localRepositoryPath) throw new Error(de ? "Öffne zuerst das passende lokale Repository und führe Fetch aus." : "Open the matching local repository and fetch it first.");
|
|
const remotes = await listRemotes(localRepositoryPath);
|
|
const clean = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
|
|
const remote = remotes.find(remote => [repository.cloneUrl, repository.sshUrl].filter(Boolean).some(url => clean(url) === clean(remote.fetch_url)));
|
|
if (!remote) throw new Error(de ? "Das offene lokale Repository passt nicht zum ausgewählten PR-Repository." : "The open local repository does not match the selected PR repository.");
|
|
const settings = { ...aiSettings };
|
|
const credential = await credLoad(`ai:${settings.provider}`);
|
|
const draft = await pullRequestAiGenerate(localRepositoryPath, remote.name, sourceBranch, targetBranch, {
|
|
provider: settings.provider,
|
|
model: settings.provider === "openai" ? settings.openaiModel : settings.provider === "anthropic" ? settings.anthropicModel : settings.customModel,
|
|
baseUrl: settings.provider === "custom" ? settings.customBaseUrl : undefined,
|
|
apiKey: credential?.password, language: de ? "de" : "en",
|
|
});
|
|
if (context !== `${repositoryId}:${sourceBranch}:${targetBranch}`) return;
|
|
title = draft.title; description = draft.description;
|
|
} catch (cause) { error = cause instanceof Error ? cause.message : String(cause); }
|
|
finally { generating = false; }
|
|
}
|
|
async function submit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (busy || generating || !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 && !generating) onClose(); }} onclick={(event) => { if (event.target === dialog && !busy && !generating) { 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 class="unified-dialog-header"><div class="heading-icon unified-dialog-icon"><GitPullRequest size={19} /></div><div class="unified-dialog-text"><h2 id="create-review-title">{heading}</h2><p>{source.label}</p></div><button data-dialog-close class="close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={generating || 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={repositoryOptions} 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"} showSelectedGroup onChange={value => repositoryId = value}>
|
|
{#snippet optionIcon()}<GitBranch size={14} aria-hidden="true" />{/snippet}
|
|
{#snippet optionMeta(option)}
|
|
{@const repository = repositoryById(option.value)}
|
|
{#if repository?.private}<LockKeyhole size={11} aria-label={de ? "Privat" : "Private"} />{/if}
|
|
{formatUpdatedAt(repository?.updatedAt ?? "")}
|
|
{/snippet}
|
|
</SelectMenu>
|
|
</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={generating || 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={generating || 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>
|
|
<div class="ai-draft-action"><button type="button" disabled={generating || busy || branchesLoading || !sourceBranch || !targetBranch || sameBranch} onclick={generateDraft}>{#if generating}<LoaderCircle class="spin" size={15}/>{:else}<Sparkles size={15}/>{/if}{generating ? (de ? "Wird generiert …" : "Generating…") : (de ? "Mit KI erstellen" : "Generate with AI")}</button><small>{de ? "Erstellt Titel und Beschreibung aus dem lokalen Stand der Remote-Branches. Vorher Fetch ausführen." : "Creates a title and description from locally fetched remote branches. Fetch first."}</small></div>
|
|
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={generating || busy} placeholder={de ? "Was ändert sich?" : "What is changing?"} required /></label>
|
|
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={generating || busy} rows="7" placeholder={de ? "Beschreibe deine Änderungen … (Markdown unterstützt)" : "Describe your changes… (Markdown supported)"}></textarea></label>
|
|
</div>
|
|
<footer><button type="button" disabled={generating || busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={generating || 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>
|
|
.ai-draft-action{display:flex;align-items:center;gap:12px}.ai-draft-action small{color:var(--color-ink-muted);line-height:1.5}.ai-draft-action button{flex-shrink:0}
|
|
.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:color-mix(in srgb, var(--app-dialog-backdrop) 92%, transparent);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);line-height:1.5}.error{padding:12px;border-radius:7px;background:color-mix(in srgb,var(--color-danger) 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-solid);border-color:var(--color-accent-solid);color:var(--color-on-accent)}.primary:enabled:hover{filter:brightness(1.08)}:global(.spin){animation:rotate 1s linear infinite}@keyframes rotate{to{transform:rotate(360deg)}}
|
|
</style>
|