feat(select-menu): add optionMeta slot and show repo details in create dialog

Add an optional optionMeta snippet to SelectMenu to render richer,
right-aligned content per option (falls back to option.meta when not
provided). Update SelectMenu markup to render optionMeta if present.

Style adjustments for .select-menu-option-meta to align items, allow
inline SVGs, and preserve spacing/coloring.

Use the new slot in CreateReviewDialog:
- supply grouped repository options (owner + name) instead of raw names
- show a branch icon for each option and a lock icon + last-updated date
  in the option meta
- add helpers repositoryById and formatUpdatedAt to resolve repository
  data for the meta snippet

No breaking changes: legacy option.meta still works when optionMeta is
not supplied.
This commit is contained in:
2026-09-17 23:12:05 +02:00
parent af1c70d45d
commit b21fc93e10
3 changed files with 41 additions and 3 deletions
+4
View File
@@ -327,12 +327,16 @@
.select-menu-option:hover .select-menu-option-icon { color: var(--color-accent); } .select-menu-option:hover .select-menu-option-icon { color: var(--color-accent); }
.select-menu-option-icon svg { color: inherit; } .select-menu-option-icon svg { color: inherit; }
.select-menu-option-meta { .select-menu-option-meta {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 0 auto; flex: 0 0 auto;
color: var(--color-ink-faint); color: var(--color-ink-faint);
font-size: 9.5px; font-size: 9.5px;
font-weight: 650; font-weight: 650;
letter-spacing: 0; letter-spacing: 0;
} }
.select-menu-option-meta svg { color: inherit; }
.select-menu-option svg { color: var(--color-primary); } .select-menu-option svg { color: var(--color-primary); }
.select-menu-option:hover:not(:disabled), .select-menu-option.active:not(:disabled) { .select-menu-option:hover:not(:disabled), .select-menu-option.active:not(:disabled) {
border-color: var(--color-border-subtle); border-color: var(--color-border-subtle);
+29 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import SelectMenu from "./SelectMenu.svelte"; import SelectMenu from "./SelectMenu.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { GitPullRequest, X, LoaderCircle, ArrowRight, Sparkles } from "@lucide/svelte"; import { GitBranch, GitPullRequest, LockKeyhole, X, LoaderCircle, ArrowRight, Sparkles } from "@lucide/svelte";
import { credLoad, pullRequestAiGenerate, createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git"; import { credLoad, pullRequestAiGenerate, createIntegrationReviewRequest, listIntegrationRepositories, listIntegrationRepositoryBranches, listRemotes, listBranches } from "../git";
import { integrationCredentialKey } from "../integrations"; import { integrationCredentialKey } from "../integrations";
import type { AiSettings, GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types"; import type { AiSettings, GitIntegrationSource, GitIntegrationRepository, IntegrationReviewRequest, StoredCredential } from "../types";
@@ -15,6 +15,26 @@
let dialog: HTMLDialogElement; let dialog: HTMLDialogElement;
let titleInput: HTMLInputElement; let titleInput: HTMLInputElement;
let repositories = $state<GitIntegrationRepository[]>([]); 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 repositoryId = $state("");
let sourceBranch = $state(""); let sourceBranch = $state("");
let targetBranch = $state(""); let targetBranch = $state("");
@@ -129,7 +149,14 @@
<div class="body"> <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} {#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> <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}/> <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> </div>
{#if !loading && !error && !repositories.length}<p>{de ? "Keine Repositories für diese Integration gefunden." : "No repositories found for this integration."}</p>{/if} {#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="branches">
+8 -1
View File
@@ -25,6 +25,8 @@
emptyText?: string; emptyText?: string;
/** Optional icon in front of every option, e.g. a branch or repository mark. */ /** Optional icon in front of every option, e.g. a branch or repository mark. */
optionIcon?: Snippet<[SelectMenuOption]>; optionIcon?: Snippet<[SelectMenuOption]>;
/** Optional right-aligned content per option, richer than `option.meta`. */
optionMeta?: Snippet<[SelectMenuOption]>;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
@@ -40,6 +42,7 @@
searchPlaceholder = "Search…", searchPlaceholder = "Search…",
emptyText = "No results", emptyText = "No results",
optionIcon = undefined, optionIcon = undefined,
optionMeta = undefined,
onChange, onChange,
}: Props = $props(); }: Props = $props();
@@ -195,7 +198,11 @@
> >
{#if optionIcon}<span class="select-menu-option-icon">{@render optionIcon(option)}</span>{/if} {#if optionIcon}<span class="select-menu-option-icon">{@render optionIcon(option)}</span>{/if}
<span class="select-menu-option-label">{option.label}</span> <span class="select-menu-option-label">{option.label}</span>
{#if option.meta}<small class="select-menu-option-meta">{option.meta}</small>{/if} {#if optionMeta}
<small class="select-menu-option-meta">{@render optionMeta(option)}</small>
{:else if option.meta}
<small class="select-menu-option-meta">{option.meta}</small>
{/if}
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if} {#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
</button> </button>
{/each} {/each}