159 lines
5.4 KiB
Svelte
159 lines
5.4 KiB
Svelte
<script lang="ts">
|
|
import { ChevronDown, Check, GitBranch, Loader2 } from 'lucide-svelte';
|
|
import * as Command from '$lib/components/ui/command';
|
|
import * as Popover from '$lib/components/ui/popover';
|
|
import { buttonVariants } from '$lib/components/ui/button';
|
|
import { cn } from '$lib/utils';
|
|
|
|
/**
|
|
* Free-text, searchable branch picker.
|
|
*
|
|
* Reuses the repository's existing Popover + Command primitives (the same
|
|
* interaction/style pattern as TimezoneSelector.svelte) so the user can
|
|
* both pick a *discovered* branch and type an *arbitrary* branch name.
|
|
*
|
|
* Behaviour contract (maintainer review for the new-repository branch
|
|
* field):
|
|
* - fetched branches appear in the list and are searchable/selectable;
|
|
* - typing a branch NOT in the list offers "Use \"<query>\"", and clicking
|
|
* it (or pressing Enter) selects that exact value;
|
|
* - if branch enumeration fails or returns nothing, the user can still
|
|
* type and select any branch manually (the "Use \"<query>\"" row always
|
|
* appears when the trimmed query is non-blank);
|
|
* - an existing value that a later enumeration did NOT return is retained
|
|
* (never silently reset), and the trigger keeps showing it;
|
|
* - while enumeration is in flight a loader is shown but the field stays
|
|
* usable;
|
|
* - keyboard selection (ArrowUp/Down + Enter) works for both fetched rows
|
|
* and the free-text row.
|
|
*/
|
|
interface Props {
|
|
/** Current branch value (e.g. "main"). */
|
|
value: string;
|
|
/** Discovered branch names (from /api/git/branches). */
|
|
branches: string[];
|
|
/** True while branch enumeration is in flight. */
|
|
loading: boolean;
|
|
/** Called with the selected value (a fetched branch or the typed name). */
|
|
onchange?: (value: string) => void;
|
|
/** Called when the user clears the field (empty value). */
|
|
onclear?: () => void;
|
|
/** When set (with onclear), shows a top row that resets to this label,
|
|
* e.g. "Repository default (main)". */
|
|
clearLabel?: string;
|
|
id?: string;
|
|
class?: string;
|
|
placeholder?: string;
|
|
}
|
|
|
|
let {
|
|
value = $bindable('main'),
|
|
branches = [],
|
|
loading = false,
|
|
onchange,
|
|
onclear,
|
|
clearLabel,
|
|
id,
|
|
class: className,
|
|
placeholder = 'main'
|
|
}: Props = $props();
|
|
|
|
let open = $state(false);
|
|
let searchQuery = $state('');
|
|
|
|
function selectBranch(branch: string) {
|
|
value = branch;
|
|
open = false;
|
|
searchQuery = '';
|
|
onchange?.(branch);
|
|
}
|
|
|
|
function clearBranch() {
|
|
value = '';
|
|
open = false;
|
|
searchQuery = '';
|
|
onclear?.();
|
|
}
|
|
|
|
// Filtered fetched branches, ordered so the current value (if not in the
|
|
// fetched list) still surfaces near the top for easy re-selection.
|
|
const filteredBranches = $derived(
|
|
searchQuery
|
|
? branches.filter((b) => b.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
: branches
|
|
);
|
|
|
|
// Offer the typed name as a selectable row when it is non-blank and is not
|
|
// already one of the fetched branches.
|
|
const freeTextBranch = $derived(
|
|
searchQuery.trim() && !branches.includes(searchQuery.trim()) ? searchQuery.trim() : ''
|
|
);
|
|
|
|
// Display: show the current value; fall back to the placeholder when empty
|
|
// (e.g. no branch chosen -> "main").
|
|
const displayValue = value || placeholder;
|
|
</script>
|
|
|
|
<Popover.Root bind:open>
|
|
<Popover.Trigger>
|
|
<span
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
class={cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between cursor-pointer select-none', className)}
|
|
{id}
|
|
>
|
|
<span class="flex items-center gap-2 truncate">
|
|
<GitBranch class="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
{#if loading}
|
|
<Loader2 class="h-4 w-4 animate-spin" />
|
|
<span class="text-muted-foreground">Loading branches…</span>
|
|
{:else}
|
|
<span class="truncate">{displayValue}</span>
|
|
{/if}
|
|
</span>
|
|
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</span>
|
|
</Popover.Trigger>
|
|
<Popover.Content class="w-[350px] p-0 z-[200]" align="start">
|
|
<Command.Root shouldFilter={false}>
|
|
<Command.Input bind:value={searchQuery} placeholder="Type a branch name…" />
|
|
<Command.List class="max-h-[300px]">
|
|
{#if loading}
|
|
<Command.Empty>Loading branches…</Command.Empty>
|
|
{:else}
|
|
{#if clearLabel && onclear && !searchQuery.trim()}
|
|
<Command.Item value="__repo_default__" onSelect={clearBranch}>
|
|
<Check class={cn('mr-2 h-4 w-4', value ? 'opacity-0' : 'opacity-100')} />
|
|
<GitBranch class="mr-2 h-4 w-4 text-muted-foreground" />
|
|
<span class="truncate">{clearLabel}</span>
|
|
</Command.Item>
|
|
{/if}
|
|
{#if filteredBranches.length > 0}
|
|
<Command.Group heading="Branches">
|
|
{#each filteredBranches as branch}
|
|
<Command.Item value={branch} onSelect={() => selectBranch(branch)}>
|
|
<Check class={cn('mr-2 h-4 w-4', value === branch ? 'opacity-100' : 'opacity-0')} />
|
|
<span class="truncate">{branch}</span>
|
|
</Command.Item>
|
|
{/each}
|
|
</Command.Group>
|
|
{/if}
|
|
{#if freeTextBranch}
|
|
<Command.Group heading="Custom">
|
|
<Command.Item value={freeTextBranch} onSelect={() => selectBranch(freeTextBranch)}>
|
|
<GitBranch class="mr-2 h-4 w-4" />
|
|
<span class="truncate">Use "{freeTextBranch}"</span>
|
|
</Command.Item>
|
|
</Command.Group>
|
|
{/if}
|
|
{#if filteredBranches.length === 0 && !freeTextBranch && !loading}
|
|
<Command.Empty>
|
|
Type a branch name (e.g. "main") or pick from the list.
|
|
</Command.Empty>
|
|
{/if}
|
|
{/if}
|
|
</Command.List>
|
|
</Command.Root>
|
|
</Popover.Content>
|
|
</Popover.Root>
|