This commit is contained in:
Ben Allfree
2026-04-09 23:27:06 -07:00
parent e6bcf4c01e
commit 54be07e6cc
17 changed files with 825 additions and 238 deletions
-76
View File
@@ -1,76 +0,0 @@
import { useId } from 'react'
type AutocompleteFieldProps = {
label: string
options: readonly string[]
value: string
onChange: (value: string) => void
onBlur?: () => void
disabled?: boolean
id?: string
placeholder?: string
/** Single-row toolbar: label left, input grows. */
layout?: 'stacked' | 'inline'
}
/** Native `<datalist>`-backed field: typeahead from the browser + keyboard friendly. */
export function AutocompleteField({
label,
options,
value,
onChange,
onBlur,
disabled,
id,
placeholder = 'Type or pick from list…',
layout = 'stacked',
}: AutocompleteFieldProps) {
const rid = useId().replace(/:/g, '')
const inputId = id ?? `ac-${rid}`
const listId = `${inputId}-options`
const inputClass =
layout === 'inline'
? 'h-9 min-w-[7rem] flex-1 bg-slate-900 border border-slate-700 rounded-md px-2.5 text-sm text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-600/50 disabled:cursor-not-allowed disabled:opacity-50'
: 'w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-600/50 disabled:cursor-not-allowed disabled:opacity-50'
return (
<label
className={
layout === 'inline'
? 'flex min-w-0 max-w-[min(100%,18rem)] flex-1 items-center gap-2 sm:max-w-[20rem]'
: 'block w-full space-y-1.5'
}
>
<span
className={
layout === 'inline'
? 'shrink-0 text-xs font-medium text-slate-500 w-14 sm:w-16'
: 'text-sm font-medium text-slate-400'
}
>
{label}
</span>
<input
id={inputId}
type="text"
className={inputClass}
list={options.length ? listId : undefined}
value={value}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
spellCheck={false}
onChange={e => onChange(e.target.value)}
onBlur={onBlur}
/>
{options.length ? (
<datalist id={listId}>
{options.map(o => (
<option key={o} value={o} />
))}
</datalist>
) : null}
</label>
)
}
+210
View File
@@ -0,0 +1,210 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
type Row = { kind: 'clear' } | { kind: 'opt'; value: string }
type ComboboxFieldProps = {
label: string
options: readonly string[]
value: string
onChange: (value: string) => void
disabled?: boolean
id?: string
placeholder?: string
layout?: 'stacked' | 'inline'
/** When set and `value` is non-empty, first row clears selection (`onChange('')`). */
clearSelectionLabel?: string
}
function buildRows(
options: readonly string[],
filter: string,
clearSelectionLabel: string | undefined,
value: string
): Row[] {
const q = filter.trim().toLowerCase()
const filtered = !q ? [...options] : options.filter(o => o.toLowerCase().includes(q))
const r: Row[] = []
if (clearSelectionLabel && value) r.push({ kind: 'clear' })
for (const o of filtered) r.push({ kind: 'opt', value: o })
return r
}
/**
* Searchable list combobox (Radix Popover). Unlike `<datalist>`, the full option list stays
* available while open so arrow keys and scrolling work after a value is selected.
*/
export function ComboboxField({
label,
options,
value,
onChange,
disabled,
id,
placeholder = 'Choose…',
layout = 'stacked',
clearSelectionLabel,
}: ComboboxFieldProps) {
const rid = useId().replace(/:/g, '')
const triggerId = id ?? `cb-${rid}`
const labelId = `${triggerId}-label`
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [highlighted, setHighlighted] = useState(0)
const filterInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const rows = useMemo(
() => buildRows(options, filter, clearSelectionLabel, value),
[options, filter, clearSelectionLabel, value]
)
const handleOpenChange = (next: boolean) => {
if (next) {
setFilter('')
const initial = buildRows(options, '', clearSelectionLabel, value)
const idx = initial.findIndex(row => row.kind === 'opt' && row.value === value)
setHighlighted(idx >= 0 ? idx : 0)
} else {
setFilter('')
}
setOpen(next)
}
useEffect(() => {
if (!open) return
setHighlighted(h => Math.min(h, Math.max(0, rows.length - 1)))
}, [rows.length, open])
useEffect(() => {
if (!open) return
requestAnimationFrame(() => filterInputRef.current?.focus())
}, [open])
useEffect(() => {
if (!open || !listRef.current) return
const el = listRef.current.querySelector(`[data-row-index="${highlighted}"]`)
el?.scrollIntoView({ block: 'nearest' })
}, [highlighted, open, rows.length])
const selectRow = (row: Row) => {
if (row.kind === 'clear') {
onChange('')
} else {
onChange(row.value)
}
setFilter('')
setOpen(false)
}
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlighted(h => Math.min(h + 1, Math.max(0, rows.length - 1)))
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlighted(h => Math.max(h - 1, 0))
return
}
if (e.key === 'Enter' && rows.length > 0) {
e.preventDefault()
const row = rows[highlighted]
if (row) selectRow(row)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setOpen(false)
}
}
const triggerClass =
layout === 'inline'
? 'h-9 min-w-[7rem] flex-1 rounded-md border border-slate-700 bg-slate-900 px-2.5 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-cyan-600/50'
: 'h-10 w-full rounded-md border border-slate-700 bg-slate-900 px-2.5 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-cyan-600/50'
const labelWrapClass =
layout === 'inline'
? 'flex min-w-0 max-w-[min(100%,18rem)] flex-1 items-center gap-2 sm:max-w-[20rem]'
: 'block w-full space-y-1.5'
const labelTextClass =
layout === 'inline' ? 'w-14 shrink-0 text-xs font-medium text-slate-500 sm:w-16' : 'text-sm font-medium text-slate-400'
return (
<div className={labelWrapClass}>
<span id={labelId} className={labelTextClass}>
{label}
</span>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<button
id={triggerId}
type="button"
disabled={disabled}
aria-expanded={open}
aria-haspopup="listbox"
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>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-[12rem] p-0"
onOpenAutoFocus={e => e.preventDefault()}
align="start"
>
<div className="border-b border-slate-800 p-1.5">
<input
ref={filterInputRef}
type="text"
value={filter}
onChange={e => {
setFilter(e.target.value)
setHighlighted(0)
}}
onKeyDown={onFilterKeyDown}
placeholder="Filter…"
className="h-8 w-full rounded border border-slate-800 bg-slate-950 px-2 text-sm text-white placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-cyan-600/50"
autoComplete="off"
spellCheck={false}
/>
</div>
<ul ref={listRef} role="listbox" aria-label={label} className="max-h-60 overflow-y-auto py-1" tabIndex={-1}>
{rows.length === 0 ? (
<li className="px-2 py-2 text-sm text-slate-500">No matches</li>
) : (
rows.map((row, i) => (
<li
key={row.kind === 'clear' ? '__clear__' : row.value}
role="option"
aria-selected={i === highlighted}
data-row-index={i}
className={cn(
'cursor-pointer px-2 py-1.5 text-sm',
i === highlighted ? 'bg-slate-800 text-white' : 'text-slate-200 hover:bg-slate-800/60'
)}
onMouseEnter={() => setHighlighted(i)}
onMouseDown={e => e.preventDefault()}
onClick={() => selectRow(row)}
>
{row.kind === 'clear' ? (
<span className="text-slate-400">{clearSelectionLabel}</span>
) : (
row.value
)}
</li>
))
)}
</ul>
</PopoverContent>
</Popover>
</div>
)
}
+6 -1
View File
@@ -1,6 +1,11 @@
@import "tailwindcss";
/* Disable auto scan of the whole repo root — `vendor/` alone is ~100k+ files and stalls the Oxide scanner. */
@import "tailwindcss" source(none);
@plugin "@tailwindcss/typography";
@source "./**/*.{html,js,mjs,mts,ts,tsx,jsx}";
@source "../components/**/*.{html,js,mjs,mts,ts,tsx,jsx}";
@source "../index.html";
@theme {
--font-sans: system-ui, -apple-system, sans-serif;
+44 -3
View File
@@ -3,9 +3,8 @@ export function formatBuildErrorSummary(summary: string | undefined): string {
if (!summary) return ''
if (summary.includes('Unexpected inputs provided')) {
return (
'GitHub Actions rejected this workflow dispatch: the workflow file on the Mesh Forge GitHub repo ' +
'does not declare the same `workflow_dispatch` inputs as this app (update `custom_build.yml` / ' +
'`custom_build_test.yml` on `MeshEnvy/mesh-forge` and try again).'
'Mesh Forges own GitHub workflow didnt accept the build request (inputs out of sync). ' +
'Thats on the Mesh Forge service, not your firmware. Retry wont help until that workflow YAML is updated.'
)
}
if (summary.length > 600) {
@@ -13,3 +12,45 @@ export function formatBuildErrorSummary(summary: string | undefined): string {
}
return summary
}
/** Short headline + body for people browsing firmware, not operating Mesh Forge. */
export function buildFailurePresentation(summary: string | undefined): {
headline: string
body: string
} {
if (!summary?.trim()) {
return { headline: 'Build did not finish.', body: '' }
}
if (summary.includes('Unexpected inputs provided')) {
return {
headline: 'Couldnt start a cloud build',
body:
'Something on the Mesh Forge side didnt line up with GitHub. Its not a signal that your repo is broken. ' +
'Retry usually wont fix this until the service is updated.',
}
}
if (/GitHub API failed:\s*5\d\d/.test(summary) || /GitHub API failed:\s*429/.test(summary)) {
return {
headline: 'GitHub was temporarily unavailable',
body: 'Starting the build failed because GitHub returned an error or rate limit. Use Retry build — it often works on a second try.',
}
}
if (/GitHub API failed:\s*4\d\d/.test(summary) && !summary.includes('422')) {
return {
headline: 'Couldnt start the build',
body: formatBuildErrorSummary(summary),
}
}
if (summary.includes('GitHub API failed: 422')) {
return {
headline: 'Couldnt start the build',
body: formatBuildErrorSummary(summary),
}
}
return {
headline: 'Build failed in CI',
body:
'Often a compile error, missing PlatformIO dependency, or bad env config in the repo. Fix the project if you can, then use Retry build. ' +
'Transient CI issues also happen — retry is safe.',
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Mesh Forge tree URLs: `/owner/repo/tree/<branch segments>/target/<env>`
* Branch ref may contain `/`; `target` is a reserved final segment pair.
*/
const TARGET_TAIL = /\/target\/([^/]+)$/
export function parseTreeSplat(treePath: string | undefined): {
branchRef: string | null
targetEnv: string | null
} {
if (!treePath?.trim()) return { branchRef: null, targetEnv: null }
const segments = treePath.split('/').filter(Boolean)
if (segments.length === 0) return { branchRef: null, targetEnv: null }
const joined = segments.map(s => decodeURIComponent(s)).join('/')
const m = TARGET_TAIL.exec(joined)
if (!m) return { branchRef: joined, targetEnv: null }
const branchRef = joined.slice(0, m.index).replace(/\/$/, '') || null
const targetEnv = m[1] ? decodeURIComponent(m[1]) : null
return { branchRef, targetEnv }
}
/** Path after `/tree/` (no leading slash). Empty string means no branch — use short repo URL. */
export function buildTreeSplatPath(branchRef: string | null, targetEnv: string | null): string {
const b = branchRef?.trim()
if (!b) return ''
const enc = b.split('/').map(s => encodeURIComponent(s)).join('/')
const t = targetEnv?.trim()
if (!t) return enc
return `${enc}/target/${encodeURIComponent(t)}`
}
+3 -2
View File
@@ -106,8 +106,9 @@ export default function HomePage() {
</div>
<p className="text-xs text-slate-500">
URLs mirror GitHub: <code className="text-slate-400">/owner/repo/tree/ref</code>. Short form{' '}
<code className="text-slate-400">owner/repo</code> uses the default branch.
Repo URLs: <code className="text-slate-400">/owner/repo</code> (pick branch),{' '}
<code className="text-slate-400">/owner/repo/tree/ref</code>, or add{' '}
<code className="text-slate-400">/target/envName</code> to deep-link a PlatformIO environment.
</p>
</div>
</div>
+156 -105
View File
@@ -1,4 +1,4 @@
import { AutocompleteField } from '../components/AutocompleteField'
import { ComboboxField } from '../components/ComboboxField'
import EspFlasher from '../components/EspFlasher'
import { Button } from '@/components/ui/button'
import { api } from '@/convex/_generated/api'
@@ -7,13 +7,17 @@ import { Github, Link2, RefreshCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { homepageHref, homepageLabel } from '../lib/githubHomepage'
import ReactMarkdown from 'react-markdown'
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams } from 'react-router-dom'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { toast } from 'sonner'
import { normalizeBuildKey } from '../lib/buildKey'
import { formatBuildErrorSummary } from '../lib/formatBuildErrorSummary'
import { buildFailurePresentation } from '../lib/formatBuildErrorSummary'
const MESH_FORGE_ACTIONS_REPO = 'MeshEnvy/mesh-forge'
const meshForgeWorkflowUrl = `https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/workflows/custom_build.yml`
import { buildTreeSplatPath, parseTreeSplat } from '../lib/repoTreeUrl'
export default function RepoPage() {
const navigate = useNavigate()
@@ -23,7 +27,8 @@ export default function RepoPage() {
const treePath = params['*']
const owner = useMemo(() => decodeURIComponent(ownerParam), [ownerParam])
const repo = useMemo(() => decodeURIComponent(repoParam), [repoParam])
const onShortUrl = !treePath
const { branchRef, targetEnv: targetFromUrl } = useMemo(() => parseTreeSplat(treePath), [treePath])
const hasBranch = Boolean(branchRef)
const branchData = useQuery(
api.repoBranches.get,
@@ -34,17 +39,10 @@ export default function RepoPage() {
const fetchReadme = useAction(api.repoBranches.fetchReadme)
const ensureScan = useMutation(api.repoScans.ensureScan)
const ensureBuild = useMutation(api.repoBuilds.ensureBuild)
const retryBuild = useMutation(api.repoBuilds.retryBuild)
const getSignedUrl = useAction(api.repoBuildDownloads.getSignedDownloadUrl)
const effectiveRef = useMemo(() => {
if (treePath) {
return treePath
.split('/')
.filter(Boolean)
.map(p => decodeURIComponent(p))
.join('/')
}
return branchData?.row?.defaultBranch ?? null
}, [treePath, branchData?.row?.defaultBranch])
/** Git branch/ref from URL only (null = `--branch--` / short `/owner/repo`). */
const effectiveRef = branchRef
useEffect(() => {
if (!owner || !repo || branchData === undefined) return
@@ -101,27 +99,35 @@ export default function RepoPage() {
}, [owner, repo, effectiveRef, fetchReadme])
const envNames = scan?.scanStatus === 'complete' ? scan.envNames ?? [] : []
const [selectedEnv, setSelectedEnv] = useState<string>('')
useEffect(() => {
if (!envNames.length) return
if (!selectedEnv || !envNames.includes(selectedEnv)) {
setSelectedEnv(envNames[0])
}
}, [envNames, selectedEnv])
const resolvedTargetEnv =
hasBranch && targetFromUrl && envNames.length > 0 && envNames.includes(targetFromUrl)
? targetFromUrl
: ''
const [branchDraft, setBranchDraft] = useState('')
useEffect(() => {
if (!effectiveRef) return
setBranchDraft(effectiveRef)
}, [effectiveRef])
setBranchDraft(branchRef ?? '')
}, [branchRef])
const [envDraft, setEnvDraft] = useState('')
useEffect(() => {
if (selectedEnv) setEnvDraft(selectedEnv)
}, [selectedEnv])
if (!hasBranch) {
setEnvDraft('')
return
}
setEnvDraft(targetFromUrl ?? '')
}, [hasBranch, targetFromUrl])
useEffect(() => {
if (!branchRef || !targetFromUrl) return
if (scan?.scanStatus !== 'complete') return
if (envNames.length === 0 || !envNames.includes(targetFromUrl)) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(branchRef, null)}`, { replace: true })
}
}, [branchRef, targetFromUrl, envNames, scan?.scanStatus, navigate, ownerParam, repoParam])
const buildKey =
resolvedSha && selectedEnv ? normalizeBuildKey(resolvedSha, selectedEnv) : null
resolvedSha && resolvedTargetEnv ? normalizeBuildKey(resolvedSha, resolvedTargetEnv) : null
const build = useQuery(api.repoBuilds.getByBuildKey, buildKey ? { buildKey } : 'skip')
const [flashUrl, setFlashUrl] = useState<string | null>(null)
@@ -153,13 +159,19 @@ export default function RepoPage() {
}, [build?._id, build?.status, getSignedUrl])
const queueFlashArtifacts = () => {
if (!effectiveRef || !resolvedSha || !selectedEnv) return
if (!effectiveRef || !resolvedSha || !resolvedTargetEnv) return
if (build?.status === 'failed' && build._id) {
void retryBuild({ buildId: build._id })
.then(() => toast.message('Re-queued build'))
.catch(e => toast.error(String(e)))
return
}
void ensureBuild({
owner,
repo,
ref: effectiveRef,
resolvedSourceSha: resolvedSha,
targetEnv: selectedEnv,
targetEnv: resolvedTargetEnv,
}).catch(e => toast.error(String(e)))
}
@@ -173,103 +185,108 @@ export default function RepoPage() {
}
}
if (onShortUrl) {
if (branchData === undefined) {
return (
<div className="min-h-[40vh] flex items-center justify-center text-slate-400">
Resolving default branch
</div>
)
}
if (!branchData.row) {
return (
<div className="max-w-xl mx-auto px-6 py-16 text-slate-300">
<p className="mb-4">Could not load branch list. The repository may be private or missing.</p>
<Button asChild variant="outline">
<Link to="/">Home</Link>
</Button>
</div>
)
}
const enc = branchData.row.defaultBranch.split('/').map(encodeURIComponent).join('/')
return <Navigate to={`/${ownerParam}/${repoParam}/tree/${enc}`} replace />
}
if (!effectiveRef) {
if (branchData === undefined) {
return (
<div className="min-h-[40vh] flex items-center justify-center text-slate-400">Loading</div>
<div className="min-h-[40vh] flex items-center justify-center text-slate-400">Loading repository</div>
)
}
if (!branchData.row) {
return (
<div className="max-w-xl mx-auto px-6 py-16 text-slate-300">
<p className="mb-4">Could not load branch list. The repository may be private or missing.</p>
<Button asChild variant="outline">
<Link to="/">Home</Link>
</Button>
</div>
)
}
const ghTree = `https://github.com/${owner}/${repo}/tree/${effectiveRef.split('/').map(encodeURIComponent).join('/')}`
const ghRepoRoot = `https://github.com/${owner}/${repo}`
const ghTree = effectiveRef
? `https://github.com/${owner}/${repo}/tree/${effectiveRef.split('/').map(encodeURIComponent).join('/')}`
: ghRepoRoot
const branchNames = branchData?.row?.branches.map(b => b.name) ?? []
const branchNames = branchData.row.branches.map(b => b.name)
let branchOptions =
effectiveRef && !branchNames.includes(effectiveRef)
? [effectiveRef, ...branchNames]
: [...branchNames]
if (branchOptions.length === 0 && effectiveRef) branchOptions = [effectiveRef]
branchRef && !branchNames.includes(branchRef) ? [branchRef, ...branchNames] : [...branchNames]
if (branchOptions.length === 0 && branchRef) branchOptions = [branchRef]
const ghAboutDescription = branchData?.row?.description?.trim() ?? ''
const ghAboutHomepage = branchData?.row?.homepage?.trim() ?? ''
const ghAboutDescription = branchData.row.description?.trim() ?? ''
const ghAboutHomepage = branchData.row.homepage?.trim() ?? ''
const scanReady = Boolean(resolvedSha && scan?.scanStatus === 'complete' && envNames.length > 0)
const scanReady = Boolean(hasBranch && resolvedSha && scan?.scanStatus === 'complete' && envNames.length > 0)
const buildInProgress = Boolean(build && (build.status === 'queued' || build.status === 'running'))
const flashPrimaryDisabled =
!hasBranch ||
!resolvedSha ||
Boolean(refError) ||
!selectedEnv ||
!envNames.includes(selectedEnv) ||
!scanReady
!resolvedTargetEnv ||
!envNames.includes(resolvedTargetEnv) ||
!scanReady ||
buildInProgress
const ghRepoRoot = `https://github.com/${owner}/${repo}`
const flashButtonLabel =
build?.status === 'failed' ? 'Retry build' : buildInProgress ? 'Building…' : 'Flash'
const targetPlaceholder = !resolvedSha
? ''
: scan == null || scan.scanStatus === 'in_progress'
? 'Scanning…'
: scan.scanStatus === 'failed'
? 'Scan failed'
: envNames.length === 0
? 'No targets'
: 'Pick env…'
const targetPlaceholder = !hasBranch
? '--target--'
: !resolvedSha
? '…'
: scan == null || scan.scanStatus === 'in_progress'
? 'Scanning…'
: scan.scanStatus === 'failed'
? 'Scan failed'
: envNames.length === 0
? 'No targets'
: '--target--'
return (
<div className="max-w-6xl mx-auto px-6 py-10 text-slate-200">
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_17.5rem] lg:gap-10 items-start">
<div className="min-w-0 space-y-5 order-2 lg:order-1">
<div className="min-w-0 space-y-5 order-1">
<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">
<AutocompleteField
<ComboboxField
label="Branch"
layout="inline"
id="mesh-forge-branch"
options={branchOptions}
value={branchDraft}
placeholder="--branch--"
clearSelectionLabel="Clear branch"
onChange={v => {
setBranchDraft(v)
if (branchOptions.includes(v)) {
const enc = v.split('/').map(encodeURIComponent).join('/')
navigate(`/${ownerParam}/${repoParam}/tree/${enc}`)
if (v === '') {
navigate(`/${ownerParam}/${repoParam}`)
return
}
if (branchOptions.includes(v)) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, null)}`)
}
}}
onBlur={() => {
if (!branchOptions.includes(branchDraft)) setBranchDraft(effectiveRef)
}}
disabled={branchOptions.length === 0}
/>
{scanReady && envNames.length > 0 ? (
<AutocompleteField
{hasBranch && scanReady && envNames.length > 0 ? (
<ComboboxField
label="Target"
layout="inline"
id="mesh-forge-target"
options={envNames}
value={envDraft}
placeholder="--target--"
clearSelectionLabel="Clear target"
onChange={v => {
setEnvDraft(v)
if (envNames.includes(v)) setSelectedEnv(v)
}}
onBlur={() => {
if (!envNames.includes(envDraft)) setEnvDraft(selectedEnv)
if (!branchRef) return
if (v === '') {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(branchRef, null)}`, {
replace: true,
})
return
}
if (envNames.includes(v)) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(branchRef, v)}`)
}
}}
disabled={false}
/>
@@ -279,10 +296,10 @@ export default function RepoPage() {
<input
type="text"
readOnly
disabled
disabled={!hasBranch}
value=""
placeholder={targetPlaceholder}
className="h-9 min-w-[7rem] flex-1 cursor-not-allowed rounded-md border border-slate-800 bg-slate-900/50 px-2.5 text-sm text-slate-500 placeholder:text-slate-600"
className="h-9 min-w-28 flex-1 cursor-not-allowed rounded-md border border-slate-800 bg-slate-900/50 px-2.5 text-sm text-slate-500 placeholder:text-slate-600"
/>
</label>
)}
@@ -293,14 +310,14 @@ export default function RepoPage() {
disabled={flashPrimaryDisabled}
onClick={queueFlashArtifacts}
>
Flash
{flashButtonLabel}
</Button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
{branchData?.isStale ? <span>Branch list may be stale.</span> : null}
{refError ? <span className="text-red-400">{refError}</span> : null}
{!refError && !resolvedSha ? <span>Resolving branch</span> : null}
{!refError && hasBranch && !resolvedSha ? <span>Resolving branch</span> : null}
{resolvedSha && (scan == null || scan.scanStatus === 'in_progress') ? (
<span>Scanning PlatformIO</span>
) : null}
@@ -318,16 +335,46 @@ export default function RepoPage() {
{build.githubRunId ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={`https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`}
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
target="_blank"
rel="noreferrer"
>
Workflow run
View run on GitHub
</a>
) : build.status === 'failed' ? (
<a
className="text-cyan-400 hover:underline text-xs"
href={meshForgeWorkflowUrl}
target="_blank"
rel="noreferrer"
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
>
Mesh Forge workflow on GitHub
</a>
) : null}
</div>
{build.status === 'failed' && build.errorSummary ? (
<p className="text-red-300 text-xs whitespace-pre-wrap">{formatBuildErrorSummary(build.errorSummary)}</p>
<div className="space-y-2 text-xs">
{(() => {
const { headline, body } = buildFailurePresentation(build.errorSummary)
return (
<>
<p className="font-medium text-slate-200">{headline}</p>
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
<details className="text-slate-500">
<summary className="cursor-pointer select-none hover:text-slate-400">
Technical details
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
{build.errorSummary.length > 2500
? `${build.errorSummary.slice(0, 2500)}`
: build.errorSummary}
</pre>
</details>
</>
)
})()}
</div>
) : null}
{build.status === 'succeeded' ? (
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
@@ -356,7 +403,9 @@ export default function RepoPage() {
</div>
<div className="mt-8 prose prose-invert prose-sm max-w-none prose-hr:my-6">
{readmeMd === null ? (
{!effectiveRef ? (
<p className="text-slate-500 not-prose text-sm">Select a branch to load the README.</p>
) : readmeMd === null ? (
<p className="text-slate-500 not-prose">Loading</p>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
@@ -366,7 +415,7 @@ export default function RepoPage() {
</div>
</div>
<aside className="order-1 border-b border-slate-800 pb-8 lg:order-2 lg:border-b-0 lg:border-l lg:border-slate-800 lg:pb-0 lg:pl-8 space-y-4">
<aside className="order-2 border-b border-slate-800 pb-8 lg:border-b-0 lg:border-l lg:border-slate-800 lg:pb-0 lg:pl-8 space-y-4">
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide">About</h2>
<div>
<p className="text-xs text-slate-500 mb-1">{owner}</p>
@@ -380,17 +429,17 @@ export default function RepoPage() {
href={ghTree}
target="_blank"
rel="noreferrer"
title={`View ${effectiveRef} on GitHub`}
title={effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
>
<Github className="size-4" aria-hidden />
<span className="sr-only">View {effectiveRef} on GitHub</span>
<span className="sr-only">
{effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
</span>
</a>
) : null}
</h3>
</div>
{branchData === undefined ? (
<p className="text-sm text-slate-500">Loading</p>
) : ghAboutDescription ? (
{ghAboutDescription ? (
<p className="text-sm text-slate-200 leading-relaxed">{ghAboutDescription}</p>
) : null}
{ghAboutHomepage ? (
@@ -409,10 +458,12 @@ export default function RepoPage() {
href={ghTree}
target="_blank"
rel="noreferrer"
title={`View ${effectiveRef} on GitHub`}
title={effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
>
<Github className="size-4" aria-hidden />
<span className="sr-only">View {effectiveRef} on GitHub</span>
<span className="sr-only">
{effectiveRef ? `View ${effectiveRef} on GitHub` : 'View repository on GitHub'}
</span>
</a>
</div>
) : null}