feat: enhance ComboboxField to support custom display values and update RepoPage to manage repository refresh state

This commit is contained in:
Ben Allfree
2026-04-22 20:41:57 -07:00
parent 5f1592bce2
commit ecddeea0b1
2 changed files with 73 additions and 20 deletions
+14 -7
View File
@@ -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')}
>
<span className={cn('min-w-0 truncate', !value && 'text-slate-600')}>{value || placeholder}</span>
<span className={cn('min-w-0 truncate', !value && 'text-slate-600')}>
{value ? renderValue(value) : placeholder}
</span>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
</button>
</PopoverTrigger>
@@ -227,7 +234,7 @@ export function ComboboxField({
{row.kind === 'clear' ? (
<span className="text-slate-400">{clearSelectionLabel}</span>
) : (
row.value
renderValue(row.value)
)}
</li>
))
+59 -13
View File
@@ -116,7 +116,8 @@ export default function RepoPage() {
const [resolvedSha, setResolvedSha] = useState<string | null>(null)
const [refError, setRefError] = useState<string | null>(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<Record<string, string>>({})
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() {
<div className="min-w-0 space-y-5 [grid-area:repo-main]">
<div className="flex flex-nowrap items-end gap-2 overflow-x-auto border-b border-slate-800 pb-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ComboboxField
label="Tag"
label="Ref"
layout="inline"
id="mesh-forge-tag"
options={filteredTagOptions}
value={tagDraft}
placeholder="--tag--"
clearSelectionLabel="Clear tag"
displayValue={displayRefOption}
placeholder="--ref--"
clearSelectionLabel="Clear ref"
onChange={v => {
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))
}}
>
<RefreshCw className={`size-3.5 ${isRefreshingTags ? "animate-spin" : ""}`} />
{isRefreshingTags ? "Refreshing…" : "Refresh tags"}
<RefreshCw className={`size-3.5 ${isRefreshingRepo ? "animate-spin" : ""}`} />
{isRefreshingRepo ? "Refreshing…" : "Refresh repo"}
</Button>
</div>
</aside>