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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user