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
+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>