Merge branch 'develop'

This commit is contained in:
Ben Allfree
2026-04-22 21:00:21 -07:00
14 changed files with 208 additions and 256 deletions
+18
View File
@@ -0,0 +1,18 @@
---
description: Use Conventional Commits; keep subject under 50 characters.
alwaysApply: true
---
# Conventional commits
When writing git commit messages:
- Use Conventional Commits: `<type>(<scope>): <subject>` or `<type>: <subject>`.
- Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `build`, `ci`, `revert`.
- Keep the subject line under 50 characters (including type/scope prefix).
- Use imperative mood and lowercase subject text.
Examples:
- `feat(flasher): add retry after connect failure`
- `fix(repo): handle missing branch metadata`
+1 -1
View File
@@ -96,7 +96,7 @@ jobs:
path: |
~/.platformio/.cache
~/.platformio/packages
key: platformio-${{ runner.os }}-v1-${{ hashFiles('.github/workflows/custom_build.yml', '.github/workflows/custom_build_test.yml') }}
key: platformio-${{ runner.os }}-v1-${{ hashFiles('.github/workflows/custom_build.yml') }}
restore-keys: |
platformio-${{ runner.os }}-v1-
-220
View File
@@ -1,220 +0,0 @@
name: Repo PlatformIO Build
on:
workflow_dispatch:
inputs:
owner:
required: true
type: string
repo:
required: true
type: string
ref:
required: true
type: string
target_env:
required: true
type: string
repo_build_id:
required: true
type: string
build_key:
required: true
type: string
resolved_source_sha:
required: true
type: string
convex_url:
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
env:
CONVEX_URL: ${{ inputs.convex_url }}
REPO_BUILD_ID: ${{ inputs.repo_build_id }}
CONVEX_BUILD_TOKEN: ${{ secrets.CONVEX_BUILD_TOKEN }}
CI_PROGRESS_TOTAL: "10"
PLATFORMIO_LIBDEPS_DIR: ${{ github.workspace }}/.pio-libdeps/${{ inputs.owner }}/${{ inputs.repo }}/${{ inputs.target_env }}
steps:
- uses: actions/checkout@v4
- name: Notify Convex — running
shell: bash
run: |
curl -sSf -X POST "$CONVEX_URL/ingest-repo-build" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CONVEX_BUILD_TOKEN" \
-d "{\"repo_build_id\":\"$REPO_BUILD_ID\",\"state\":\"running\",\"github_run_id\":${{ github.run_id }}}"
- name: CI progress — build started
shell: bash
env:
STEP_INDEX: 1
LABEL: Build started on Actions
run: python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: CI progress — tooling ready
shell: bash
env:
STEP_INDEX: 2
LABEL: Runner tooling ready
run: python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
- name: Download source archive
shell: bash
env:
OWNER: ${{ inputs.owner }}
REPO: ${{ inputs.repo }}
REF: ${{ inputs.ref }}
run: |
STEP_INDEX=3 LABEL="Downloading source archive" python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
ENC_REF=$(python3 -c "import urllib.parse,os; print(urllib.parse.quote(os.environ['REF'], safe=''))")
curl -fsSL -o /tmp/src.zip "https://codeload.github.com/${OWNER}/${REPO}/zip/${ENC_REF}"
- name: CI progress — restoring PlatformIO global cache
shell: bash
env:
STEP_INDEX: 4
LABEL: Restoring PlatformIO global cache (toolchains)
run: python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
- name: Cache PlatformIO global store
uses: actions/cache@v4
with:
path: |
~/.platformio/.cache
~/.platformio/packages
key: platformio-${{ runner.os }}-v1-${{ hashFiles('.github/workflows/custom_build.yml', '.github/workflows/custom_build_test.yml') }}
restore-keys: |
platformio-${{ runner.os }}-v1-
- name: CI progress — restoring PlatformIO libdeps cache
shell: bash
env:
STEP_INDEX: 5
LABEL: Restoring PlatformIO libdeps cache
run: python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
# Key is owner/repo/env only (not source SHA); bump …-v2 if lib_deps get out of sync.
- name: Cache PlatformIO libdeps (per repo + target)
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.pio-libdeps/${{ inputs.owner }}/${{ inputs.repo }}/${{ inputs.target_env }}
key: pio-libdeps-${{ inputs.owner }}-${{ inputs.repo }}-${{ inputs.target_env }}-v1
- name: Extract firmware source
shell: bash
run: |
STEP_INDEX=6 LABEL="Extracting firmware source" python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
set -e
sudo apt-get update -qq && sudo apt-get install -y -qq unzip
rm -rf /tmp/src
mkdir -p /tmp/src
unzip -q /tmp/src.zip -d /tmp/src
ROOT=$(find /tmp/src -mindepth 1 -maxdepth 1 -type d | head -1)
printf '%s\n' "$ROOT" > /tmp/fw-src-root.txt
mkdir -p "$PLATFORMIO_LIBDEPS_DIR"
- name: Install PlatformIO
shell: bash
run: |
STEP_INDEX=7 LABEL="Installing PlatformIO" python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
set -e
ROOT=$(cat /tmp/fw-src-root.txt)
cd "$ROOT"
python -m pip install -q --upgrade pip
pip install -q platformio adafruit-nrfutil
- name: Install PlatformIO dependencies
shell: bash
run: |
STEP_INDEX=8 LABEL="Installing firmware dependencies (PlatformIO)" python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
set -e
ROOT=$(cat /tmp/fw-src-root.txt)
cd "$ROOT"
pio pkg install -e "${{ inputs.target_env }}"
- name: Compile firmware (PlatformIO)
shell: bash
run: |
STEP_INDEX=9 LABEL="Compiling firmware (PlatformIO)" python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
set -e
ROOT=$(cat /tmp/fw-src-root.txt)
cd "$ROOT"
MKLITTLEFS_BIN=$(find "$HOME/.platformio/packages" -type f -name mklittlefs 2>/dev/null | head -1)
if [ -n "$MKLITTLEFS_BIN" ]; then
export PATH="$(dirname "$MKLITTLEFS_BIN"):$PATH"
fi
command -v mklittlefs >/dev/null 2>&1 || echo "WARNING: mklittlefs not on PATH; LittleFS build may fail"
pio run -e "${{ inputs.target_env }}" 2>&1 | tee /tmp/pio.log
BUILD_DIR=".pio/build/${{ inputs.target_env }}"
if [ ! -d "$BUILD_DIR" ]; then
echo "Build output directory missing"
exit 1
fi
- name: Package and upload firmware bundle
id: pio
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
shell: bash
run: |
STEP_INDEX=10 LABEL="Packaging and uploading firmware bundle" python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
set -e
ROOT=$(cat /tmp/fw-src-root.txt)
BUILD_DIR_ABS="$ROOT/.pio/build/${{ inputs.target_env }}"
ARTIFACT_NAME="firmware-${{ inputs.build_key }}-${{ github.run_id }}.tar.gz"
MESHTASTIC_OTA=1 bash "${{ github.workspace }}/scripts/ci/common/fw-bundle-pipeline.sh" "$ROOT" "$BUILD_DIR_ABS" "/tmp/$ARTIFACT_NAME"
OBJECT_PATH="${R2_BUCKET_NAME}/${ARTIFACT_NAME}"
bunx wrangler r2 object put "$OBJECT_PATH" --file "/tmp/$ARTIFACT_NAME" --remote
STEP_INDEX=10 LABEL="Firmware bundle uploaded" python3 "${{ github.workspace }}/scripts/ci/report-convex-ci-progress.py"
echo "r2_key=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
- name: Notify Convex — success
if: success()
shell: bash
env:
R2_KEY: ${{ steps.pio.outputs.r2_key }}
run: |
curl -sSf -X POST "$CONVEX_URL/ingest-repo-build" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CONVEX_BUILD_TOKEN" \
-d "{\"repo_build_id\":\"$REPO_BUILD_ID\",\"state\":\"succeeded\",\"github_run_id\":${{ github.run_id }},\"r2ObjectKey\":\"$R2_KEY\"}"
- name: Notify Convex — failure
if: failure()
shell: bash
env:
GITHUB_RUN_ID: ${{ github.run_id }}
run: |
python3 -c "
import json, os, urllib.request
err = open('/tmp/pio.log').read()[-4000:] if os.path.exists('/tmp/pio.log') else 'build failed'
body = {
'repo_build_id': os.environ['REPO_BUILD_ID'],
'state': 'failed',
'github_run_id': int(os.environ['GITHUB_RUN_ID']),
'errorSummary': err,
}
req = urllib.request.Request(
os.environ['CONVEX_URL'] + '/ingest-repo-build',
data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + os.environ['CONVEX_BUILD_TOKEN']},
method='POST',
)
urllib.request.urlopen(req)
"
+6
View File
@@ -46,3 +46,9 @@
[submodule "vendor/microReticulum-Firmware"]
path = vendor/microReticulum-Firmware
url = git@github.com:Reticulum-Community/microReticulum_Firmware.git
[submodule "vendor/potato-mesh"]
path = vendor/potato-mesh
url = git@github.com:l5yth/potato-mesh.git
[submodule "vendor/lotato"]
path = vendor/lotato
url = git@github.com:MeshEnvy/lotato.git
+2 -3
View File
@@ -23,8 +23,7 @@ export const dispatchRepoBuild = action({
}
const isDev = process.env.CONVEX_ENV === "dev"
const workflowFile = isDev ? "custom_build_test.yml" : "custom_build.yml"
const workflowRef = isDev ? "v2" : "main"
const workflowRef = isDev ? "develop" : "main"
const payload = {
ref: workflowRef,
@@ -40,7 +39,7 @@ export const dispatchRepoBuild = action({
},
}
const url = `https://api.github.com/repos/MeshEnvy/mesh-forge/actions/workflows/${workflowFile}/dispatches`
const url = `https://api.github.com/repos/MeshEnvy/mesh-forge/actions/workflows/custom_build.yml/dispatches`
const headers = {
Authorization: `Bearer ${githubToken}`,
Accept: "application/vnd.github.v3+json",
+2 -2
View File
@@ -28,8 +28,8 @@
},
{
"path": "vendor/meshcore-lotato",
"heltec_v3": "Heltec_v3_companion_radio_ble",
"rak_4631": "RAK_4631_companion_radio_ble"
"heltec_v3": "Heltec_v3_repeater",
"rak_4631": "RAK_4631_repeater"
}
]
}
+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>
))
+24 -2
View File
@@ -16,10 +16,12 @@ import { toast } from "sonner"
import { buildFlashParts } from "../lib/espFlashLayout"
import {
ensureSerialPortClosed,
ESP_FLASH_BAUD_OPTIONS,
ESP_FLASH_WEB_BAUD,
isSerialUserCancelledError,
pulseUsbBootloaderOnPort,
runEspFlash,
type EspFlashBaud,
type FlashPhase,
} from "../lib/espFlashRun"
import { inferTargetFamilyFromBundle, inferTargetFamilyFromEnv } from "../lib/flashTargetFamily"
@@ -84,6 +86,7 @@ export default function DeviceFlasher({
const [busy, setBusy] = useState(false)
const shareDialogRef = useRef<HTMLDialogElement>(null)
const [eraseFlashForFactory, setEraseFlashForFactory] = useState(false)
const [flashBaud, setFlashBaud] = useState<EspFlashBaud>(ESP_FLASH_WEB_BAUD)
const [bundleFamily, setBundleFamily] = useState<FlashTargetFamily>(inferTargetFamilyFromEnv(targetEnv) ?? "esp32")
const [bundleCanErase, setBundleCanErase] = useState(false)
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
@@ -175,7 +178,7 @@ export default function DeviceFlasher({
await runEspFlash({
port,
parts: plan.parts,
baud: ESP_FLASH_WEB_BAUD,
baud: flashBaud,
eraseAll: eraseFlashForFactory || plan.eraseAll,
onPhase: phase => {
setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] })
@@ -212,7 +215,7 @@ export default function DeviceFlasher({
setFlashProgress(null)
}
}
}, [eraseFlashForFactory, prepareBundle, canEspFlash, flashBlockedReason, resolvedFamily])
}, [eraseFlashForFactory, flashBaud, prepareBundle, canEspFlash, flashBlockedReason, resolvedFamily])
const shareUrlTrimmed = useMemo(() => sharePageUrl?.trim() ?? "", [sharePageUrl])
const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function"
@@ -299,6 +302,25 @@ export default function DeviceFlasher({
</button>
<span className="text-sm font-medium text-slate-200">Full device reset</span>
</div>
{resolvedFamily === "esp32" ? (
<label className="flex items-center gap-2 text-sm text-slate-200">
<span>Baud</span>
<select
value={flashBaud}
onChange={e => setFlashBaud(Number(e.target.value) as EspFlashBaud)}
disabled={busy || !canEspFlash}
className="h-8 rounded-md border border-slate-600 bg-slate-900/60 px-2 text-sm text-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 disabled:cursor-not-allowed disabled:opacity-50"
title="Lower the baud rate if flashing fails on a cheap cable or USB hub."
>
{ESP_FLASH_BAUD_OPTIONS.map(b => (
<option key={b} value={b}>
{b.toLocaleString()}
</option>
))}
</select>
</label>
) : null}
</div>
{!canFullReset ? (
+6 -2
View File
@@ -195,8 +195,12 @@ export async function runEspFlash(options: {
await transport.disconnect()
}
/** Conservative default for `runEspFlash` over Web Serial (fewer cable/hub issues than 921600). */
export const ESP_FLASH_WEB_BAUD = 115200
/** Supported Web Serial baud rates for ESP flashing. First entry is the default. */
export const ESP_FLASH_BAUD_OPTIONS = [921600, 460800, 230400, 115200] as const
export type EspFlashBaud = (typeof ESP_FLASH_BAUD_OPTIONS)[number]
/** Default baud for `runEspFlash` over Web Serial. Matches PlatformIO; lower if cables/hubs are flaky. */
export const ESP_FLASH_WEB_BAUD: EspFlashBaud = ESP_FLASH_BAUD_OPTIONS[0]
/** Match MeshCore `lib/dfu.js` CDC touch timing (1200 baud → close → wait for re-enumeration). */
const CDC_TOUCH_OPEN_MS = 100
+131 -17
View File
@@ -30,6 +30,24 @@ import { buildTreeSplatPath, parseTreeSplat } from "../lib/repoTreeUrl"
const MESH_FORGE_ACTIONS_REPO = "MeshEnvy/mesh-forge"
const meshForgeWorkflowUrl = `https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/workflows/custom_build.yml`
function classifyResolveRefError(error: unknown, ref: string): { message: string; shouldRedirectToBase: boolean } {
const raw = String(error)
const lower = raw.toLowerCase()
const isMissingRef =
lower.includes("no commit found for sha") ||
lower.includes("couldn't find remote ref") ||
lower.includes("unknown revision or path not in the working tree")
if (isMissingRef) {
return {
message: `Ref "${ref}" was not found.`,
shouldRedirectToBase: true,
}
}
return { message: raw, shouldRedirectToBase: false }
}
export default function RepoPage() {
const navigate = useNavigate()
const location = useLocation()
@@ -74,17 +92,22 @@ export default function RepoPage() {
if (sourceRef) return
if (tagData === undefined || !tagData.row) return
const tags = tagData.row.tags
const defaultBranch = (tagData.row as { defaultBranch?: string }).defaultBranch
if (tags.length > 0) {
const allSorted = sortTagNames(tags.map(t => t.name))
const cfg = tagData.row.meshforgeConfig as MeshforgeConfig | null | undefined
const candidates = cfg ? filterTagNames(allSorted, cfg) : allSorted
// Fall back to unfiltered list when the profile leaves nothing (e.g. no matching tags yet)
const latest = (candidates.length > 0 ? candidates : allSorted)[0]
if (!latest) return
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(latest, null)}`, { replace: true })
const latest = candidates[0]
if (latest) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(latest, null)}`, { replace: true })
return
}
// Profile filtered out all tags; use default branch when available.
if (defaultBranch) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(defaultBranch, null)}`, { replace: true })
}
} else {
// No tags — redirect to the repo's default branch if known
const defaultBranch = (tagData.row as { defaultBranch?: string }).defaultBranch
if (!defaultBranch) return
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(defaultBranch, null)}`, { replace: true })
}
@@ -92,6 +115,10 @@ export default function RepoPage() {
const [resolvedSha, setResolvedSha] = useState<string | null>(null)
const [refError, setRefError] = useState<string | null>(null)
const [pendingTagRefreshValidation, setPendingTagRefreshValidation] = useState(false)
const [isRefreshingRepo, setIsRefreshingRepo] = useState(false)
const [refreshResolveTick, setRefreshResolveTick] = useState(0)
const [readmeRefreshTick, setReadmeRefreshTick] = useState(0)
useEffect(() => {
if (!owner || !repo || !effectiveRef) return
let cancelled = false
@@ -102,12 +129,31 @@ export default function RepoPage() {
if (!cancelled) setResolvedSha(sha)
})
.catch(e => {
if (!cancelled) setRefError(String(e))
if (cancelled) return
const { message, shouldRedirectToBase } = classifyResolveRefError(e, effectiveRef)
setRefError(message)
if (shouldRedirectToBase) {
navigate(`/${ownerParam}/${repoParam}`, { replace: true })
}
})
return () => {
cancelled = true
}
}, [owner, repo, effectiveRef, resolveRef])
}, [owner, repo, effectiveRef, resolveRef, navigate, ownerParam, repoParam, refreshResolveTick])
useEffect(() => {
if (!pendingTagRefreshValidation || !sourceRef || tagData === undefined) return
setPendingTagRefreshValidation(false)
const tags = tagData.row?.tags ?? []
const defaultBranch = (tagData.row as { defaultBranch?: string } | null | undefined)?.defaultBranch
const normalizedSourceRef = sourceRef.toLowerCase()
const refStillExists =
tags.some(t => t.name.toLowerCase() === normalizedSourceRef) ||
defaultBranch?.toLowerCase() === normalizedSourceRef
if (!refStillExists) {
navigate(`/${ownerParam}/${repoParam}`, { replace: true })
}
}, [pendingTagRefreshValidation, sourceRef, tagData, navigate, ownerParam, repoParam])
useEffect(() => {
if (!owner || !repo || !effectiveRef || !resolvedSha) return
@@ -153,7 +199,7 @@ export default function RepoPage() {
return () => {
cancelled = true
}
}, [owner, repo, effectiveRef, fetchReadme, isFlashView])
}, [owner, repo, effectiveRef, fetchReadme, isFlashView, readmeRefreshTick])
const readmeMarkdownComponents = useMemo(
() => ({
@@ -227,10 +273,64 @@ export default function RepoPage() {
const defaultBranch = (tagData?.row as { defaultBranch?: string } | null | undefined)?.defaultBranch
const reinjected = new Set(filtered.map(n => n.toLowerCase()))
const extras: string[] = []
if (sourceRef && !reinjected.has(sourceRef.toLowerCase())) extras.push(sourceRef)
if (defaultBranch && !reinjected.has(defaultBranch.toLowerCase())) extras.push(defaultBranch)
if (sourceRef) {
const key = sourceRef.toLowerCase()
if (!reinjected.has(key)) {
extras.push(sourceRef)
reinjected.add(key)
}
}
if (defaultBranch) {
const key = defaultBranch.toLowerCase()
if (!reinjected.has(key)) {
extras.push(defaultBranch)
reinjected.add(key)
}
}
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]
@@ -643,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 === "") {
@@ -771,11 +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"
onClick={() => void refreshTags({ owner, repo }).catch(e => toast.error(String(e)))}
title="Refresh repo metadata from GitHub"
disabled={isRefreshingRepo}
onClick={() => {
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)
})
.catch(e => toast.error(String(e)))
.finally(() => setIsRefreshingRepo(false))
}}
>
<RefreshCw className="size-3.5" />
Refresh tags
<RefreshCw className={`size-3.5 ${isRefreshingRepo ? "animate-spin" : ""}`} />
{isRefreshingRepo ? "Refreshing…" : "Refresh repo"}
</Button>
</div>
</aside>
+1 -1
Vendored Submodule
+1
Submodule vendor/lotato added at e2a83c3661
Vendored Submodule
+1
Submodule vendor/potato-mesh added at f866cf8837