mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 09:22:56 +02:00
Enhance firmware flashing process by updating emit-flash-manifest.py to merge target family metadata from PlatformIO when missing. Updated CI workflows to pass additional parameters for improved manifest generation. Refactor EspFlasher component to utilize target environment for better flash handling and error messaging. Improved error handling and user feedback during the flashing process.
This commit is contained in:
@@ -110,7 +110,7 @@ jobs:
|
||||
echo "Build output directory missing"
|
||||
exit 1
|
||||
fi
|
||||
python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR"
|
||||
python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR" "$ROOT" "${{ inputs.target_env }}"
|
||||
ARTIFACT_NAME="firmware-${{ inputs.build_key }}-${{ github.run_id }}.tar.gz"
|
||||
STAGE=/tmp/fw-bundle
|
||||
rm -rf "$STAGE"
|
||||
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
echo "Build output directory missing"
|
||||
exit 1
|
||||
fi
|
||||
python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR"
|
||||
python3 "${{ github.workspace }}/scripts/emit-flash-manifest.py" "$ROOT/$BUILD_DIR" "$ROOT" "${{ inputs.target_env }}"
|
||||
ARTIFACT_NAME="firmware-${{ inputs.build_key }}-${{ github.run_id }}.tar.gz"
|
||||
STAGE=/tmp/fw-bundle
|
||||
rm -rf "$STAGE"
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
"""
|
||||
Emit flash-manifest.json for Mesh Forge USB flasher.
|
||||
|
||||
If BUILD_DIR/flash-manifest.json already exists (project-supplied), leave it.
|
||||
If BUILD_DIR/flash-manifest.json already exists (project-supplied), merge in
|
||||
targetFamily from PlatformIO when missing.
|
||||
|
||||
Otherwise, if Meshtastic-style *.mt.json is present, synthesize a manifest
|
||||
from partition table + on-disk artifacts.
|
||||
|
||||
Optional args: PROJECT_ROOT TARGET_ENV — merged PIO config for that env fills
|
||||
targetFamily (and platform/board) for the USB flasher UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,6 +20,85 @@ import re
|
||||
import sys
|
||||
|
||||
|
||||
def _normalize_platform(platform: str | None) -> str:
|
||||
if not platform:
|
||||
return ""
|
||||
p = platform.strip().lower()
|
||||
if "#" in p:
|
||||
p = p.split("#", 1)[0].strip()
|
||||
if "@" in p:
|
||||
p = p.split("@", 1)[0].strip()
|
||||
if "/" in p:
|
||||
p = p.rsplit("/", 1)[-1].strip()
|
||||
return p
|
||||
|
||||
|
||||
def _platform_to_target_family(platform: str | None, board: str | None) -> str:
|
||||
pl = _normalize_platform(platform)
|
||||
b = (board or "").lower()
|
||||
if "8266" in pl or "esp8266" in b:
|
||||
return "esp8266"
|
||||
if "nrf52" in pl or "nrf52840" in b or "nrf52833" in b or "nrf52832" in b:
|
||||
return "nrf52"
|
||||
if "rp2040" in pl or "rp2040" in b or "raspberrypi" in pl:
|
||||
return "rp2040"
|
||||
if "espressif32" in pl or "esp32" in pl or "esp32" in b or "esp32c3" in b or "esp32s3" in b:
|
||||
return "esp32"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _resolve_pio_target_family(project_root: str, target_env: str) -> tuple[str, str | None, str | None]:
|
||||
try:
|
||||
from platformio.project.config import ProjectConfig
|
||||
except ImportError:
|
||||
print("platformio not installed; targetFamily will be unknown", file=sys.stderr)
|
||||
return "unknown", None, None
|
||||
|
||||
ini = os.path.join(project_root, "platformio.ini")
|
||||
if not os.path.isfile(ini):
|
||||
return "unknown", None, None
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
config = ProjectConfig("platformio.ini")
|
||||
section = f"env:{target_env}"
|
||||
if section not in config.sections():
|
||||
print(f"PIO env not found: {target_env!r}", file=sys.stderr)
|
||||
return "unknown", None, None
|
||||
platform = config.get(section, "platform")
|
||||
board = config.get(section, "board")
|
||||
fam = _platform_to_target_family(platform, board)
|
||||
return fam, platform, board
|
||||
except Exception as e:
|
||||
print(f"PIO targetFamily resolution failed: {e}", file=sys.stderr)
|
||||
return "unknown", None, None
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
|
||||
def _merge_target_family_meta(
|
||||
manifest: dict,
|
||||
target_family: str,
|
||||
platform: str | None,
|
||||
board: str | None,
|
||||
) -> dict:
|
||||
"""Add targetFamily / platform / board only when absent."""
|
||||
out = dict(manifest)
|
||||
if "targetFamily" not in out:
|
||||
out["targetFamily"] = target_family
|
||||
if platform and "platform" not in out:
|
||||
out["platform"] = platform.strip() if isinstance(platform, str) else platform
|
||||
if board and "board" not in out:
|
||||
out["board"] = board.strip() if isinstance(board, str) else board
|
||||
return out
|
||||
|
||||
|
||||
def _write_manifest(path: str, data: dict) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def parse_offset(v: object) -> int | None:
|
||||
if v is None:
|
||||
return None
|
||||
@@ -199,13 +283,38 @@ def pick_mt_json(build_dir: str) -> str | None:
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: emit-flash-manifest.py BUILD_DIR", file=sys.stderr)
|
||||
print(
|
||||
"usage: emit-flash-manifest.py BUILD_DIR [PROJECT_ROOT TARGET_ENV]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
build_dir = os.path.abspath(sys.argv[1])
|
||||
project_root: str | None = None
|
||||
target_env: str | None = None
|
||||
if len(sys.argv) >= 4:
|
||||
project_root = os.path.abspath(sys.argv[2])
|
||||
target_env = sys.argv[3]
|
||||
|
||||
pio_family, pio_platform, pio_board = (
|
||||
_resolve_pio_target_family(project_root, target_env)
|
||||
if project_root and target_env
|
||||
else ("unknown", None, None)
|
||||
)
|
||||
|
||||
out_path = os.path.join(build_dir, "flash-manifest.json")
|
||||
|
||||
if os.path.isfile(out_path):
|
||||
print(f"Keeping existing {out_path}")
|
||||
with open(out_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
if not isinstance(manifest, dict) or not isinstance(manifest.get("images"), list):
|
||||
print(f"Invalid existing {out_path}", file=sys.stderr)
|
||||
return 1
|
||||
merged = _merge_target_family_meta(manifest, pio_family, pio_platform, pio_board)
|
||||
if merged != manifest:
|
||||
_write_manifest(out_path, merged)
|
||||
print(f"Merged targetFamily into {out_path}")
|
||||
else:
|
||||
print(f"Keeping existing {out_path} (targetFamily already set)")
|
||||
return 0
|
||||
|
||||
mt_path = pick_mt_json(build_dir)
|
||||
@@ -221,8 +330,8 @@ def main() -> int:
|
||||
print("Could not synthesize flash-manifest.json from mt.json")
|
||||
return 0
|
||||
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
manifest = _merge_target_family_meta(manifest, pio_family, pio_platform, pio_board)
|
||||
_write_manifest(out_path, manifest)
|
||||
print(f"Wrote {out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
+146
-52
@@ -1,34 +1,51 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap } from '../lib/espFlashLayout'
|
||||
import type { FlashManifest } from '../lib/untarGz'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap } from "../lib/espFlashLayout"
|
||||
import {
|
||||
ensureSerialPortClosed,
|
||||
isSerialUserCancelledError,
|
||||
pulseUsbBootloaderPort,
|
||||
runEspFlash,
|
||||
type FlashPhase,
|
||||
} from '../lib/espFlashRun'
|
||||
import { extractTarGz } from '../lib/untarGz'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
} from "../lib/espFlashRun"
|
||||
import { resolveFlashTargetFamily } from "../lib/flashTargetFamily"
|
||||
import type { FlashManifest, FlashTargetFamily } from "../lib/untarGz"
|
||||
import { extractTarGz } from "../lib/untarGz"
|
||||
|
||||
type FlashProgress =
|
||||
| { kind: 'indeterminate'; label: string }
|
||||
| { kind: 'determinate'; label: string; pct: number }
|
||||
| { kind: 'complete' }
|
||||
| { kind: "indeterminate"; label: string }
|
||||
| { kind: "determinate"; label: string; pct: number }
|
||||
| { kind: "complete" }
|
||||
|
||||
const PHASE_LABEL: Record<FlashPhase, string> = {
|
||||
connect: 'Connecting to bootloader…',
|
||||
detect: 'Detecting flash size…',
|
||||
write: 'Writing firmware…',
|
||||
connect: "Connecting to bootloader…",
|
||||
detect: "Detecting flash size…",
|
||||
write: "Writing firmware…",
|
||||
}
|
||||
|
||||
function unsupportedFlashMessage(family: FlashTargetFamily): string | null {
|
||||
if (family === "nrf52") {
|
||||
return "This bundle targets nRF52. In-browser flashing here uses esptool (ESP32). Use adafruit-nrfutil / nrfutil with the ZIP from Download bundle, or your board’s UF2/DFU workflow."
|
||||
}
|
||||
if (family === "rp2040") {
|
||||
return "This bundle targets RP2040. Use UF2 drag-and-drop or picotool with artifacts from Download bundle — Web Serial esptool here is for ESP32-class boards only."
|
||||
}
|
||||
if (family === "esp8266") {
|
||||
return "This bundle targets ESP8266. The embedded flasher is tuned for ESP32; use esptool.py locally with Download bundle if you need USB flashing."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type EspFlasherProps = {
|
||||
bundleUrl: string
|
||||
/** PlatformIO env for the selected build; used if manifest omits targetFamily. */
|
||||
targetEnv?: string | null
|
||||
/** Primary CTA label (default matches standalone copy). */
|
||||
flashButtonLabel?: string
|
||||
flashBusyLabel?: string
|
||||
flashButtonSize?: 'default' | 'lg'
|
||||
flashButtonSize?: "default" | "lg"
|
||||
className?: string
|
||||
/** Tighter copy when shown in the repo hero. */
|
||||
condensed?: boolean
|
||||
@@ -36,10 +53,11 @@ type EspFlasherProps = {
|
||||
|
||||
export default function EspFlasher({
|
||||
bundleUrl,
|
||||
flashButtonLabel = 'Connect serial & flash',
|
||||
flashBusyLabel = 'Flashing…',
|
||||
flashButtonSize = 'default',
|
||||
className = '',
|
||||
targetEnv = null,
|
||||
flashButtonLabel = "Connect serial & flash",
|
||||
flashBusyLabel = "Flashing…",
|
||||
flashButtonSize = "default",
|
||||
className = "",
|
||||
condensed = false,
|
||||
}: EspFlasherProps) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -48,6 +66,42 @@ export default function EspFlasher({
|
||||
const [baud, setBaud] = useState(921600)
|
||||
const [layoutPreview, setLayoutPreview] = useState<FlashManifest | null>(null)
|
||||
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
|
||||
const [bundleLoadError, setBundleLoadError] = useState<string | null>(null)
|
||||
const [dfuTouchComplete, setDfuTouchComplete] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDfuTouchComplete(false)
|
||||
setLayoutPreview(null)
|
||||
setBundleLoadError(null)
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(bundleUrl)
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setBundleLoadError(`Download failed: ${res.status}`)
|
||||
return
|
||||
}
|
||||
const buf = new Uint8Array(await res.arrayBuffer())
|
||||
const files = extractTarGz(buf)
|
||||
const m = manifestFromMap(files)
|
||||
if (!cancelled) {
|
||||
setLayoutPreview(m)
|
||||
setBundleLoadError(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setBundleLoadError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bundleUrl])
|
||||
|
||||
const resolvedFamily = resolveFlashTargetFamily(layoutPreview, targetEnv)
|
||||
const flashBlockedReason = unsupportedFlashMessage(resolvedFamily)
|
||||
const canEspFlash = flashBlockedReason === null
|
||||
|
||||
const prepareBundle = useCallback(async () => {
|
||||
const res = await fetch(bundleUrl)
|
||||
@@ -60,20 +114,25 @@ export default function EspFlasher({
|
||||
}, [bundleUrl])
|
||||
|
||||
const flash = useCallback(async () => {
|
||||
if (!('serial' in navigator)) {
|
||||
toast.error('Web Serial is not supported in this browser')
|
||||
if (!canEspFlash) {
|
||||
toast.error("Unsupported for in-browser flash", { description: flashBlockedReason })
|
||||
return
|
||||
}
|
||||
if (!("serial" in navigator)) {
|
||||
toast.error("Web Serial is not supported in this browser")
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setFlashProgress({ kind: 'indeterminate', label: 'Select a serial port…' })
|
||||
setFlashProgress({ kind: "indeterminate", label: "Select a serial port…" })
|
||||
let finishedOk = false
|
||||
let port: SerialPort | undefined
|
||||
try {
|
||||
const port = await navigator.serial.requestPort()
|
||||
setFlashProgress({ kind: 'indeterminate', label: 'Downloading firmware…' })
|
||||
port = await navigator.serial.requestPort()
|
||||
setFlashProgress({ kind: "indeterminate", label: "Downloading firmware…" })
|
||||
const files = await prepareBundle()
|
||||
const parts = buildFlashParts(files)
|
||||
if (!parts) {
|
||||
toast.error('Could not detect flash layout from bundle')
|
||||
toast.error("Could not detect flash layout from bundle")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,40 +141,45 @@ export default function EspFlasher({
|
||||
parts,
|
||||
baud,
|
||||
eraseAll,
|
||||
resetMode: noReset ? 'no_reset' : 'default_reset',
|
||||
resetMode: noReset ? "no_reset" : "default_reset",
|
||||
onPhase: phase => {
|
||||
setFlashProgress({ kind: 'indeterminate', label: PHASE_LABEL[phase] })
|
||||
setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] })
|
||||
},
|
||||
onWriteProgress: p => {
|
||||
setFlashProgress({
|
||||
kind: 'determinate',
|
||||
kind: "determinate",
|
||||
label: `Writing firmware (${p.imageIndex + 1}/${p.imageCount})`,
|
||||
pct: p.overallPct,
|
||||
})
|
||||
},
|
||||
})
|
||||
finishedOk = true
|
||||
setFlashProgress({ kind: 'complete' })
|
||||
toast.success('Flash complete')
|
||||
setFlashProgress({ kind: "complete" })
|
||||
toast.success("Flash complete")
|
||||
} catch (e) {
|
||||
if (isSerialUserCancelledError(e)) {
|
||||
return
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toast.error('Flash failed', { description: msg })
|
||||
toast.error("Flash failed", { description: msg })
|
||||
} finally {
|
||||
if (port && !finishedOk) {
|
||||
void ensureSerialPortClosed(port)
|
||||
}
|
||||
setBusy(false)
|
||||
if (!finishedOk) {
|
||||
setFlashProgress(null)
|
||||
}
|
||||
}
|
||||
}, [baud, eraseAll, noReset, prepareBundle])
|
||||
}, [baud, eraseAll, noReset, prepareBundle, canEspFlash, flashBlockedReason])
|
||||
|
||||
const boot1200 = useCallback(async () => {
|
||||
const enterDfuMode = useCallback(async () => {
|
||||
try {
|
||||
await pulseUsbBootloaderPort()
|
||||
toast.success('1200 baud pulse sent', {
|
||||
description: 'If the board did not enter bootloader, hold BOOT, tap RST, then try flash again.',
|
||||
setDfuTouchComplete(true)
|
||||
toast.success("DFU / bootloader touch sent", {
|
||||
description:
|
||||
"If the device re-enumerated, pick the bootloader port and flash. If not, hold BOOT, tap RST, then try again.",
|
||||
})
|
||||
} catch (e) {
|
||||
if (isSerialUserCancelledError(e)) {
|
||||
@@ -125,22 +189,37 @@ export default function EspFlasher({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const familyLine =
|
||||
resolvedFamily !== "esp32" || layoutPreview?.targetFamily
|
||||
? `Target: ${resolvedFamily}${layoutPreview?.platform ? ` · ${layoutPreview.platform.trim()}` : ""}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-slate-700 bg-slate-900/50 p-4 space-y-3 ${className}`.trim()}
|
||||
>
|
||||
<div className={`rounded-lg border border-slate-700 bg-slate-900/50 p-4 space-y-3 ${className}`.trim()}>
|
||||
{condensed ? null : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-white">ESP flash (Web Serial)</h3>
|
||||
<h3 className="text-lg font-semibold text-white">USB firmware flash (Web Serial)</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Uses esptool-js. Connect USB, put the board in bootloader if needed, then flash. Wrong offsets can brick
|
||||
hardware—verify the map.
|
||||
esptool-js for ESP32-class layouts. Put the board in bootloader if needed — use{" "}
|
||||
<strong>Enter DFU mode</strong> for the USB CDC 1200-baud touch when supported.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{condensed ? (
|
||||
<p className="text-sm text-slate-400">
|
||||
USB + Chromium Web Serial. Verify the flash map before writing—wrong images can brick hardware.
|
||||
Chromium Web Serial + esptool-js (ESP32-class). Verify the flash map — wrong images can brick hardware.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{bundleLoadError ? (
|
||||
<p className="text-xs text-amber-300/90">Could not prefetch bundle: {bundleLoadError}</p>
|
||||
) : null}
|
||||
|
||||
{familyLine ? <p className="text-xs font-mono text-slate-400">{familyLine}</p> : null}
|
||||
|
||||
{flashBlockedReason ? (
|
||||
<p className="text-sm text-amber-200/90 rounded-md border border-amber-800/40 bg-amber-950/30 px-3 py-2">
|
||||
{flashBlockedReason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -152,9 +231,9 @@ export default function EspFlasher({
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000—or
|
||||
single firmware.bin @ 0x0. With <code className="text-slate-400">flash-manifest.json</code>, offsets come
|
||||
from the bundle.
|
||||
Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000—or single
|
||||
firmware.bin @ 0x0. With <code className="text-slate-400">flash-manifest.json</code>, offsets come from the
|
||||
bundle.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -165,6 +244,7 @@ export default function EspFlasher({
|
||||
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white"
|
||||
value={baud}
|
||||
onChange={e => setBaud(Number(e.target.value))}
|
||||
disabled={!canEspFlash}
|
||||
>
|
||||
<option value={115200}>115200</option>
|
||||
<option value={460800}>460800</option>
|
||||
@@ -172,13 +252,27 @@ export default function EspFlasher({
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300 flex items-center gap-2">
|
||||
<input type="checkbox" checked={eraseAll} onChange={e => setEraseAll(e.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eraseAll}
|
||||
onChange={e => setEraseAll(e.target.checked)}
|
||||
disabled={!canEspFlash}
|
||||
/>
|
||||
Full chip erase (destructive)
|
||||
</label>
|
||||
<label className="text-sm text-slate-300 flex items-center gap-2">
|
||||
<input type="checkbox" checked={noReset} onChange={e => setNoReset(e.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noReset}
|
||||
onChange={e => setNoReset(e.target.checked)}
|
||||
disabled={!canEspFlash}
|
||||
/>
|
||||
No auto-reset (hold BOOT manually)
|
||||
</label>
|
||||
<label className="text-sm text-slate-300 flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={dfuTouchComplete} onChange={e => setDfuTouchComplete(e.target.checked)} />
|
||||
Bootloader touch done
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
|
||||
@@ -186,18 +280,18 @@ export default function EspFlasher({
|
||||
type="button"
|
||||
size={flashButtonSize}
|
||||
className="bg-amber-600 hover:bg-amber-700"
|
||||
disabled={busy}
|
||||
disabled={busy || !canEspFlash}
|
||||
onClick={() => void flash()}
|
||||
>
|
||||
{busy ? flashBusyLabel : flashButtonLabel}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void boot1200()}>
|
||||
1200 baud reset
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void enterDfuMode()}>
|
||||
{dfuTouchComplete ? "Enter DFU mode (again)" : "Enter DFU mode"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{flashProgress ? (
|
||||
flashProgress.kind === 'complete' ? (
|
||||
flashProgress.kind === "complete" ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-950/40 px-3 py-2">
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-400" aria-hidden />
|
||||
<span className="text-sm font-medium text-emerald-300">Flashing complete</span>
|
||||
@@ -206,7 +300,7 @@ export default function EspFlasher({
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-400">{flashProgress.label}</p>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-800">
|
||||
{flashProgress.kind === 'determinate' ? (
|
||||
{flashProgress.kind === "determinate" ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-600 transition-[width] duration-150 ease-out"
|
||||
style={{ width: `${flashProgress.pct}%` }}
|
||||
|
||||
+92
-27
@@ -1,5 +1,5 @@
|
||||
import { ESPLoader, Transport, type FlashSizeValues } from 'esptool-js'
|
||||
import type { FlashPart } from './espFlashLayout'
|
||||
import { ESPLoader, Transport, type FlashSizeValues } from "esptool-js"
|
||||
import type { FlashPart } from "./espFlashLayout"
|
||||
|
||||
export type EspTerminal = {
|
||||
clean: () => void
|
||||
@@ -14,12 +14,74 @@ export const noopEspTerminal: EspTerminal = {
|
||||
}
|
||||
|
||||
export function isSerialUserCancelledError(e: unknown): boolean {
|
||||
if (e instanceof DOMException && e.name === 'NotFoundError') return true
|
||||
if (e instanceof DOMException && e.name === "NotFoundError") return true
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return msg.includes('No port selected')
|
||||
return msg.includes("No port selected")
|
||||
}
|
||||
|
||||
export type FlashPhase = 'connect' | 'detect' | 'write'
|
||||
function sleepMs(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isPortAlreadyOpenError(e: unknown): boolean {
|
||||
if (e instanceof DOMException && e.name === "InvalidStateError") return true
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return /already open/i.test(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* When readable/writable exist, the port is still open. Close it so a later `open()` succeeds.
|
||||
* Handles failed prior sessions that skipped `disconnect()` or a `close()` that threw.
|
||||
*/
|
||||
export async function ensureSerialPortClosed(port: SerialPort): Promise<void> {
|
||||
if (port.readable === null && port.writable === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (port.readable && !port.readable.locked) {
|
||||
const reader = port.readable.getReader()
|
||||
await reader.cancel().catch(() => {})
|
||||
reader.releaseLock()
|
||||
}
|
||||
} catch {
|
||||
// Readable may be locked by another consumer (e.g. stuck esptool readLoop).
|
||||
}
|
||||
try {
|
||||
if (port.writable && !port.writable.locked) {
|
||||
const writer = port.writable.getWriter()
|
||||
await writer.close().catch(() => {})
|
||||
writer.releaseLock()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (port.readable === null && port.writable === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await port.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await sleepMs(80)
|
||||
}
|
||||
}
|
||||
|
||||
async function openSerialPortWithRecovery(port: SerialPort, options: SerialOptions): Promise<void> {
|
||||
try {
|
||||
await port.open(options)
|
||||
} catch (e) {
|
||||
if (!isPortAlreadyOpenError(e)) {
|
||||
throw e
|
||||
}
|
||||
await ensureSerialPortClosed(port)
|
||||
await sleepMs(120)
|
||||
await port.open(options)
|
||||
}
|
||||
}
|
||||
|
||||
export type FlashPhase = "connect" | "detect" | "write"
|
||||
|
||||
export type WriteProgressPayload = {
|
||||
imageIndex: number
|
||||
@@ -35,7 +97,7 @@ export async function runEspFlash(options: {
|
||||
baud: number
|
||||
eraseAll: boolean
|
||||
terminal?: EspTerminal
|
||||
resetMode?: 'default_reset' | 'no_reset'
|
||||
resetMode?: "default_reset" | "no_reset"
|
||||
onPhase?: (phase: FlashPhase) => void
|
||||
onWriteProgress?: (p: WriteProgressPayload) => void
|
||||
}): Promise<void> {
|
||||
@@ -45,14 +107,16 @@ export async function runEspFlash(options: {
|
||||
baud,
|
||||
eraseAll,
|
||||
terminal = noopEspTerminal,
|
||||
resetMode = 'default_reset',
|
||||
resetMode = "default_reset",
|
||||
onPhase,
|
||||
onWriteProgress,
|
||||
} = options
|
||||
if (!('serial' in navigator)) {
|
||||
throw new Error('Web Serial is not available (use Chromium on https:// or localhost)')
|
||||
if (!("serial" in navigator)) {
|
||||
throw new Error("Web Serial is not available (use Chromium on https:// or localhost)")
|
||||
}
|
||||
|
||||
await ensureSerialPortClosed(port)
|
||||
|
||||
const transport = new Transport(port)
|
||||
const loader = new ESPLoader({
|
||||
transport,
|
||||
@@ -64,24 +128,23 @@ export async function runEspFlash(options: {
|
||||
const lengths = fileArray.map(f => f.data.byteLength)
|
||||
const totalBytes = lengths.reduce((a, b) => a + b, 0)
|
||||
|
||||
onPhase?.('connect')
|
||||
onPhase?.("connect")
|
||||
await loader.main(resetMode)
|
||||
onPhase?.('detect')
|
||||
onPhase?.("detect")
|
||||
const flashSize = (await loader.detectFlashSize()) as FlashSizeValues
|
||||
|
||||
onPhase?.('write')
|
||||
onPhase?.("write")
|
||||
await loader.writeFlash({
|
||||
fileArray,
|
||||
flashMode: 'dio',
|
||||
flashFreq: '40m',
|
||||
flashMode: "dio",
|
||||
flashFreq: "40m",
|
||||
flashSize,
|
||||
eraseAll,
|
||||
compress: true,
|
||||
reportProgress: (i, written, total) => {
|
||||
let offset = 0
|
||||
for (let j = 0; j < i; j++) offset += lengths[j] ?? 0
|
||||
const overallPct =
|
||||
totalBytes > 0 ? Math.min(100, Math.round((100 * (offset + written)) / totalBytes)) : 0
|
||||
const overallPct = totalBytes > 0 ? Math.min(100, Math.round((100 * (offset + written)) / totalBytes)) : 0
|
||||
onWriteProgress?.({
|
||||
imageIndex: i,
|
||||
imageCount: fileArray.length,
|
||||
@@ -92,24 +155,26 @@ export async function runEspFlash(options: {
|
||||
},
|
||||
})
|
||||
|
||||
await loader.after('hard_reset')
|
||||
await loader.after("hard_reset")
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
/** Classic ESP32/S3 USB CDC bootloader entry: open port at 1200 baud briefly. */
|
||||
/** Match MeshCore `lib/dfu.js` CDC touch timing (1200 baud → close → wait for re-enumeration). */
|
||||
const CDC_TOUCH_OPEN_MS = 100
|
||||
const CDC_TOUCH_AFTER_CLOSE_MS = 1500
|
||||
|
||||
/** Classic ESP32/S3 (and many nRF CDC) USB bootloader entry: open port at 1200 baud, then close and wait. */
|
||||
export async function pulseUsbBootloaderPort(): Promise<void> {
|
||||
if (!('serial' in navigator)) {
|
||||
throw new Error('Web Serial is not available')
|
||||
if (!("serial" in navigator)) {
|
||||
throw new Error("Web Serial is not available")
|
||||
}
|
||||
const port = await navigator.serial.requestPort()
|
||||
await ensureSerialPortClosed(port)
|
||||
try {
|
||||
await port.open({ baudRate: 1200 })
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 200))
|
||||
await openSerialPortWithRecovery(port, { baudRate: 1200 })
|
||||
await sleepMs(CDC_TOUCH_OPEN_MS)
|
||||
} finally {
|
||||
try {
|
||||
await port.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await ensureSerialPortClosed(port)
|
||||
}
|
||||
await sleepMs(CDC_TOUCH_AFTER_CLOSE_MS)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FlashManifest, FlashTargetFamily } from "./untarGz"
|
||||
|
||||
const KNOWN: readonly FlashTargetFamily[] = ["esp32", "esp8266", "nrf52", "rp2040", "unknown"]
|
||||
|
||||
function coerceTargetFamily(v: unknown): FlashTargetFamily | undefined {
|
||||
if (typeof v !== "string") return undefined
|
||||
return KNOWN.includes(v as FlashTargetFamily) ? (v as FlashTargetFamily) : undefined
|
||||
}
|
||||
|
||||
/** Heuristic when manifest lacks targetFamily (older bundles). */
|
||||
export function inferTargetFamilyFromEnv(env: string | null | undefined): FlashTargetFamily | null {
|
||||
if (!env?.trim()) return null
|
||||
const e = env.toLowerCase()
|
||||
if (/nrf|rak4631|rak2560|t-echo|wio|tracker-t|canaryone|meshlink|mesh-tab|wm1110|sensecap|nrf52840|nrf52/i.test(e)) {
|
||||
return "nrf52"
|
||||
}
|
||||
if (/esp8266|8266|d1_mini|nodemcu/i.test(e)) {
|
||||
return "esp8266"
|
||||
}
|
||||
if (/rp2040|pico|challenger_2040|picow|rak11310/i.test(e)) {
|
||||
return "rp2040"
|
||||
}
|
||||
if (/esp32|tlora|tbeam|heltec|challenger|station|s3|c3|hru|ebyte|nibble|nano-g1|radiomaster|m5stack|ai-c3/i.test(e)) {
|
||||
return "esp32"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer manifest.targetFamily from CI; else env-name heuristic; else esp32 for legacy ESP-only bundles.
|
||||
*/
|
||||
export function resolveFlashTargetFamily(
|
||||
manifest: FlashManifest | null,
|
||||
targetEnv: string | null | undefined
|
||||
): FlashTargetFamily {
|
||||
const fromManifest = coerceTargetFamily(manifest?.targetFamily)
|
||||
if (fromManifest && fromManifest !== "unknown") {
|
||||
return fromManifest
|
||||
}
|
||||
const fromEnv = inferTargetFamilyFromEnv(targetEnv ?? null)
|
||||
if (fromEnv) {
|
||||
return fromEnv
|
||||
}
|
||||
if (fromManifest === "unknown") {
|
||||
return "unknown"
|
||||
}
|
||||
return "esp32"
|
||||
}
|
||||
+18
-8
@@ -1,7 +1,7 @@
|
||||
import pako from 'pako'
|
||||
import pako from "pako"
|
||||
|
||||
function basenameKey(path: string): string {
|
||||
const parts = path.replace(/^\.\//, '').split('/')
|
||||
const parts = path.replace(/^\.\//, "").split("/")
|
||||
return parts[parts.length - 1] ?? path
|
||||
}
|
||||
|
||||
@@ -16,18 +16,18 @@ export function extractTarGz(gz: Uint8Array): Map<string, Uint8Array> {
|
||||
const header = tar.subarray(off, off + 512)
|
||||
off += 512
|
||||
|
||||
const name = dec.decode(header.subarray(0, 100)).split('\0')[0].trim()
|
||||
const name = dec.decode(header.subarray(0, 100)).split("\0")[0].trim()
|
||||
if (!name) break
|
||||
|
||||
const typeflag = dec.decode(header.subarray(156, 157))
|
||||
const sizeField = dec.decode(header.subarray(124, 136)).split('\0')[0].trim()
|
||||
const sizeField = dec.decode(header.subarray(124, 136)).split("\0")[0].trim()
|
||||
const size = parseInt(sizeField, 8) || 0
|
||||
const prefix = dec.decode(header.subarray(345, 500)).split('\0')[0].trim()
|
||||
const path = (prefix ? `${prefix}/${name}` : name).replace(/^\.\//, '')
|
||||
const prefix = dec.decode(header.subarray(345, 500)).split("\0")[0].trim()
|
||||
const path = (prefix ? `${prefix}/${name}` : name).replace(/^\.\//, "")
|
||||
|
||||
const pad = (512 - (size % 512)) % 512
|
||||
|
||||
if (typeflag === '0' || typeflag === '\0' || typeflag === '') {
|
||||
if (typeflag === "0" || typeflag === "\0" || typeflag === "") {
|
||||
out.set(path, new Uint8Array(tar.subarray(off, off + size)))
|
||||
}
|
||||
|
||||
@@ -53,7 +53,17 @@ export type FlashManifestImage = {
|
||||
role?: string
|
||||
}
|
||||
|
||||
export type FlashManifest = { images: FlashManifestImage[] }
|
||||
/** Coarse MCU family for USB flasher entry + tool selection (from CI / PlatformIO). */
|
||||
export type FlashTargetFamily = "esp32" | "esp8266" | "nrf52" | "rp2040" | "unknown"
|
||||
|
||||
export type FlashManifest = {
|
||||
images: FlashManifestImage[]
|
||||
targetFamily?: FlashTargetFamily
|
||||
/** Raw PlatformIO `platform` (debug / advanced UI). */
|
||||
platform?: string
|
||||
/** Raw PlatformIO `board` (debug / advanced UI). */
|
||||
board?: string
|
||||
}
|
||||
|
||||
export function parseFlashManifest(json: string): FlashManifest | null {
|
||||
try {
|
||||
|
||||
+139
-148
@@ -37,10 +37,11 @@ export default function RepoPage() {
|
||||
const treePath = params["*"]
|
||||
const owner = useMemo(() => decodeURIComponent(ownerParam), [ownerParam])
|
||||
const repo = useMemo(() => decodeURIComponent(repoParam), [repoParam])
|
||||
const { sourceRef, targetEnv: targetFromUrl, flash: flashFromUrl } = useMemo(
|
||||
() => parseTreeSplat(treePath),
|
||||
[treePath]
|
||||
)
|
||||
const {
|
||||
sourceRef,
|
||||
targetEnv: targetFromUrl,
|
||||
flash: flashFromUrl,
|
||||
} = useMemo(() => parseTreeSplat(treePath), [treePath])
|
||||
const isFlashView = flashFromUrl
|
||||
const hasRef = Boolean(sourceRef)
|
||||
|
||||
@@ -412,9 +413,7 @@ export default function RepoPage() {
|
||||
{tagData?.isStale ? <span>Tag list may be stale.</span> : null}
|
||||
{refError ? <span className="text-red-400">{refError}</span> : null}
|
||||
{!refError && hasRef && !resolvedSha ? <span>Resolving tag…</span> : null}
|
||||
{resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? (
|
||||
<span>Scanning PlatformIO…</span>
|
||||
) : null}
|
||||
{resolvedSha && (scan == null || scan.scanStatus === "in_progress") ? <span>Scanning PlatformIO…</span> : null}
|
||||
{resolvedSha && scan?.scanStatus === "failed" ? (
|
||||
<span className="text-red-300">Scan failed: {scan.scanError ?? "unknown"}</span>
|
||||
) : null}
|
||||
@@ -459,11 +458,7 @@ export default function RepoPage() {
|
||||
const total = build.ciProgressTotal
|
||||
const label = build.ciProgressLabel
|
||||
const hasSteps =
|
||||
typeof step === "number" &&
|
||||
typeof total === "number" &&
|
||||
total > 0 &&
|
||||
step >= 1 &&
|
||||
step <= total
|
||||
typeof step === "number" && typeof total === "number" && total > 0 && step >= 1 && step <= total
|
||||
const pct = hasSteps ? Math.min(100, Math.round((step / total) * 100)) : null
|
||||
return (
|
||||
<>
|
||||
@@ -507,9 +502,7 @@ export default function RepoPage() {
|
||||
<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(-2500)}`
|
||||
: build.errorSummary}
|
||||
{build.errorSummary.length > 2500 ? `…${build.errorSummary.slice(-2500)}` : build.errorSummary}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
@@ -534,6 +527,7 @@ export default function RepoPage() {
|
||||
{flashUrl ? (
|
||||
<EspFlasher
|
||||
bundleUrl={flashUrl}
|
||||
targetEnv={resolvedTargetEnv}
|
||||
condensed
|
||||
flashButtonLabel="USB flash"
|
||||
flashBusyLabel="Writing…"
|
||||
@@ -545,9 +539,7 @@ export default function RepoPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isFlashView ? "max-w-3xl" : "max-w-6xl"} mx-auto px-6 py-10 text-slate-200`}
|
||||
>
|
||||
<div className={`${isFlashView ? "max-w-3xl" : "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">
|
||||
{isFlashView ? (
|
||||
<div className="min-w-0 space-y-5">
|
||||
@@ -556,10 +548,7 @@ export default function RepoPage() {
|
||||
{owner}/{repo}@{effectiveRef}
|
||||
{resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher
|
||||
</h1>
|
||||
<Link
|
||||
to={backToRepoPath}
|
||||
className="inline-block text-sm text-cyan-400 hover:underline"
|
||||
>
|
||||
<Link to={backToRepoPath} className="inline-block text-sm text-cyan-400 hover:underline">
|
||||
← Repository
|
||||
</Link>
|
||||
</div>
|
||||
@@ -567,93 +556,122 @@ export default function RepoPage() {
|
||||
{ciAndFlasherEl}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_17.5rem] lg:gap-10 items-start
|
||||
<div
|
||||
className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_17.5rem] lg:gap-10 items-start
|
||||
[grid-template-areas:'repo-main''repo-aside''repo-readme']
|
||||
lg:[grid-template-areas:'repo-main_repo-aside''repo-readme_repo-aside']"
|
||||
>
|
||||
<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"
|
||||
layout="inline"
|
||||
id="mesh-forge-tag"
|
||||
options={tagOptions}
|
||||
value={tagDraft}
|
||||
placeholder="--tag--"
|
||||
clearSelectionLabel="Clear tag"
|
||||
onChange={v => {
|
||||
setTagDraft(v)
|
||||
if (v === "") {
|
||||
navigate(`/${ownerParam}/${repoParam}`)
|
||||
return
|
||||
}
|
||||
if (tagOptions.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`)
|
||||
}
|
||||
}}
|
||||
disabled={tagOptions.length === 0}
|
||||
/>
|
||||
{hasRef && scanReady && envNames.length > 0 ? (
|
||||
>
|
||||
<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="Target"
|
||||
label="Tag"
|
||||
layout="inline"
|
||||
id="mesh-forge-target"
|
||||
options={envNames}
|
||||
value={envDraft}
|
||||
placeholder="--target--"
|
||||
clearSelectionLabel="Clear target"
|
||||
id="mesh-forge-tag"
|
||||
options={tagOptions}
|
||||
value={tagDraft}
|
||||
placeholder="--tag--"
|
||||
clearSelectionLabel="Clear tag"
|
||||
onChange={v => {
|
||||
setEnvDraft(v)
|
||||
if (!sourceRef) return
|
||||
setTagDraft(v)
|
||||
if (v === "") {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, null)}`, {
|
||||
replace: true,
|
||||
})
|
||||
navigate(`/${ownerParam}/${repoParam}`)
|
||||
return
|
||||
}
|
||||
if (envNames.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`)
|
||||
if (tagOptions.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`)
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
disabled={tagOptions.length === 0}
|
||||
/>
|
||||
) : (
|
||||
<label className="flex min-w-0 max-w-[min(100%,18rem)] flex-1 items-center gap-2 sm:max-w-[20rem]">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-slate-500 sm:w-16">Target</span>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
disabled={!hasRef}
|
||||
value=""
|
||||
placeholder={targetPlaceholder}
|
||||
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"
|
||||
{hasRef && 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 (!sourceRef) return
|
||||
if (v === "") {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, null)}`, {
|
||||
replace: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (envNames.includes(v)) {
|
||||
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`)
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
) : (
|
||||
<label className="flex min-w-0 max-w-[min(100%,18rem)] flex-1 items-center gap-2 sm:max-w-[20rem]">
|
||||
<span className="w-14 shrink-0 text-xs font-medium text-slate-500 sm:w-16">Target</span>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
disabled={!hasRef}
|
||||
value=""
|
||||
placeholder={targetPlaceholder}
|
||||
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>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-9 shrink-0 bg-amber-600 px-4 text-white hover:bg-amber-700"
|
||||
disabled={flashPrimaryDisabled}
|
||||
onClick={queueFlashArtifacts}
|
||||
>
|
||||
{flashButtonLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-9 shrink-0 bg-amber-600 px-4 text-white hover:bg-amber-700"
|
||||
disabled={flashPrimaryDisabled}
|
||||
onClick={queueFlashArtifacts}
|
||||
>
|
||||
{flashButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{statusStripEl}
|
||||
</div>
|
||||
|
||||
{statusStripEl}
|
||||
</div>
|
||||
|
||||
<aside className="[grid-area:repo-aside] 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>
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 text-xl font-bold text-white leading-tight">
|
||||
<a className="hover:text-cyan-400" href={ghRepoRoot} target="_blank" rel="noreferrer">
|
||||
{repo}
|
||||
</a>
|
||||
{!ghAboutHomepage ? (
|
||||
<aside className="[grid-area:repo-aside] 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>
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 text-xl font-bold text-white leading-tight">
|
||||
<a className="hover:text-cyan-400" href={ghRepoRoot} target="_blank" rel="noreferrer">
|
||||
{repo}
|
||||
</a>
|
||||
{!ghAboutHomepage ? (
|
||||
<a
|
||||
className="inline-flex rounded p-0.5 text-slate-500 hover:text-white"
|
||||
href={ghTree}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
>
|
||||
<Github className="size-4" aria-hidden />
|
||||
<span className="sr-only">
|
||||
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
</span>
|
||||
</a>
|
||||
) : null}
|
||||
</h3>
|
||||
</div>
|
||||
{ghAboutDescription ? (
|
||||
<p className="text-sm text-slate-200 leading-relaxed">{ghAboutDescription}</p>
|
||||
) : null}
|
||||
{ghAboutHomepage ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-cyan-400 hover:underline"
|
||||
href={homepageHref(ghAboutHomepage)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Link2 className="size-3.5 shrink-0 text-slate-400" aria-hidden />
|
||||
{homepageLabel(ghAboutHomepage)}
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex rounded p-0.5 text-slate-500 hover:text-white"
|
||||
href={ghTree}
|
||||
@@ -666,64 +684,37 @@ export default function RepoPage() {
|
||||
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
</span>
|
||||
</a>
|
||||
) : null}
|
||||
</h3>
|
||||
</div>
|
||||
{ghAboutDescription ? <p className="text-sm text-slate-200 leading-relaxed">{ghAboutDescription}</p> : null}
|
||||
{ghAboutHomepage ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-cyan-400 hover:underline"
|
||||
href={homepageHref(ghAboutHomepage)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
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)))}
|
||||
>
|
||||
<Link2 className="size-3.5 shrink-0 text-slate-400" aria-hidden />
|
||||
{homepageLabel(ghAboutHomepage)}
|
||||
</a>
|
||||
<a
|
||||
className="inline-flex rounded p-0.5 text-slate-500 hover:text-white"
|
||||
href={ghTree}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
>
|
||||
<Github className="size-4" aria-hidden />
|
||||
<span className="sr-only">
|
||||
{effectiveRef ? `View ${effectiveRef} on GitHub` : "View repository on GitHub"}
|
||||
</span>
|
||||
</a>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Refresh tags
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
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)))}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Refresh tags
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
|
||||
<div className="[grid-area:repo-readme] prose prose-invert prose-sm max-w-none prose-hr:my-6">
|
||||
{readmeMd === null ? (
|
||||
<p className="text-slate-500 not-prose">Loading…</p>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={readmeMarkdownComponents}
|
||||
>
|
||||
{readmeMd || "*No README.*"}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
<div className="[grid-area:repo-readme] prose prose-invert prose-sm max-w-none prose-hr:my-6">
|
||||
{readmeMd === null ? (
|
||||
<p className="text-slate-500 not-prose">Loading…</p>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={readmeMarkdownComponents}
|
||||
>
|
||||
{readmeMd || "*No README.*"}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user