ui: migrate Flasher.js off Semantic UI React

Replace semantic-ui-react/Theme.js imports with the token-driven ui/
library: Tab -> Tabs, Segment -> Panel, Message/Label/Progress/Loader ->
their ui equivalents, Table.* compound API, Confirm-free flows preserved
exactly. Remove obsolete ThemeContext i/s/t spreads. Drop Flasher.js from
semantic-baseline.json now that it no longer imports Semantic UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-30 13:13:39 -06:00
parent 7de1bead98
commit fa1715523e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 415 additions and 404 deletions

View file

@ -1,27 +1,30 @@
import React, {useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {Route, Routes} from "react-router";
import {Checkbox, Label, Message, Select, Button as SButton, Icon as SIcon} from "semantic-ui-react";
import {ESPLoader, Transport} from "esptool-js";
import {useDropzone} from "react-dropzone";
import _ from "lodash";
import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common";
import {
Button,
darkTheme,
Checkbox,
Divider,
Form,
Group,
Header,
Icon,
List,
Loader,
IconButton,
Label,
Loading,
Message,
Modal,
Panel,
Progress,
Segment,
Tab,
Select,
Stack,
Table,
TextArea
} from "./Theme";
import {ThemeContext} from "../contexts/contexts";
Tabs,
Textarea,
TextInput,
} from "./ui";
import {deleteFlasherConfig, flasherSearch, getFlasherConfigs, saveFlasherConfig} from "../api";
// The suffix used to find flashable ESP32 firmware in the media directory.
@ -50,6 +53,10 @@ const KNOWN_ESP_CHIPS = [
// colors when they render at the same width. The first eight cover today's largest same-width group (the eight
// 8-character ESP32-* names); the extras let a same-width group grow past eight before colors would repeat.
// (Truly colorblind-distinct qualitative colors top out around ten, so beyond that distinctness is best-effort.)
//
// These are deliberately NOT theme tokens: this is a fixed categorical palette used to visually tell many ESP
// chip families apart, the same way a chart's series colors stay fixed regardless of theme. See the report to
// the user for the resulting night-mode tension (non-red hues on these chips, unlike the rest of the UI).
const CHIP_COLORS = [
'#4477aa', '#ee6677', '#228833', '#ccbb44', '#66ccee', '#aa3377', '#bbbbbb', '#000000',
'#ee7733', '#332288', '#999933', '#882255',
@ -115,10 +122,10 @@ export function espImageChipId(header) {
// bundled into the frontend build, so this works fully offline once the page has loaded.
const BAUD_OPTIONS = [
{key: '115200', value: 115200, text: '115200 (safe)'},
{key: '230400', value: 230400, text: '230400'},
{key: '460800', value: 460800, text: '460800'},
{key: '921600', value: 921600, text: '921600 (fast)'},
{value: 115200, text: '115200 (safe)'},
{value: 230400, text: '230400'},
{value: 460800, text: '460800'},
{value: 921600, text: '921600 (fast)'},
];
// Web Serial is only available in a secure context (HTTPS or localhost) on Chromium-based browsers.
@ -218,10 +225,10 @@ export function withTimeout(promise, ms, message) {
// How long to wait for a device to connect before giving up (esptool-js retries can otherwise stall the UI).
const CONNECT_TIMEOUT_MS = 30_000;
// A read-only log TextArea that auto-scrolls to the newest output — but only when the user is already at the
// A read-only log Textarea that auto-scrolls to the newest output — but only when the user is already at the
// bottom, so scrolling up to read earlier lines isn't yanked back down. Starts at the bottom on mount (e.g. when
// a modal opens).
function AutoScrollLog({value, ...props}) {
function AutoScrollLog({value, style, ...props}) {
const containerRef = useRef(null);
const atBottomRef = useRef(true);
@ -241,13 +248,18 @@ function AutoScrollLog({value, ...props}) {
}, [value]);
return <div ref={containerRef}>
<TextArea readOnly value={value} onScroll={handleScroll} {...props}/>
<Textarea readOnly value={value} onScroll={handleScroll} styles={{input: style}} {...props}/>
</div>;
}
// Tab keys for the firmware-source picker, in display order: saved sets first, then the media picker, then a
// local file.
const TAB_SAVED = 'saved';
const TAB_WROLPI = 'wrolpi';
const TAB_COMPUTER = 'computer';
export function FlasherPage() {
useTitle('Flasher');
const {t, theme} = useContext(ThemeContext);
// Each entry is {name, size, address, file?: File, mediaPath?: string}. A local file has `file`; a media
// firmware has `mediaPath`. Default offset 0x0 suits a single merged firmware image.
@ -283,7 +295,7 @@ export function FlasherPage() {
const [flashError, setFlashError] = useState('');
// Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer").
const [activeTab, setActiveTab] = useState(0);
const [activeTab, setActiveTab] = useState(TAB_SAVED);
// Saved firmware configurations (flasher.yaml).
const [savedConfigs, setSavedConfigs] = useState([]);
@ -331,15 +343,14 @@ export function FlasherPage() {
}
};
// The "Add from computer" tab (index 2 in firmwarePanes) hosts the dropzone; a drop anywhere on the page or a
// click on the zone lands here.
const COMPUTER_TAB_INDEX = 2;
// The "Add from computer" tab hosts the dropzone; a drop anywhere on the page or a click on the zone lands
// here.
const onDrop = React.useCallback((droppedFiles) => {
// Only take .bin firmware; anything else dropped is ignored.
const bins = (droppedFiles || []).filter(file => file.name.toLowerCase().endsWith(FIRMWARE_SUFFIX));
if (bins.length > 0) {
addFiles(bins);
setActiveTab(COMPUTER_TAB_INDEX);
setActiveTab(TAB_COMPUTER);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// The dropzone root wraps the whole page so firmware can be dropped anywhere. noClick/noKeyboard because the
@ -354,7 +365,7 @@ export function FlasherPage() {
// While the user drags a file over the page, jump to the "Add from computer" tab so the drop target is shown.
useEffect(() => {
if (isDragActive) {
setActiveTab(COMPUTER_TAB_INDEX);
setActiveTab(TAB_COMPUTER);
}
}, [isDragActive]);
@ -644,15 +655,11 @@ export function FlasherPage() {
if (!webSerialSupported()) {
return <PageContainer>
<Header as='h1'>Flasher</Header>
<Message icon warning>
<SIcon name='warning sign'/>
<Message.Content>
<Message.Header>Web Serial is not available</Message.Header>
<p>Flashing requires the Web Serial API, which is only available in Chromium-based browsers
(Chrome, Edge, Brave) served over a secure connection (HTTPS or <code>localhost</code>).</p>
<p>Open this page in Chrome or Edge over <code>https://</code> to flash your device. Firefox
and iOS are not supported.</p>
</Message.Content>
<Message kind='warning' icon='warning sign' title='Web Serial is not available'>
<p>Flashing requires the Web Serial API, which is only available in Chromium-based browsers
(Chrome, Edge, Brave) served over a secure connection (HTTPS or <code>localhost</code>).</p>
<p>Open this page in Chrome or Edge over <code>https://</code> to flash your device. Firefox
and iOS are not supported.</p>
</Message>
</PageContainer>;
}
@ -664,409 +671,414 @@ export function FlasherPage() {
const totalFlashBytes = files.reduce((sum, item) => sum + (item.size || 0), 0);
const flashEtaText = humanDuration(estimateFlashSeconds(totalFlashBytes, baudrate));
// The nested tab menu isn't inverted automatically; give it the inverted style in dark mode so it matches.
const tabMenu = theme === darkTheme ? {inverted: true, attached: true} : {attached: true};
// A connect failure needs a download-mode reminder shown right where the user acted — both in the Connect
// segment and next to the "Filter files by detecting device" button so it isn't missed off-screen.
// panel and next to the "Filter files by detecting device" button so it isn't missed off-screen.
const connectErrorMessage = () => (error && bootHint) &&
<Message error onDismiss={() => {
setError('');
setBootHint(false);
}} style={{marginTop: '1em'}}>
<Message.Header>Could not connect to the device</Message.Header>
<p>{error}</p>
<p>
Many ESP boards must be put into <b>download mode</b> manually before flashing. Hold the
{' '}<b>BOOT</b> (or <b>IO0</b>) button, briefly press <b>RESET</b> (<b>EN</b>), then release
BOOT and try again. Also check the USB cable is a data cable and no other tab has the port open.
</p>
</Message>;
<div style={{marginTop: '1em'}}>
<Message
kind='error'
title='Could not connect to the device'
onDismiss={() => {
setError('');
setBootHint(false);
}}
>
<p>{error}</p>
<p>
Many ESP boards must be put into <b>download mode</b> manually before flashing. Hold the
{' '}<b>BOOT</b> (or <b>IO0</b>) button, briefly press <b>RESET</b> (<b>EN</b>), then release
BOOT and try again. Also check the USB cable is a data cable and no other tab has the port open.
</p>
</Message>
</div>;
// The firmware source is chosen via three tabs (see the ordered array below).
const computerPane = {
menuItem: 'Add from computer',
render: () => <Tab.Pane>
{/* Clickable drop target (files may also be dropped anywhere on the page, which routes here). */}
<Segment
placeholder
onClick={busy ? undefined : open}
// The firmware source is chosen via three tabs, in this order: saved sets, the media picker, then a local
// file.
const computerPaneContent = <>
{/* Clickable drop target (files may also be dropped anywhere on the page, which routes here). */}
<Panel
onClick={busy ? undefined : open}
style={{
cursor: busy ? 'default' : 'pointer',
textAlign: 'center',
...(isDragActive ? {borderColor: 'var(--blue)', borderWidth: 2} : {}),
}}
>
<Header as='h4' icon='microchip'>
{isDragActive
? <>Drop <code>.bin</code> firmware to add it</>
: <>Click here, or drop <code>.bin</code> firmware here</>}
</Header>
</Panel>
<p style={{marginTop: '1em', opacity: 0.8}}>
A single merged image is flashed at offset <code>0x0</code>. For multi-part firmware, add each
part and set its offset (e.g. bootloader <code>0x1000</code>, partitions <code>0x8000</code>,
app <code>0x10000</code>).
</p>
</>;
const wrolpiPaneContent = <>
<p style={{opacity: 0.8}}>
Not sure which file you need? Plug in your device and let WROLPi show only the firmware that
matches it.
</p>
{/* Always available so a user who swaps to a different ESP device can re-detect it. */}
<Button
icon='usb'
loading={connecting}
disabled={busy}
onClick={handleFilterByDevice}
>
{deviceChip ? 'Detect a different device' : 'Filter files by detecting device'}
</Button>
{/* Show a connect failure here too, so the user doesn't have to scroll to the Connect panel. */}
{connectErrorMessage()}
{deviceChip &&
<Message kind='info' icon='microchip'>
Showing firmware for <b>{deviceChip}</b>.
<Button role='cancel' size='xs' onClick={() => applyDeviceChip(null)}
disabled={busy} style={{marginLeft: '1em'}}>
Show all firmware
</Button>
</Message>}
<TextInput
style={{marginTop: '1em'}}
leftSection={<Icon name='search'/>}
placeholder='Filter firmware by path (e.g. a folder name)...'
value={mediaFilter}
disabled={busy}
onChange={(e) => handleMediaFilterChange(e.currentTarget.value)}
/>
<div style={{marginTop: '1em', overflowX: 'auto'}}>
{mediaLoading
? <Loading/>
: (mediaResults.length === 0
? <p style={{opacity: 0.7}}>
No <code>.bin</code> firmware found
{deviceChip ? ` for ${deviceChip}` : ' in your media directory'}
{mediaFilter ? ' matching that filter.' : '.'}
</p>
: <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Path</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Chip</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell style={{width: '1%'}}/>
</Table.Row>
</Table.Header>
<Table.Body>
{mediaResults.map(result => {
const name = result.name || (result.primary_path || '').split('/').pop();
// esp_kind is 'not_esp_image' for parts with no chip (partitions, littlefs).
const kind = result.esp_kind && result.esp_kind !== 'not_esp_image'
? result.esp_kind : null;
return <Table.Row key={result.primary_path}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell style={{opacity: 0.7, wordBreak: 'break-all'}}>
{result.primary_path}
</Table.Cell>
<Table.Cell>{humanFileSize(result.size)}</Table.Cell>
<Table.Cell>
{result.esp_chip
? <span className='wrolpi-label' style={{
'--label-color': chipColor(result.esp_chip),
color: chipTextColor(chipColor(result.esp_chip)),
}}>{result.esp_chip}</span>
: <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell>
{kind || <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}}>
<IconButton icon='plus' label='Add to selected firmware' role='primary'
size='xs' disabled={busy}
onClick={() => handleAddMediaFile(result)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>)}
</div>
</>;
const savedPaneContent = <>
<p style={{opacity: 0.8}}>
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs.length > 0
? <Panel style={{padding: 0}}>
{savedConfigs.map((configuration, index) => <div
key={configuration.name}
style={{
cursor: busy ? 'default' : 'pointer',
textAlign: 'center',
...(isDragActive ? {outline: '2px dashed #2185d0', outlineOffset: '-2px'} : {}),
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '10px 12px',
borderBottom: index === savedConfigs.length - 1 ? undefined : '1px solid var(--border)',
}}
>
<Header icon {...t}>
<Icon name='microchip'/>
{isDragActive
? <>Drop <code>.bin</code> firmware to add it</>
: <>Click here, or drop <code>.bin</code> firmware here</>}
</Header>
</Segment>
<p {...t} style={{marginTop: '1em', opacity: 0.8}}>
A single merged image is flashed at offset <code>0x0</code>. For multi-part firmware, add each
part and set its offset (e.g. bootloader <code>0x1000</code>, partitions <code>0x8000</code>,
app <code>0x10000</code>).
</p>
</Tab.Pane>,
};
const wrolpiPane = {
menuItem: 'Choose from your WROLPi',
render: () => <Tab.Pane>
<p {...t} style={{opacity: 0.8}}>
Not sure which file you need? Plug in your device and let WROLPi show only the firmware that
matches it.
</p>
{/* Always available so a user who swaps to a different ESP device can re-detect it. */}
<Button
icon labelPosition='left'
loading={connecting}
disabled={busy}
onClick={handleFilterByDevice}
>
<Icon name='usb'/>
{deviceChip ? 'Detect a different device' : 'Filter files by detecting device'}
</Button>
{/* Show a connect failure here too, so the user doesn't have to scroll to the Connect segment. */}
{connectErrorMessage()}
{deviceChip &&
<Message info>
{/* Plain (non-inverted) icon: Message backgrounds are light, so a themed white icon
vanishes. */}
<SIcon name='microchip'/>
Showing firmware for <b>{deviceChip}</b>.
<SButton size='tiny' compact onClick={() => applyDeviceChip(null)}
disabled={busy} style={{marginLeft: '1em'}}>
Show all firmware
</SButton>
</Message>}
<Form style={{marginTop: '1em'}}>
<Form.Input
fluid
icon='search'
placeholder='Filter firmware by path (e.g. a folder name)...'
value={mediaFilter}
disabled={busy}
onChange={(e, {value}) => handleMediaFilterChange(value)}
/>
</Form>
<div style={{marginTop: '1em', overflowX: 'auto'}}>
{mediaLoading
? <Loader active inline='centered'/>
: (mediaResults.length === 0
? <p {...t} style={{opacity: 0.7}}>
No <code>.bin</code> firmware found
{deviceChip ? ` for ${deviceChip}` : ' in your media directory'}
{mediaFilter ? ' matching that filter.' : '.'}
</p>
: <Table compact selectable unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell>Path</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Chip</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell width={1}/>
</Table.Row>
</Table.Header>
<Table.Body>
{mediaResults.map(result => {
const name = result.name || (result.primary_path || '').split('/').pop();
// esp_kind is 'not_esp_image' for parts with no chip (partitions, littlefs).
const kind = result.esp_kind && result.esp_kind !== 'not_esp_image'
? result.esp_kind : null;
return <Table.Row key={result.primary_path}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell {...t} style={{opacity: 0.7, wordBreak: 'break-all'}}>
{result.primary_path}
</Table.Cell>
<Table.Cell>{humanFileSize(result.size)}</Table.Cell>
<Table.Cell>
{result.esp_chip
? <Label size='tiny' style={{
backgroundColor: chipColor(result.esp_chip),
color: chipTextColor(chipColor(result.esp_chip)),
borderColor: 'transparent',
}}>{result.esp_chip}</Label>
: <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell>
{kind || <span style={{opacity: 0.5}}>—</span>}
</Table.Cell>
<Table.Cell textAlign='center'>
<Button icon='plus' size='mini' primary disabled={busy}
title='Add to selected firmware'
onClick={() => handleAddMediaFile(result)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>)}
</div>
</Tab.Pane>,
};
const savedPane = {
menuItem: 'Saved Firmwares',
render: () => <Tab.Pane>
<p {...t} style={{opacity: 0.8}}>
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs.length > 0
? <List divided relaxed selection>
{savedConfigs.map(configuration => <List.Item key={configuration.name}>
<List.Content floated='right'>
<Button icon='trash' size='mini' color='red' disabled={busy}
onClick={() => handleDeleteConfig(configuration.name)}/>
</List.Content>
<List.Icon name='save' verticalAlign='middle'/>
<List.Content onClick={busy ? undefined : () => handleLoadConfig(configuration)}
style={busy ? {} : {cursor: 'pointer'}}>
<List.Header>{configuration.name}</List.Header>
<List.Description {...t}>
{(configuration.files || []).length} file(s)
{configuration.erase_all ? ' — erases flash' : ''}
</List.Description>
</List.Content>
</List.Item>)}
</List>
: <p {...t} style={{opacity: 0.7}}>No saved configurations yet.</p>}
</Tab.Pane>,
};
// Tab order: saved sets first, then the media picker, then a local file.
const firmwarePanes = [savedPane, wrolpiPane, computerPane];
<div
onClick={busy ? undefined : () => handleLoadConfig(configuration)}
style={{
display: 'flex', alignItems: 'center', gap: 10, flex: 1,
cursor: busy ? 'default' : 'pointer',
}}
>
<Icon name='save'/>
<div>
<div style={{fontWeight: 600}}>{configuration.name}</div>
<div style={{fontSize: 12, opacity: 0.8}}>
{(configuration.files || []).length} file(s)
{configuration.erase_all ? ' — erases flash' : ''}
</div>
</div>
</div>
<IconButton icon='trash' label={`Delete ${configuration.name}`} role='danger' size='xs'
disabled={busy}
onClick={() => handleDeleteConfig(configuration.name)}/>
</div>)}
</Panel>
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>}
</>;
return <div {...getRootProps()}>
{/* The dropzone root wraps the whole page: firmware .bin files can be dropped anywhere and are routed to
the "Add from computer" tab. noClick is set, so this wrapper never opens the file dialog itself. */}
<input {...getInputProps()}/>
<PageContainer>
<Header as='h1'>Flasher</Header>
<p {...t}>
Flash firmware onto an ESP32/ESP8266 device directly from your browser over USB. Choose one or more
firmware <code>.bin</code> files &mdash; from your computer or from your WROLPi's media directory &mdash;
connect your device, then flash. The flash happens entirely in your browser and is written straight to
the device over USB.
</p>
<Header as='h1'>Flasher</Header>
<p>
Flash firmware onto an ESP32/ESP8266 device directly from your browser over USB. Choose one or
more firmware <code>.bin</code> files &mdash; from your computer or from your WROLPi's media
directory &mdash; connect your device, then flash. The flash happens entirely in your browser and
is written straight to the device over USB.
</p>
{/* Connection errors render inside the Connect segment (below), where the user just clicked. Other
errors show here at the top. */}
{error && !bootHint && <Message error onDismiss={() => setError('')}>
<Message.Header>Error</Message.Header>
<p>{error}</p>
</Message>}
{/* Connection errors render inside the Connect panel (below), where the user just clicked. Other
errors show here at the top. */}
{error && !bootHint && <Message kind='error' title='Error' onDismiss={() => setError('')}>
<p>{error}</p>
</Message>}
<Segment>
<Header as='h3'>1. Firmware files</Header>
<Tab
menu={tabMenu}
panes={firmwarePanes}
activeIndex={activeTab}
onTabChange={(e, {activeIndex}) => setActiveTab(activeIndex)}
/>
<Panel>
<Header as='h3'>1. Firmware files</Header>
<Tabs value={activeTab} onChange={setActiveTab} keepMounted={false}>
<Tabs.List>
<Tabs.Tab value={TAB_SAVED}>Saved Firmwares</Tabs.Tab>
<Tabs.Tab value={TAB_WROLPI}>Choose from your WROLPi</Tabs.Tab>
<Tabs.Tab value={TAB_COMPUTER}>Add from computer</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value={TAB_SAVED} pt='md'>{savedPaneContent}</Tabs.Panel>
<Tabs.Panel value={TAB_WROLPI} pt='md'>{wrolpiPaneContent}</Tabs.Panel>
<Tabs.Panel value={TAB_COMPUTER} pt='md'>{computerPaneContent}</Tabs.Panel>
</Tabs>
{/* The selected firmware is shown below the source tabs so it stays visible regardless of which tab
(computer / WROLPi / saved) the user is on. */}
{files.length > 0 && <>
<Divider/>
<Header as='h4'>Selected firmware</Header>
<Table compact unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell width={4}>Flash offset</Table.HeaderCell>
<Table.HeaderCell width={1}/>
</Table.Row>
</Table.Header>
<Table.Body>
{files.map((item, index) => {
// Flag (but never block) a file whose image targets a different chip than the device.
const fileChipId = fileChipIds[index];
const chipMismatch = deviceChipId !== null && fileChipId != null
&& fileChipId !== deviceChipId;
return <Table.Row key={`${item.name}-${index}`} warning={chipMismatch}>
<Table.Cell>
<Icon name={item.mediaPath ? 'database' : 'file'}/>
{item.name} <span style={{opacity: 0.6}}>({humanFileSize(item.size)})</span>
{chipMismatch &&
<Label color='red' size='tiny' style={{marginLeft: '0.5em'}}>
<SIcon name='warning sign'/>
Built for {chipIdName(fileChipId)}, not {chipIdName(deviceChipId)}
</Label>}
</Table.Cell>
<Table.Cell>
<Form.Input
fluid
size='small'
placeholder='0x10000'
value={item.address}
disabled={busy}
error={!isValidHexOffset(item.address)}
onChange={(e, {value}) => handleAddressChange(index, value)}
/>
</Table.Cell>
<Table.Cell textAlign='center'>
<Button icon='trash' size='mini' color='red' disabled={busy}
onClick={() => handleRemoveFile(index)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>
{/* Save lives here (not in the Saved Firmwares tab) so the current selection can be saved from
any source tab. */}
<Button icon labelPosition='left' disabled={busy || saveableFiles.length === 0}
onClick={() => setSaveModalOpen(true)}>
<Icon name='save'/>
Save current firmware
</Button>
</>}
</Segment>
{/* The selected firmware is shown below the source tabs so it stays visible regardless of which
tab (computer / WROLPi / saved) the user is on. */}
{files.length > 0 && <>
<Divider/>
<Header as='h4'>Selected firmware</Header>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>File</Table.HeaderCell>
<Table.HeaderCell style={{width: '30%'}}>Flash offset</Table.HeaderCell>
<Table.HeaderCell style={{width: '1%'}}/>
</Table.Row>
</Table.Header>
<Table.Body>
{files.map((item, index) => {
// Flag (but never block) a file whose image targets a different chip than the
// device.
const fileChipId = fileChipIds[index];
const chipMismatch = deviceChipId !== null && fileChipId != null
&& fileChipId !== deviceChipId;
return <Table.Row key={`${item.name}-${index}`}>
<Table.Cell>
<Icon name={item.mediaPath ? 'disk' : 'file'}/>
{' '}{item.name} <span style={{opacity: 0.6}}>
({humanFileSize(item.size)})
</span>
{chipMismatch &&
<span style={{marginLeft: '0.5em'}}>
<Label color='red' icon='warning sign'>
Built for {chipIdName(fileChipId)}, not{' '}
{chipIdName(deviceChipId)}
</Label>
</span>}
</Table.Cell>
<Table.Cell>
<TextInput
size='xs'
placeholder='0x10000'
value={item.address}
disabled={busy}
error={!isValidHexOffset(item.address)}
onChange={(e) => handleAddressChange(index, e.currentTarget.value)}
/>
</Table.Cell>
<Table.Cell style={{textAlign: 'center'}}>
<IconButton icon='trash' label={`Remove ${item.name}`} role='danger'
size='xs' disabled={busy}
onClick={() => handleRemoveFile(index)}/>
</Table.Cell>
</Table.Row>;
})}
</Table.Body>
</Table>
{/* Save lives here (not in the Saved Firmwares tab) so the current selection can be saved
from any source tab. */}
<Button icon='save' role='save' disabled={busy || saveableFiles.length === 0}
onClick={() => setSaveModalOpen(true)}>
Save current firmware
</Button>
</>}
</Panel>
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='tiny'>
<Modal.Header>Save firmware configuration</Modal.Header>
<Modal.Content>
<Form onSubmit={handleSaveConfig}>
<Form.Input
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='sm'>
<Modal.Header>Save firmware configuration</Modal.Header>
<Modal.Content>
<TextInput
autoFocus
label='Name'
placeholder='e.g. T-Deck Meshtastic UI'
value={saveName}
onChange={(e, {value}) => setSaveName(value)}
onChange={(e) => setSaveName(e.currentTarget.value)}
/>
<p {...t} style={{opacity: 0.8}}>
<p style={{opacity: 0.8, marginTop: '0.75em'}}>
Saving {saveableFiles.length} firmware file(s) from your WROLPi. An existing configuration
with the same name will be replaced.
</p>
</Form>
</Modal.Content>
<Modal.Actions>
<SButton onClick={() => setSaveModalOpen(false)}>Cancel</SButton>
<Button primary disabled={!saveName.trim()} onClick={handleSaveConfig}>Save</Button>
</Modal.Actions>
</Modal>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setSaveModalOpen(false)}>Cancel</Button>
<Button role='save' disabled={!saveName.trim()} onClick={handleSaveConfig}>Save</Button>
</Modal.Actions>
</Modal>
<Segment>
<Header as='h3'>2. Connect</Header>
<Form>
<Form.Field>
<label>Baud rate</label>
<Select
options={BAUD_OPTIONS}
value={baudrate}
disabled={connected || busy}
onChange={(e, {value}) => setBaudrate(value)}
/>
</Form.Field>
{connected &&
<Message positive>
<SIcon name='usb'/> Connected to <b>{chip}</b>
</Message>}
{connected
? <Button color='grey' disabled={busy} onClick={disconnect}>Disconnect</Button>
: <Button primary loading={connecting} disabled={busy} onClick={handleConnect}>
Connect Device
</Button>}
<Button icon labelPosition='left' disabled={!log} onClick={() => setLogOpen(true)}>
<Icon name='terminal'/> Logs
</Button>
</Form>
{/* Rendered outside <Form> so Semantic's ".ui.form .error.message { display:none }" rule doesn't
hide it, and here (not at the top) so it is visible right where the user clicked Connect. */}
{connectErrorMessage()}
</Segment>
<Panel>
<Header as='h3'>2. Connect</Header>
<Stack gap={12} style={{maxWidth: 320}}>
<div>
<div style={{marginBottom: 4, fontSize: 13, fontWeight: 500}}>Baud rate</div>
<Select
data={BAUD_OPTIONS.map(o => ({value: String(o.value), label: o.text}))}
value={String(baudrate)}
disabled={connected || busy}
allowDeselect={false}
onChange={(value) => value && setBaudrate(Number(value))}
/>
</div>
{connected &&
<Message kind='success' icon='usb'>Connected to <b>{chip}</b></Message>}
<Group>
{connected
? <Button role='cancel' disabled={busy} onClick={disconnect}>Disconnect</Button>
: <Button role='primary' loading={connecting} disabled={busy} onClick={handleConnect}>
Connect Device
</Button>}
<Button icon='terminal' disabled={!log} onClick={() => setLogOpen(true)}>
Logs
</Button>
</Group>
</Stack>
{/* Rendered outside the fields above, and here (not at the top) so it is visible right where
the user clicked Connect. */}
{connectErrorMessage()}
</Panel>
<Segment>
<Header as='h3'>3. Flash</Header>
<Form>
<Form.Field>
<Panel danger>
<Header as='h3'>3. Flash</Header>
<Stack gap={12}>
<Checkbox
label='Erase all flash before writing'
checked={eraseAll}
disabled={busy}
onChange={(e, {checked}) => setEraseAll(checked)}
onChange={(e) => setEraseAll(e.currentTarget.checked)}
/>
</Form.Field>
<Button
color='red'
loading={flashing}
disabled={!connected || flashing || files.length === 0 || !offsetsValid}
onClick={handleFlash}
>
<Icon name='lightning'/> Flash Device
</Button>
{offsetsValid && flashEtaText &&
<span {...t} style={{marginLeft: '1em', opacity: 0.8}}>
Estimated time: ~{flashEtaText} at {baudrate.toLocaleString()} baud
</span>}
{files.length > 0 && !offsetsValid &&
<p {...t} style={{marginTop: '0.5em', opacity: 0.8}}>
Every firmware file needs a valid hex flash offset (e.g. <code>0x0</code> or{' '}
<code>0x10000</code>).
</p>}
</Form>
</Segment>
<div>
<Button
role='danger'
icon='lightning'
loading={flashing}
disabled={!connected || flashing || files.length === 0 || !offsetsValid}
onClick={handleFlash}
>
Flash Device
</Button>
{offsetsValid && flashEtaText &&
<span style={{marginLeft: '1em', opacity: 0.8}}>
Estimated time: ~{flashEtaText} at {baudrate.toLocaleString()} baud
</span>}
</div>
{files.length > 0 && !offsetsValid &&
<p style={{opacity: 0.8}}>
Every firmware file needs a valid hex flash offset (e.g. <code>0x0</code> or{' '}
<code>0x10000</code>).
</p>}
</Stack>
</Panel>
<Modal
open={flashOpen}
onClose={() => !flashing && setFlashOpen(false)}
closeOnDimmerClick={!flashing}
size='fullscreen'
>
<Modal.Header>{flashing ? 'Flashing device…' : (flashError ? 'Flash failed' : 'Flash complete')}</Modal.Header>
<Modal.Content scrolling>
{progress !== null &&
<Progress percent={progress.percent} progress indicating={flashing}
error={!!flashError} autoSuccess={!flashError}>
{flashing ? `Writing file ${progress.fileIndex + 1} of ${files.length}`
: (flashError ? 'Failed' : 'Done')}
</Progress>}
{flashing && flashEtaText &&
<p {...t} style={{opacity: 0.8}}>
Estimated total time: ~{flashEtaText} at {baudrate.toLocaleString()} baud. Keep this tab
open and do not unplug the device.
</p>}
{/* Surface a failure right here — otherwise a failed flash looks identical to a frozen one. */}
{flashError &&
<Message error>
<SIcon name='warning circle'/>
<Message.Content>
<Message.Header>Flashing failed</Message.Header>
<Modal
open={flashOpen}
onClose={() => !flashing && setFlashOpen(false)}
closeOnClickOutside={!flashing}
closeOnEscape={!flashing}
size='fullscreen'
>
<Modal.Header>
{flashing ? 'Flashing device…' : (flashError ? 'Flash failed' : 'Flash complete')}
</Modal.Header>
<Modal.Content>
{progress !== null &&
<Progress
percent={progress.percent}
color={flashError ? 'red' : (flashing ? 'blue' : 'green')}
label={flashing ? `Writing file ${progress.fileIndex + 1} of ${files.length}`
: (flashError ? 'Failed' : 'Done')}
/>}
{flashing && flashEtaText &&
<p style={{opacity: 0.8}}>
Estimated total time: ~{flashEtaText} at {baudrate.toLocaleString()} baud. Keep this
tab open and do not unplug the device.
</p>}
{/* Surface a failure right here — otherwise a failed flash looks identical to a frozen
one. */}
{flashError &&
<Message kind='error' icon='warning circle' title='Flashing failed'>
<p>{flashError}</p>
</Message.Content>
</Message>}
{!flashing && !flashError && progress !== null &&
<Message success>
<SIcon name='check circle'/>
<Message.Content>
<Message.Header>Flashing complete</Message.Header>
</Message>}
{!flashing && !flashError && progress !== null &&
<Message kind='success' icon='check circle' title='Flashing complete'>
<p>Unplug the device and power it back on to run the new firmware.</p>
</Message.Content>
</Message>}
<AutoScrollLog
value={log || 'Starting…'}
style={{fontFamily: 'monospace', width: '100%', minHeight: '60vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setFlashOpen(false)} disabled={flashing}>Close</Button>
</Modal.Actions>
</Modal>
</Message>}
<AutoScrollLog
value={log || 'Starting…'}
style={{fontFamily: 'var(--font-mono)', width: '100%', minHeight: '60vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setFlashOpen(false)} disabled={flashing}>Close</Button>
</Modal.Actions>
</Modal>
<Modal open={logOpen} onClose={() => setLogOpen(false)} size='fullscreen'>
<Modal.Header>Flasher Log</Modal.Header>
<Modal.Content scrolling>
<AutoScrollLog
value={log || 'No activity yet.'}
style={{fontFamily: 'monospace', width: '100%', minHeight: '70vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setLogOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
<Modal open={logOpen} onClose={() => setLogOpen(false)} size='fullscreen'>
<Modal.Header>Flasher Log</Modal.Header>
<Modal.Content>
<AutoScrollLog
value={log || 'No activity yet.'}
style={{fontFamily: 'var(--font-mono)', width: '100%', minHeight: '70vh', whiteSpace: 'pre'}}
/>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setLogOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
</PageContainer>
</div>;
}

View file

@ -1,5 +1,5 @@
import React from 'react';
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import {fireEvent, render, screen, waitFor} from '../test-utils';
// esptool-js ships untranspiled ESM which Jest will not transform; mock it so importing Flasher.js is safe.
// Real classes (not jest.fn mock impls) so `new ESPLoader()` reliably yields an instance with main()/after().

View file

@ -4,7 +4,6 @@
"src/components/Docs.js",
"src/components/FileBrowser.js",
"src/components/FilePreview.js",
"src/components/Flasher.js",
"src/components/Nav.js",
"src/components/Playlists.js",
"src/components/Theme.tsx",