diff --git a/src/components/ComboboxField.tsx b/src/components/ComboboxField.tsx index d7008c3..4e269a1 100644 --- a/src/components/ComboboxField.tsx +++ b/src/components/ComboboxField.tsx @@ -18,6 +18,8 @@ type ComboboxFieldProps = { clearSelectionLabel?: string /** Match by letters/digits only (case-insensitive); ignores spaces and punctuation in query and options. */ filterNormalize?: boolean + /** Optional display formatter; keeps option values stable while changing UI labels. */ + displayValue?: (value: string) => string } function normalizeForFilter(s: string): string { @@ -29,15 +31,16 @@ function buildRows( filter: string, clearSelectionLabel: string | undefined, value: string, - filterNormalize: boolean + filterNormalize: boolean, + displayValue: (value: string) => string ): Row[] { let filtered: readonly string[] if (filterNormalize) { const nq = normalizeForFilter(filter) - filtered = !nq ? [...options] : options.filter(o => normalizeForFilter(o).includes(nq)) + filtered = !nq ? [...options] : options.filter(o => normalizeForFilter(displayValue(o)).includes(nq)) } else { const q = filter.trim().toLowerCase() - filtered = !q ? [...options] : options.filter(o => o.toLowerCase().includes(q)) + filtered = !q ? [...options] : options.filter(o => displayValue(o).toLowerCase().includes(q)) } const r: Row[] = [] if (clearSelectionLabel && value) r.push({ kind: 'clear' }) @@ -66,7 +69,9 @@ export function ComboboxField({ layout = 'stacked', clearSelectionLabel, filterNormalize = false, + displayValue, }: ComboboxFieldProps) { + const renderValue = displayValue ?? (v => v) const rid = useId().replace(/:/g, '') const triggerId = id ?? `cb-${rid}` const labelId = `${triggerId}-label` @@ -78,8 +83,8 @@ export function ComboboxField({ const syncHighlightAfterOpenRef = useRef(false) const rows = useMemo( - () => buildRows(options, filter, clearSelectionLabel, value, filterNormalize), - [options, filter, clearSelectionLabel, value, filterNormalize] + () => buildRows(options, filter, clearSelectionLabel, value, filterNormalize, renderValue), + [options, filter, clearSelectionLabel, value, filterNormalize, renderValue] ) const handleOpenChange = (next: boolean) => { @@ -181,7 +186,9 @@ export function ComboboxField({ aria-labelledby={labelId} className={cn(triggerClass, 'inline-flex items-center justify-between gap-1 text-left font-normal')} > - {value || placeholder} + + {value ? renderValue(value) : placeholder} + @@ -227,7 +234,7 @@ export function ComboboxField({ {row.kind === 'clear' ? ( {clearSelectionLabel} ) : ( - row.value + renderValue(row.value) )} )) diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index ef33e04..b62398b 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -116,7 +116,8 @@ export default function RepoPage() { const [resolvedSha, setResolvedSha] = useState(null) const [refError, setRefError] = useState(null) const [pendingTagRefreshValidation, setPendingTagRefreshValidation] = useState(false) - const [isRefreshingTags, setIsRefreshingTags] = useState(false) + const [isRefreshingRepo, setIsRefreshingRepo] = useState(false) + const [refreshResolveTick, setRefreshResolveTick] = useState(0) const [readmeRefreshTick, setReadmeRefreshTick] = useState(0) useEffect(() => { if (!owner || !repo || !effectiveRef) return @@ -138,7 +139,7 @@ export default function RepoPage() { return () => { cancelled = true } - }, [owner, repo, effectiveRef, resolveRef, navigate, ownerParam, repoParam]) + }, [owner, repo, effectiveRef, resolveRef, navigate, ownerParam, repoParam, refreshResolveTick]) useEffect(() => { if (!pendingTagRefreshValidation || !sourceRef || tagData === undefined) return @@ -288,6 +289,48 @@ export default function RepoPage() { } return extras.length > 0 ? sortTagNames([...filtered, ...extras]) : filtered }, [tagOptions, meshforgeConfig, sourceRef, tagData?.row]) + const [refShaByName, setRefShaByName] = useState>({}) + useEffect(() => { + setRefShaByName({}) + }, [owner, repo]) + + const tagNameSet = useMemo(() => new Set((tagData?.row?.tags ?? []).map(t => t.name.toLowerCase())), [tagData?.row?.tags]) + const branchLikeTagOptions = useMemo( + () => filteredTagOptions.filter(name => !tagNameSet.has(name.toLowerCase())), + [filteredTagOptions, tagNameSet] + ) + useEffect(() => { + if (!owner || !repo || branchLikeTagOptions.length === 0) return + let cancelled = false + const missing = branchLikeTagOptions.filter(name => !refShaByName[name]) + if (missing.length === 0) return + void Promise.all( + missing.map(async name => { + const sha = await resolveRef({ owner, repo, ref: name }) + return { name, sha } + }) + ) + .then(entries => { + if (cancelled) return + setRefShaByName(prev => { + const next = { ...prev } + for (const { name, sha } of entries) next[name] = sha + return next + }) + }) + .catch(() => { + // Ignore failed branch ref lookups; keep dropdown usable without SHA badges. + }) + return () => { + cancelled = true + } + }, [owner, repo, branchLikeTagOptions, refShaByName, resolveRef]) + + const displayRefOption = (name: string) => { + if (tagNameSet.has(name.toLowerCase())) return name + const sha = refShaByName[name] + return sha ? `${name} (${sha.slice(0, 7)})` : name + } const filteredEnvNames = useMemo( () => filterEnvNames(envNames, meshforgeConfig, envCapabilities ?? {}, tagDraft), [envNames, meshforgeConfig, envCapabilities, tagDraft] @@ -700,13 +743,14 @@ export default function RepoPage() {
{ setTagDraft(v) if (v === "") { @@ -828,22 +872,24 @@ export default function RepoPage() { variant="outline" size="sm" className="w-full border-slate-600 text-slate-300 hover:border-slate-500 hover:bg-slate-800 hover:text-white" - title="Refresh tags from GitHub" - disabled={isRefreshingTags} + title="Refresh repo metadata from GitHub" + disabled={isRefreshingRepo} onClick={() => { - if (isRefreshingTags) return - setIsRefreshingTags(true) + if (isRefreshingRepo) return + // Force re-resolve current ref so moving branches pick up latest SHA. + setRefreshResolveTick(t => t + 1) + setReadmeRefreshTick(t => t + 1) + setIsRefreshingRepo(true) void refreshTags({ owner, repo }) .then(() => { setPendingTagRefreshValidation(true) - setReadmeRefreshTick(t => t + 1) }) .catch(e => toast.error(String(e))) - .finally(() => setIsRefreshingTags(false)) + .finally(() => setIsRefreshingRepo(false)) }} > - - {isRefreshingTags ? "Refreshing…" : "Refresh tags"} + + {isRefreshingRepo ? "Refreshing…" : "Refresh repo"}