mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-07-06 01:42:13 +02:00
wip
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 Forge’s own GitHub workflow didn’t accept the build request (inputs out of sync). ' +
|
||||
'That’s on the Mesh Forge service, not your firmware. Retry won’t 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: 'Couldn’t start a cloud build',
|
||||
body:
|
||||
'Something on the Mesh Forge side didn’t line up with GitHub. It’s not a signal that your repo is broken. ' +
|
||||
'Retry usually won’t 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: 'Couldn’t start the build',
|
||||
body: formatBuildErrorSummary(summary),
|
||||
}
|
||||
}
|
||||
if (summary.includes('GitHub API failed: 422')) {
|
||||
return {
|
||||
headline: 'Couldn’t 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.',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user