mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-06 08:53:08 +02:00
feat: enhance ComboboxField to support custom display values and update RepoPage to manage repository refresh state
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user