mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-07-03 08:22:07 +02:00
Merge branch 'develop'
This commit is contained in:
@@ -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`
|
||||
@@ -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-
|
||||
|
||||
|
||||
@@ -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)
|
||||
"
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/lobbs updated: 3aee4fb0e3...ea1e8a19ab
+1
Submodule vendor/lotato added at e2a83c3661
Vendored
+1
-1
Submodule vendor/meshcore-lotato updated: 7c7ab69126...175a207300
+1
Submodule vendor/potato-mesh added at f866cf8837
Reference in New Issue
Block a user