feat(create-review): load repository branches and suggest local branch

Add a backend command to enumerate a repository's branches and default
branch for configured integrations, and expose it to the UI. The create
review dialog now fetches branches, shows loading and error states, and
uses searchable SelectMenu controls for repositories and branches. If a
local repository path is available the dialog will try to match remotes
and preselect a local branch that exists on the remote to streamline
review creation.

- Add integration branch listing command and wire it into the dialog
- Replace plain selects with searchable SelectMenu and improved UX
- Attempt to detect and suggest a matching local source branch when possible
This commit is contained in:
2026-09-08 21:41:29 +02:00
parent f7f85d387d
commit ee35169feb
8 changed files with 174 additions and 22 deletions
+59 -7
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { onMount } from "svelte";
import { GitPullRequest, X, LoaderCircle, ArrowRight } from "@lucide/svelte";
import { createIntegrationReviewRequest, listIntegrationRepositories } from "../git";
import { createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
let { source, de, loadCredential, onClose, onCreated }: {
source: GitIntegrationSource; de: boolean;
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();
@@ -15,6 +16,49 @@
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);
@@ -24,7 +68,7 @@
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(repositoryId && title.trim() && sourceBranch.trim() && targetBranch.trim() && !sameBranch);
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() {
@@ -56,9 +100,16 @@
<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}
<label>Repository<select bind:value={repositoryId} disabled={loading || busy} required><option value="" disabled>{loading ? (de ? "Repositories werden geladen …" : "Loading repositories…") : (de ? "Repository auswählen" : "Select repository")}</option>{#each repositories as repository}<option value={repository.id}>{repository.fullName}</option>{/each}</select></label>
<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"><label>{de ? "Quellbranch" : "Source branch"}<input bind:value={sourceBranch} disabled={busy} placeholder="feature/my-change" required autocomplete="off" /></label><ArrowRight size={16}/><label>{de ? "Zielbranch" : "Target branch"}<input bind:value={targetBranch} disabled={busy} placeholder="main" required autocomplete="off" /></label></div>
<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>
@@ -69,5 +120,6 @@
</dialog>
<style>
dialog{margin:auto;width:min(600px,calc(100vw - 40px));max-height:calc(100vh - 48px);padding:0;border:1px solid var(--color-border-subtle);border-radius:14px;background:var(--color-surface);color:var(--color-ink);box-shadow:0 24px 90px #0005;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 24px;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,select,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:17px;padding:24px}label{display:grid;gap:8px;font-weight:600;min-width:0}input,select,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}select{height:38px;padding:7px 11px;line-height:22px}input:focus,select:focus,textarea:focus{outline:2px solid var(--color-accent);outline-offset:1px}textarea{resize:vertical;min-height:110px;line-height:1.6}.branches{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 24px;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)}}
.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>
+3 -2
View File
@@ -17,6 +17,7 @@
interface Props {
language: AppLanguage;
localRepositoryPath?: string;
integrations: GitIntegrationSettings;
initialQuery?: string;
initialSourceId?: string;
@@ -32,7 +33,7 @@
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 loading = $state(false);
@@ -401,7 +402,7 @@
</script>
{#if createOpen && activeSource}
<CreateReviewDialog source={activeSource} {de} {loadCredential} onClose={() => createOpen = false} onCreated={(request) => {
<CreateReviewDialog {localRepositoryPath} source={activeSource} {de} {loadCredential} onClose={() => createOpen = false} onCreated={(request) => {
++loadGeneration;
loading = false;
requests = [request, ...requests.filter(item => item.id !== request.id)];
+41 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { tick } from "svelte";
import { Check, ChevronDown } from "@lucide/svelte";
import { Check, ChevronDown, Search } from "@lucide/svelte";
export interface SelectMenuOption {
value: string;
@@ -17,6 +17,9 @@
ariaLabel?: string;
class?: string;
showSelectedGroup?: boolean;
searchable?: boolean;
searchPlaceholder?: string;
emptyText?: string;
onChange: (value: string) => void;
}
@@ -28,9 +31,16 @@
ariaLabel = "",
class: className = "",
showSelectedGroup = false,
searchable = false,
searchPlaceholder = "Search…",
emptyText = "No results",
onChange,
}: 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 trigger = $state<HTMLButtonElement>();
let open = $state(false);
@@ -39,10 +49,10 @@
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
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 {
return options.filter((option) => option.group === group).length;
return visibleOptions.filter((option) => option.group === group).length;
}
function positionMenu() {
@@ -50,8 +60,8 @@
const rect = trigger.getBoundingClientRect();
const viewportGap = 8;
const menuGap = 5;
const groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
const groupHeaderCount = new Set(visibleOptions.map((option) => option.group).filter(Boolean)).size;
const desiredHeight = Math.min(300, visibleOptions.length * 32 + (searchable ? 46 : 0) + groupHeaderCount * 36 + 12);
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
const spaceAbove = rect.top - viewportGap;
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
@@ -63,12 +73,14 @@
}
async function show() {
if (disabled || enabledIndices.length === 0) return;
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
if (disabled || !options.some(option => !option.disabled)) return;
search = "";
const selectedIndex = visibleOptions.findIndex((option) => option.value === value && !option.disabled);
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
open = true;
await tick();
positionMenu();
if (searchable) searchInput?.focus();
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
@@ -77,7 +89,7 @@
}
function choose(index: number) {
const option = options[index];
const option = visibleOptions[index];
if (!option || option.disabled) return;
onChange(option.value);
close();
@@ -102,6 +114,8 @@
return;
}
if (!open) return;
const editingSearch = event.target === searchInput;
if (editingSearch && [" ", "Home", "End"].includes(event.key)) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
@@ -148,9 +162,14 @@
</button>
{#if open}
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
{#each options as option, index (`${option.value}:${index}`)}
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
<div class="select-menu-popup" class:searchable style={menuStyle}>
{#if searchable}
<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">
<span>{option.group}</span>
<small>{groupCount(option.group)}</small>
@@ -172,6 +191,17 @@
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
</button>
{/each}
</div>
{#if searchable && !visibleOptions.length}<div class="select-empty" role="status">{emptyText}</div>{/if}
</div>
{/if}
</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>
+4
View File
@@ -679,3 +679,7 @@ export function resolveConflictSide(
export function createIntegrationReviewRequest(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository, sourceBranch: string, targetBranch: string, title: string, description: string): Promise<IntegrationReviewRequest> {
return invoke("create_integration_review_request", { provider, baseUrl, username, token, repository, sourceBranch, targetBranch, title, description });
}
export function listIntegrationRepositoryBranches(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: GitIntegrationRepository): Promise<{branches: string[]; defaultBranch: string}> {
return invoke("list_integration_repository_branches", {provider, baseUrl, username, token, repository});
}