feat(web): improve SD file browser
Add directory navigation, folder creation, inline deletion, clear controls, and a focused editor view. Include the opt-in FAT16 vvfat experiment and fail-fast browser diagnostics; raw FAT32 remains the default.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { BUTTONS, DEFAULT_SD_FILES, EXAMPLE_SD_TEXT, formatBytes, setButton, mergeFirmware } from '../web/app.js';
|
import { BUTTONS, formatBytes, setButton, mergeFirmware } from '../web/app.js';
|
||||||
import { buildFat32Image, FAT32_IMAGE_SIZE } from '../web/fat32.js';
|
import { buildFat32Image, FAT32_IMAGE_SIZE } from '../web/fat32.js';
|
||||||
|
|
||||||
// Merge - app image is placed at 0x10000 in a 0xFF-erased 16 MB flash with bootloader and partitions.
|
// Merge - app image is placed at 0x10000 in a 0xFF-erased 16 MB flash with bootloader and partitions.
|
||||||
@@ -15,8 +15,6 @@ assert.equal(flash[0x10000], 0xe9);
|
|||||||
assert.equal(flash[0x20000], 0xff);
|
assert.equal(flash[0x20000], 0xff);
|
||||||
assert.throws(() => mergeFirmware(new Uint8Array([0x00]), bootloader, partitions), /0xE9/);
|
assert.throws(() => mergeFirmware(new Uint8Array([0x00]), bootloader, partitions), /0xE9/);
|
||||||
|
|
||||||
assert.equal(DEFAULT_SD_FILES.get('Test/example.txt'), EXAMPLE_SD_TEXT);
|
|
||||||
assert.equal(EXAMPLE_SD_TEXT.split('Lorem ipsum').length - 1, 10);
|
|
||||||
assert.equal(formatBytes(512), '512 B');
|
assert.equal(formatBytes(512), '512 B');
|
||||||
assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MiB');
|
assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MiB');
|
||||||
|
|
||||||
|
|||||||
+159
-49
@@ -1,73 +1,183 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
|
from selenium.common.exceptions import WebDriverException
|
||||||
from selenium.webdriver.common.action_chains import ActionChains
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
from selenium.webdriver.firefox.options import Options
|
from selenium.webdriver.firefox.options import Options
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VVFAT = bool(os.environ.get('XTEINK_VVFAT'))
|
||||||
|
MODE = 'vvfat' if VVFAT else 'raw-sd'
|
||||||
|
ARTIFACT = ROOT / f'_scratch/browser-{MODE}'
|
||||||
|
BAD_MARKERS = (
|
||||||
|
'RuntimeError', 'Aborted()', 'index out of bounds',
|
||||||
|
'unaligned memory access', 'SSI_PERIPHERAL:', 'Guru Meditation',
|
||||||
|
"panic'ed", 'Invalid SD card size', 'FS error', 'SPI:', 'SD:',
|
||||||
|
'Unexpected response to cmd', 'Error writing to qcow backend',
|
||||||
|
'write error on host side',
|
||||||
|
)
|
||||||
|
|
||||||
options = Options()
|
options = Options()
|
||||||
options.add_argument('-headless')
|
options.add_argument('-headless')
|
||||||
|
options.accept_insecure_certs = True
|
||||||
driver = webdriver.Firefox(options=options)
|
driver = webdriver.Firefox(options=options)
|
||||||
driver.set_page_load_timeout(60)
|
driver.set_page_load_timeout(60)
|
||||||
|
|
||||||
|
|
||||||
|
def page_state():
|
||||||
|
try:
|
||||||
|
return driver.execute_script('''
|
||||||
|
return {
|
||||||
|
status: document.querySelector('#status')?.textContent || '',
|
||||||
|
log: document.querySelector('#log')?.textContent || '',
|
||||||
|
generation: Number(document.querySelector('#frame-generation')?.value || -1),
|
||||||
|
runtimeErrors: window.__xteinkRuntimeErrors || [],
|
||||||
|
wasmHeap: window.__xteinkModule?.HEAPU8?.byteLength || 0,
|
||||||
|
};
|
||||||
|
''')
|
||||||
|
except WebDriverException as error:
|
||||||
|
return {
|
||||||
|
'status': 'WebDriver lost the page',
|
||||||
|
'log': '',
|
||||||
|
'generation': -1,
|
||||||
|
'runtimeErrors': [str(error)],
|
||||||
|
'wasmHeap': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def failure(state):
|
||||||
|
text = '\n'.join([
|
||||||
|
state.get('status', ''),
|
||||||
|
state.get('log', ''),
|
||||||
|
*map(str, state.get('runtimeErrors', [])),
|
||||||
|
])
|
||||||
|
matched = [marker for marker in BAD_MARKERS if marker in text]
|
||||||
|
if matched:
|
||||||
|
return f'emulator failure detected: {matched}'
|
||||||
|
if state.get('runtimeErrors'):
|
||||||
|
return f'browser runtime error: {state["runtimeErrors"]}'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def wait_until(predicate, description, timeout):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
last = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
last = page_state()
|
||||||
|
problem = failure(last)
|
||||||
|
if problem:
|
||||||
|
raise RuntimeError(f'{problem} while waiting for {description}')
|
||||||
|
if predicate(last):
|
||||||
|
return last
|
||||||
|
time.sleep(0.25)
|
||||||
|
raise TimeoutError(f'timed out waiting for {description}; last state: {last}')
|
||||||
|
|
||||||
|
|
||||||
|
def wait_stable(quiet=5, timeout=180):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
state = page_state()
|
||||||
|
last_generation = state['generation']
|
||||||
|
stable_since = time.monotonic()
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
state = page_state()
|
||||||
|
problem = failure(state)
|
||||||
|
if problem:
|
||||||
|
raise RuntimeError(f'{problem} while waiting for display stability')
|
||||||
|
if state['generation'] != last_generation:
|
||||||
|
last_generation = state['generation']
|
||||||
|
stable_since = time.monotonic()
|
||||||
|
elif time.monotonic() - stable_since >= quiet:
|
||||||
|
return state
|
||||||
|
time.sleep(0.25)
|
||||||
|
raise TimeoutError(f'display did not settle; last state: {state}')
|
||||||
|
|
||||||
|
|
||||||
|
def save_artifacts():
|
||||||
|
state = page_state()
|
||||||
|
ARTIFACT.with_suffix('.json').write_text(json.dumps(state, indent=2))
|
||||||
|
ARTIFACT.with_suffix('.log').write_text(state.get('log', ''))
|
||||||
|
try:
|
||||||
|
driver.save_screenshot(str(ARTIFACT.with_suffix('.png')))
|
||||||
|
except WebDriverException:
|
||||||
|
pass
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
driver.get('http://127.0.0.1:8180/web/?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin&variant=x3')
|
suffix = '&vvfat' if VVFAT else ''
|
||||||
|
url = os.environ.get(
|
||||||
|
'XTEINK_TEST_URL',
|
||||||
|
'http://127.0.0.1:8180/web/'
|
||||||
|
'?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin'
|
||||||
|
f'&variant=x3{suffix}',
|
||||||
|
)
|
||||||
|
driver.get(url)
|
||||||
|
|
||||||
confirm = driver.find_element(By.CSS_SELECTOR, '[data-button="confirm"]')
|
confirm = driver.find_element(By.CSS_SELECTOR, '[data-button="confirm"]')
|
||||||
WebDriverWait(driver, 8 * 60).until(lambda _: confirm.get_attribute('disabled') is None)
|
if os.environ.get('XTEINK_INJECT_FAILURE'):
|
||||||
|
driver.execute_script("window.dispatchEvent(new ErrorEvent('error', {message: 'RuntimeError: injected test failure'}))")
|
||||||
indicator = driver.find_element(By.ID, 'frame-generation')
|
wait_until(
|
||||||
|
lambda _: confirm.get_dom_attribute('disabled') is None,
|
||||||
def generation():
|
'automatic cold boot and enabled controls',
|
||||||
return int(indicator.get_attribute('value') or '-1')
|
240,
|
||||||
|
)
|
||||||
def wait_frame(previous, timeout=180):
|
|
||||||
WebDriverWait(driver, timeout).until(lambda _: generation() > previous)
|
|
||||||
|
|
||||||
def wait_stable(quiet=5, timeout=180):
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
last = generation()
|
|
||||||
stable_since = time.time()
|
|
||||||
while time.time() < deadline:
|
|
||||||
time.sleep(0.25)
|
|
||||||
current = generation()
|
|
||||||
if current != last:
|
|
||||||
last = current
|
|
||||||
stable_since = time.time()
|
|
||||||
elif time.time() - stable_since >= quiet:
|
|
||||||
return current
|
|
||||||
raise RuntimeError('display did not settle')
|
|
||||||
|
|
||||||
wait_stable()
|
wait_stable()
|
||||||
|
|
||||||
def press(button):
|
def press(button, description):
|
||||||
driver.execute_script('arguments[0].scrollIntoView({block: "center"})', button)
|
driver.execute_script(
|
||||||
before = wait_stable()
|
'arguments[0].scrollIntoView({block: "center"})', button
|
||||||
|
)
|
||||||
|
before = wait_stable()['generation']
|
||||||
ActionChains(driver).click_and_hold(button).perform()
|
ActionChains(driver).click_and_hold(button).perform()
|
||||||
time.sleep(0.3)
|
try:
|
||||||
ActionChains(driver).release(button).perform()
|
time.sleep(0.3)
|
||||||
wait_frame(before)
|
finally:
|
||||||
wait_stable()
|
ActionChains(driver).release(button).perform()
|
||||||
|
wait_until(
|
||||||
|
lambda state: state['generation'] > before,
|
||||||
|
f'frame after {description}',
|
||||||
|
180,
|
||||||
|
)
|
||||||
|
return wait_stable()
|
||||||
|
|
||||||
press(confirm) # Browse Files
|
press(confirm, 'opening Browse Files')
|
||||||
press(confirm) # Test directory
|
press(confirm, 'opening Test directory')
|
||||||
press(confirm) # highlight example.txt (browser indexes a preview)
|
press(confirm, 'highlighting example.txt')
|
||||||
press(confirm) # Open example.txt into the reader
|
press(confirm, 'opening example.txt')
|
||||||
wait_stable(quiet=10, timeout=600)
|
|
||||||
|
|
||||||
status = driver.find_element(By.ID, 'status').text
|
down = driver.find_element(By.CSS_SELECTOR, '[data-button="down"]')
|
||||||
log = driver.find_element(By.ID, 'log').text
|
up = driver.find_element(By.CSS_SELECTOR, '[data-button="up"]')
|
||||||
(ROOT / '_scratch/browser-raw-sd.log').write_text(log)
|
press(down, 'turning to reader page 2')
|
||||||
driver.save_screenshot(str(ROOT / '_scratch/browser-raw-sd.png'))
|
press(down, 'turning to reader page 3')
|
||||||
|
press(up, 'turning back to reader page 2')
|
||||||
|
|
||||||
bad = (
|
back = driver.find_element(By.CSS_SELECTOR, '[data-button="back"]')
|
||||||
'RuntimeError', 'Guru Meditation', "panic'ed", 'Invalid SD card size',
|
press(back, 'closing the reader')
|
||||||
'FS error', 'SPI:', 'SD:', 'Unexpected response to cmd',
|
press(confirm, 'reopening example.txt')
|
||||||
|
final_state = wait_stable(quiet=10, timeout=600)
|
||||||
|
|
||||||
|
problem = failure(final_state)
|
||||||
|
if problem:
|
||||||
|
raise RuntimeError(problem)
|
||||||
|
if final_state['generation'] < 10:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'reader workflow produced too few frames: {final_state["generation"]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f'browser {MODE} test passed at frame generation '
|
||||||
|
f'{final_state["generation"]}, '
|
||||||
|
f'wasm heap {final_state["wasmHeap"] / 1024 / 1024:.1f} MiB'
|
||||||
)
|
)
|
||||||
matched = [marker for marker in bad if marker in log or marker in status]
|
except Exception:
|
||||||
if matched:
|
state = save_artifacts()
|
||||||
raise RuntimeError(f'emulator failure detected: {matched}')
|
print(json.dumps(state, indent=2))
|
||||||
print(f'browser raw-SD test passed at frame generation {generation()}')
|
raise
|
||||||
|
else:
|
||||||
|
save_artifacts()
|
||||||
finally:
|
finally:
|
||||||
driver.quit()
|
driver.quit()
|
||||||
|
|||||||
Vendored
+1
-1
Submodule third_party/qemu updated: cbeba3fbe9...962fc6e71d
+149
-74
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
listSdFiles, readSdFile, writeSdFile, deleteSdFile, readAllSdFiles,
|
listSdFiles, readSdFile, writeSdFile, mkdirSd, deleteSdFile, clearSdFiles, readAllSdFiles,
|
||||||
seedDefaultsIfEmpty, saveBootState, loadBootState,
|
|
||||||
} from './sdstore.js';
|
} from './sdstore.js';
|
||||||
import { buildFat32Image } from './fat32.js';
|
import { buildFat32Image } from './fat32.js';
|
||||||
|
|
||||||
@@ -44,10 +43,6 @@ async function fetchBin(url) {
|
|||||||
return new Uint8Array(await response.arrayBuffer());
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOREM_IPSUM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
|
|
||||||
export const EXAMPLE_SD_TEXT = Array(10).fill(LOREM_IPSUM).join('\n\n');
|
|
||||||
export const DEFAULT_SD_FILES = new Map([['Test/example.txt', EXAMPLE_SD_TEXT]]);
|
|
||||||
|
|
||||||
// A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table.
|
// A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table.
|
||||||
async function prepareFirmware(bytes, assetRoot) {
|
async function prepareFirmware(bytes, assetRoot) {
|
||||||
if (bytes.length === FLASH_SIZE) {
|
if (bytes.length === FLASH_SIZE) {
|
||||||
@@ -105,6 +100,7 @@ function drawFrame(module, canvas) {
|
|||||||
function startDisplayLoop(module, canvas, setStatus, onReady) {
|
function startDisplayLoop(module, canvas, setStatus, onReady) {
|
||||||
let generation = -1;
|
let generation = -1;
|
||||||
let powerHeld = false;
|
let powerHeld = false;
|
||||||
|
let heldFrames = 0;
|
||||||
let ready = false;
|
let ready = false;
|
||||||
const update = () => {
|
const update = () => {
|
||||||
const nextGeneration = module._xteink_wasm_frame_generation();
|
const nextGeneration = module._xteink_wasm_frame_generation();
|
||||||
@@ -117,7 +113,7 @@ function startDisplayLoop(module, canvas, setStatus, onReady) {
|
|||||||
powerHeld = true;
|
powerHeld = true;
|
||||||
setButton(module, 'power', true);
|
setButton(module, 'power', true);
|
||||||
setStatus('Holding power for cold boot…');
|
setStatus('Holding power for cold boot…');
|
||||||
} else if (!ready) {
|
} else if (!ready && ++heldFrames >= 2) {
|
||||||
setButton(module, 'power', false);
|
setButton(module, 'power', false);
|
||||||
ready = true;
|
ready = true;
|
||||||
setStatus('Running.');
|
setStatus('Running.');
|
||||||
@@ -178,6 +174,9 @@ const logBuffer = [];
|
|||||||
const LOG_MAX_LINES = 1000;
|
const LOG_MAX_LINES = 1000;
|
||||||
const DEBUG_LOG_KEY = 'xteink-debug-log';
|
const DEBUG_LOG_KEY = 'xteink-debug-log';
|
||||||
let activeModule = null;
|
let activeModule = null;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__xteinkRuntimeErrors = [];
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes) {
|
export function formatBytes(bytes) {
|
||||||
if (bytes < 1024) {
|
if (bytes < 1024) {
|
||||||
@@ -195,17 +194,27 @@ export function formatBytes(bytes) {
|
|||||||
|
|
||||||
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']);
|
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']);
|
||||||
let selectedSdPath = null;
|
let selectedSdPath = null;
|
||||||
|
let currentSdDirectory = '';
|
||||||
|
|
||||||
function isTextPath(path) {
|
function isTextPath(path) {
|
||||||
return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase());
|
return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showEditor(show) {
|
||||||
|
document.querySelector('#files-browse').hidden = show;
|
||||||
|
document.querySelector('#files-edit').hidden = !show;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
selectedSdPath = null;
|
||||||
|
showEditor(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function selectSdFile(path) {
|
async function selectSdFile(path) {
|
||||||
selectedSdPath = path;
|
selectedSdPath = path;
|
||||||
const editor = document.querySelector('#file-editor');
|
const editor = document.querySelector('#file-editor');
|
||||||
const save = document.querySelector('#file-save');
|
const save = document.querySelector('#file-save');
|
||||||
const del = document.querySelector('#file-delete');
|
document.querySelector('#file-edit-name').textContent = path.split('/').pop();
|
||||||
del.hidden = false;
|
|
||||||
try {
|
try {
|
||||||
const bytes = await readSdFile(path);
|
const bytes = await readSdFile(path);
|
||||||
if (isTextPath(path)) {
|
if (isTextPath(path)) {
|
||||||
@@ -222,49 +231,82 @@ async function selectSdFile(path) {
|
|||||||
editor.readOnly = true;
|
editor.readOnly = true;
|
||||||
save.hidden = true;
|
save.hidden = true;
|
||||||
}
|
}
|
||||||
for (const button of document.querySelectorAll('#file-list button')) {
|
showEditor(true);
|
||||||
button.classList.toggle('selected', button.dataset.path === path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderFiles() {
|
async function renderFiles() {
|
||||||
const list = document.querySelector('#file-list');
|
const list = document.querySelector('#file-list');
|
||||||
const editor = document.querySelector('#file-editor');
|
|
||||||
const entries = await listSdFiles();
|
const entries = await listSdFiles();
|
||||||
list.replaceChildren();
|
if (currentSdDirectory && !entries.some(entry => entry.directory && entry.path === `${currentSdDirectory}/`)) {
|
||||||
let firstPath = null;
|
currentSdDirectory = '';
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.directory) {
|
|
||||||
const directory = document.createElement('div');
|
|
||||||
directory.className = 'file-directory';
|
|
||||||
directory.textContent = entry.path;
|
|
||||||
list.append(directory);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.dataset.path = entry.path;
|
|
||||||
const path = document.createElement('span');
|
|
||||||
path.textContent = entry.path;
|
|
||||||
const size = document.createElement('small');
|
|
||||||
size.textContent = formatBytes(entry.size);
|
|
||||||
button.append(path, size);
|
|
||||||
button.addEventListener('click', () => selectSdFile(entry.path));
|
|
||||||
list.append(button);
|
|
||||||
firstPath ||= entry.path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stillPresent = entries.some(entry => entry.path === selectedSdPath);
|
const prefix = currentSdDirectory ? `${currentSdDirectory}/` : '';
|
||||||
const target = stillPresent ? selectedSdPath : firstPath;
|
const visibleEntries = entries.filter(entry => {
|
||||||
if (target) {
|
if (!entry.path.startsWith(prefix)) {
|
||||||
await selectSdFile(target);
|
return false;
|
||||||
} else {
|
}
|
||||||
selectedSdPath = null;
|
const relativePath = entry.path.slice(prefix.length).replace(/\/$/, '');
|
||||||
editor.value = 'The SD card is empty.';
|
return relativePath && !relativePath.includes('/');
|
||||||
editor.readOnly = true;
|
});
|
||||||
document.querySelector('#file-save').hidden = true;
|
|
||||||
document.querySelector('#file-delete').hidden = true;
|
document.querySelector('#file-path').textContent = `/sdcard${currentSdDirectory ? `/${currentSdDirectory}` : ''}`;
|
||||||
|
document.querySelector('#file-back').disabled = !currentSdDirectory;
|
||||||
|
|
||||||
|
const body = list.querySelector('tbody');
|
||||||
|
body.replaceChildren();
|
||||||
|
|
||||||
|
if (!visibleEntries.length) {
|
||||||
|
const row = body.insertRow();
|
||||||
|
const cell = row.insertCell();
|
||||||
|
cell.colSpan = 4;
|
||||||
|
cell.className = 'file-empty';
|
||||||
|
cell.textContent = 'This folder is empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childCount = dirPrefix => entries.filter(e => {
|
||||||
|
const rest = e.path.slice(dirPrefix.length).replace(/\/$/, '');
|
||||||
|
return e.path.startsWith(dirPrefix) && rest && !rest.includes('/');
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
for (const entry of visibleEntries) {
|
||||||
|
const name = entry.path.slice(prefix.length).replace(/\/$/, '');
|
||||||
|
const row = body.insertRow();
|
||||||
|
row.className = entry.directory ? 'directory' : 'file';
|
||||||
|
row.insertCell().textContent = entry.directory ? '\u{1F4C1}' : '\u{1F4C4}';
|
||||||
|
row.insertCell().textContent = name;
|
||||||
|
if (entry.directory) {
|
||||||
|
const n = childCount(entry.path);
|
||||||
|
row.insertCell().textContent = `${n} item${n === 1 ? '' : 's'}`;
|
||||||
|
} else {
|
||||||
|
row.insertCell().textContent = formatBytes(entry.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.className = 'file-del';
|
||||||
|
del.textContent = '\u2715';
|
||||||
|
del.title = `Delete ${name}`;
|
||||||
|
del.addEventListener('click', async event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!confirm(`Delete ${name}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteSdFile(entry.path);
|
||||||
|
await renderFiles();
|
||||||
|
});
|
||||||
|
row.insertCell().append(del);
|
||||||
|
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
if (entry.directory) {
|
||||||
|
currentSdDirectory = entry.path.replace(/\/$/, '');
|
||||||
|
selectedSdPath = null;
|
||||||
|
renderFiles();
|
||||||
|
} else {
|
||||||
|
selectSdFile(entry.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,26 +355,54 @@ function initializeInspector() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
wireFileControls();
|
wireFileControls();
|
||||||
seedDefaultsIfEmpty(DEFAULT_SD_FILES).then(renderFiles);
|
renderFiles();
|
||||||
renderDebug();
|
renderDebug();
|
||||||
setInterval(renderDebug, 1000);
|
setInterval(renderDebug, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireFileControls() {
|
function wireFileControls() {
|
||||||
|
const inDir = name => (currentSdDirectory ? `${currentSdDirectory}/${name}` : name);
|
||||||
document.querySelector('#file-add').addEventListener('change', async event => {
|
document.querySelector('#file-add').addEventListener('change', async event => {
|
||||||
for (const file of event.target.files) {
|
for (const file of event.target.files) {
|
||||||
await writeSdFile(file.name, new Uint8Array(await file.arrayBuffer()));
|
await writeSdFile(inDir(file.name), new Uint8Array(await file.arrayBuffer()));
|
||||||
}
|
}
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
await renderFiles();
|
await renderFiles();
|
||||||
});
|
});
|
||||||
document.querySelector('#file-new').addEventListener('click', async () => {
|
document.querySelector('#file-new').addEventListener('click', async () => {
|
||||||
const name = prompt('New file path (e.g. Notes/todo.txt):');
|
const name = prompt('New file name:')?.replace(/^\/+|\/+$/g, '');
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await writeSdFile(name, '');
|
const path = inDir(name);
|
||||||
selectedSdPath = name;
|
await writeSdFile(path, '');
|
||||||
|
await renderFiles();
|
||||||
|
await selectSdFile(path);
|
||||||
|
});
|
||||||
|
document.querySelector('#file-new-folder').addEventListener('click', async () => {
|
||||||
|
const name = prompt('New folder name:')?.replace(/^\/+|\/+$/g, '');
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mkdirSd(inDir(name));
|
||||||
|
await renderFiles();
|
||||||
|
});
|
||||||
|
document.querySelector('#file-close').addEventListener('click', () => {
|
||||||
|
closeEditor();
|
||||||
|
renderFiles();
|
||||||
|
});
|
||||||
|
document.querySelector('#file-back').addEventListener('click', async () => {
|
||||||
|
currentSdDirectory = currentSdDirectory.split('/').slice(0, -1).join('/');
|
||||||
|
selectedSdPath = null;
|
||||||
|
await renderFiles();
|
||||||
|
});
|
||||||
|
document.querySelector('#file-reset').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Delete all files from the SD card?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await clearSdFiles();
|
||||||
|
currentSdDirectory = '';
|
||||||
|
selectedSdPath = null;
|
||||||
await renderFiles();
|
await renderFiles();
|
||||||
});
|
});
|
||||||
document.querySelector('#file-save').addEventListener('click', async () => {
|
document.querySelector('#file-save').addEventListener('click', async () => {
|
||||||
@@ -340,14 +410,13 @@ function wireFileControls() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value);
|
await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value);
|
||||||
await renderFiles();
|
|
||||||
});
|
});
|
||||||
document.querySelector('#file-delete').addEventListener('click', async () => {
|
document.querySelector('#file-delete').addEventListener('click', async () => {
|
||||||
if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) {
|
if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deleteSdFile(selectedSdPath);
|
await deleteSdFile(selectedSdPath);
|
||||||
selectedSdPath = null;
|
closeEditor();
|
||||||
await renderFiles();
|
await renderFiles();
|
||||||
});
|
});
|
||||||
document.querySelector('#file-reboot').addEventListener('click', () => location.reload());
|
document.querySelector('#file-reboot').addEventListener('click', () => location.reload());
|
||||||
@@ -382,11 +451,13 @@ async function boot(file, variant, setStatus) {
|
|||||||
prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url),
|
prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url),
|
||||||
fetchBin(new URL('esp32c3-rom.bin', artifactRoot)),
|
fetchBin(new URL('esp32c3-rom.bin', artifactRoot)),
|
||||||
]);
|
]);
|
||||||
await seedDefaultsIfEmpty(DEFAULT_SD_FILES);
|
|
||||||
const sdFiles = await readAllSdFiles();
|
const sdFiles = await readAllSdFiles();
|
||||||
setStatus(`Building SD card image from ${sdFiles.length} browser file(s)…`);
|
const useVvfat = new URL(location.href).searchParams.has('vvfat');
|
||||||
const sdImage = buildFat32Image(sdFiles);
|
setStatus(`${useVvfat ? 'Staging' : 'Building SD card image from'} ${sdFiles.length} browser file(s)…`);
|
||||||
const sdMessage = `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`;
|
const sdImage = useVvfat ? null : buildFat32Image(sdFiles);
|
||||||
|
const sdMessage = useVvfat
|
||||||
|
? `Using experimental vvfat backed by ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`
|
||||||
|
: `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`;
|
||||||
console.log(sdMessage);
|
console.log(sdMessage);
|
||||||
logLine(sdMessage);
|
logLine(sdMessage);
|
||||||
const { default: createQemu } = await import(qemuUrl);
|
const { default: createQemu } = await import(qemuUrl);
|
||||||
@@ -405,7 +476,9 @@ async function boot(file, variant, setStatus) {
|
|||||||
'-nic', 'none',
|
'-nic', 'none',
|
||||||
'-d', qemuLogFlags,
|
'-d', qemuLogFlags,
|
||||||
'-drive', 'file=/flash.bin,if=mtd,format=raw',
|
'-drive', 'file=/flash.bin,if=mtd,format=raw',
|
||||||
'-drive', 'file=/sd.img,if=sd,format=raw',
|
'-drive', useVvfat
|
||||||
|
? 'file=fat:16:rw:/sdcard,if=sd,format=raw'
|
||||||
|
: 'file=/sd.img,if=sd,format=raw',
|
||||||
],
|
],
|
||||||
locateFile: path => new URL(path, artifactRoot).href,
|
locateFile: path => new URL(path, artifactRoot).href,
|
||||||
mainScriptUrlOrBlob: qemuUrl.href,
|
mainScriptUrlOrBlob: qemuUrl.href,
|
||||||
@@ -418,7 +491,16 @@ async function boot(file, variant, setStatus) {
|
|||||||
options.FS.mkdirTree('/var/tmp');
|
options.FS.mkdirTree('/var/tmp');
|
||||||
options.FS.writeFile('/bios/esp32c3-rom.bin', rom);
|
options.FS.writeFile('/bios/esp32c3-rom.bin', rom);
|
||||||
options.FS.writeFile('/flash.bin', firmware);
|
options.FS.writeFile('/flash.bin', firmware);
|
||||||
options.FS.writeFile('/sd.img', sdImage);
|
if (useVvfat) {
|
||||||
|
for (const { path, bytes } of sdFiles) {
|
||||||
|
const slash = path.lastIndexOf('/');
|
||||||
|
const directory = slash < 0 ? '/sdcard' : `/sdcard/${path.slice(0, slash)}`;
|
||||||
|
options.FS.mkdirTree(directory);
|
||||||
|
options.FS.writeFile(`/sdcard/${path}`, bytes);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
options.FS.writeFile('/sd.img', sdImage);
|
||||||
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
setStatus('Starting emulator…');
|
setStatus('Starting emulator…');
|
||||||
@@ -450,6 +532,14 @@ function initialize() {
|
|||||||
status.textContent = message;
|
status.textContent = message;
|
||||||
status.classList.toggle('error', error);
|
status.classList.toggle('error', error);
|
||||||
};
|
};
|
||||||
|
const recordRuntimeError = value => {
|
||||||
|
const message = value instanceof Error ? value.stack || value.message : String(value);
|
||||||
|
window.__xteinkRuntimeErrors.push(message);
|
||||||
|
logLine(`[runtime] ${message}`);
|
||||||
|
setStatus(`Runtime error: ${message}`, true);
|
||||||
|
};
|
||||||
|
window.addEventListener('error', event => recordRuntimeError(event.message || event.error || 'Unknown worker error'));
|
||||||
|
window.addEventListener('unhandledrejection', event => recordRuntimeError(event.reason || 'Unhandled rejection'));
|
||||||
|
|
||||||
const start = async (file, variant = variantInput.value) => {
|
const start = async (file, variant = variantInput.value) => {
|
||||||
bootButton.disabled = true;
|
bootButton.disabled = true;
|
||||||
@@ -480,7 +570,6 @@ function initialize() {
|
|||||||
setStatus('Choose a firmware image first.', true);
|
setStatus('Choose a firmware image first.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveBootState(file, variantInput.value);
|
|
||||||
start(file);
|
start(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,21 +582,7 @@ function initialize() {
|
|||||||
}
|
}
|
||||||
return response.blob();
|
return response.blob();
|
||||||
})
|
})
|
||||||
.then(async blob => {
|
.then(blob => start(new File([blob], 'firmware.bin'), params.get('variant') || 'x3'))
|
||||||
const variant = params.get('variant') || 'x3';
|
|
||||||
const firmware = new File([blob], 'firmware.bin');
|
|
||||||
await saveBootState(firmware, variant);
|
|
||||||
await start(firmware, variant);
|
|
||||||
})
|
|
||||||
.catch(error => setStatus(error.message || String(error), true));
|
|
||||||
} else {
|
|
||||||
loadBootState()
|
|
||||||
.then(saved => {
|
|
||||||
if (saved) {
|
|
||||||
variantInput.value = saved.variant;
|
|
||||||
start(saved.firmware, saved.variant);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => setStatus(error.message || String(error), true));
|
.catch(error => setStatus(error.message || String(error), true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-9
@@ -66,16 +66,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
|
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
|
||||||
<div class="file-toolbar">
|
<div id="files-browse">
|
||||||
<label class="file-button">Add files<input id="file-add" type="file" multiple hidden></label>
|
<div class="file-location">
|
||||||
<button id="file-new" type="button" class="file-button">New text file</button>
|
<button id="file-back" type="button" aria-label="Go up one folder" disabled>←</button>
|
||||||
<button id="file-reboot" type="button" class="file-button file-reboot">Apply & reboot</button>
|
<code id="file-path">/sdcard</code>
|
||||||
|
</div>
|
||||||
|
<table id="file-list" class="file-list" aria-label="SD card files"><tbody></tbody></table>
|
||||||
|
<div class="file-toolbar">
|
||||||
|
<label class="file-button">Upload<input id="file-add" type="file" multiple hidden></label>
|
||||||
|
<button id="file-new" type="button" class="file-button">New File</button>
|
||||||
|
<button id="file-new-folder" type="button" class="file-button">New Folder</button>
|
||||||
|
<button id="file-reset" type="button" class="file-button file-clear">Clear</button>
|
||||||
|
<button id="file-reboot" type="button" class="file-button file-reboot">Apply & Reboot</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="file-list" class="file-list" aria-label="SD card files"></div>
|
<div id="files-edit" hidden>
|
||||||
<textarea id="file-editor" class="file-preview" spellcheck="false" readonly>Select a file to view it.</textarea>
|
<div class="file-location">
|
||||||
<div class="file-actions">
|
<button id="file-close" type="button" aria-label="Back to file list">←</button>
|
||||||
<button id="file-save" type="button" class="file-button" hidden>Save</button>
|
<code id="file-edit-name"></code>
|
||||||
<button id="file-delete" type="button" class="file-button" hidden>Delete</button>
|
<button id="file-delete" type="button" class="file-button file-clear">Delete</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="file-editor" class="file-preview" spellcheck="false" readonly></textarea>
|
||||||
|
<div class="file-actions">
|
||||||
|
<button id="file-save" type="button" class="file-button" hidden>Save</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+13
-39
@@ -4,7 +4,6 @@
|
|||||||
// MEMFS /sdcard on the next boot. Guest-side writes remain ephemeral.
|
// MEMFS /sdcard on the next boot. Guest-side writes remain ephemeral.
|
||||||
|
|
||||||
const SD_DIR = 'sdcard';
|
const SD_DIR = 'sdcard';
|
||||||
const STATE_DIR = 'emulator';
|
|
||||||
|
|
||||||
async function sdRoot() {
|
async function sdRoot() {
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage.getDirectory();
|
||||||
@@ -54,11 +53,24 @@ export async function writeSdFile(path, data) {
|
|||||||
await writable.close();
|
await writable.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: empty dirs live only in OPFS; readAllSdFiles skips them, so they vanish on reboot. Add a placeholder file if guest-side empty dirs ever matter.
|
||||||
|
export async function mkdirSd(path) {
|
||||||
|
let dir = await sdRoot();
|
||||||
|
for (const part of path.split('/').filter(Boolean)) {
|
||||||
|
dir = await dir.getDirectoryHandle(part, { create: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteSdFile(path) {
|
export async function deleteSdFile(path) {
|
||||||
const [dir, name] = await resolveParent(path, false);
|
const [dir, name] = await resolveParent(path, false);
|
||||||
await dir.removeEntry(name, { recursive: true });
|
await dir.removeEntry(name, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearSdFiles() {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
await root.removeEntry(SD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Flatten to [{ path, bytes }] for seeding the guest's MEMFS /sdcard at boot.
|
// Flatten to [{ path, bytes }] for seeding the guest's MEMFS /sdcard at boot.
|
||||||
export async function readAllSdFiles() {
|
export async function readAllSdFiles() {
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -69,41 +81,3 @@ export async function readAllSdFiles() {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedDefaultsIfEmpty(defaults) {
|
|
||||||
const entries = await listSdFiles();
|
|
||||||
if (entries.some(entry => !entry.directory)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [path, contents] of defaults) {
|
|
||||||
await writeSdFile(path, contents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveBootState(firmware, variant) {
|
|
||||||
const root = await navigator.storage.getDirectory();
|
|
||||||
const state = await root.getDirectoryHandle(STATE_DIR, { create: true });
|
|
||||||
const firmwareHandle = await state.getFileHandle('firmware.bin', { create: true });
|
|
||||||
const firmwareWriter = await firmwareHandle.createWritable();
|
|
||||||
await firmwareWriter.write(firmware);
|
|
||||||
await firmwareWriter.close();
|
|
||||||
const settingsHandle = await state.getFileHandle('settings.json', { create: true });
|
|
||||||
const settingsWriter = await settingsHandle.createWritable();
|
|
||||||
await settingsWriter.write(JSON.stringify({ variant, name: firmware.name || 'firmware.bin' }));
|
|
||||||
await settingsWriter.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadBootState() {
|
|
||||||
try {
|
|
||||||
const root = await navigator.storage.getDirectory();
|
|
||||||
const state = await root.getDirectoryHandle(STATE_DIR);
|
|
||||||
const settings = JSON.parse(await (await state.getFileHandle('settings.json')).getFile().then(file => file.text()));
|
|
||||||
const firmware = await (await state.getFileHandle('firmware.bin')).getFile();
|
|
||||||
return { firmware: new File([firmware], settings.name || 'firmware.bin'), variant: settings.variant || 'x3' };
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'NotFoundError') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+27
-11
@@ -80,17 +80,33 @@ canvas { display: block; width: 100%; height: auto; background: #fff; image-rend
|
|||||||
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #625e55; }
|
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #625e55; }
|
||||||
.log-header button { min-height: auto; padding: .25rem .75rem; font-size: .8rem; }
|
.log-header button { min-height: auto; padding: .25rem .75rem; font-size: .8rem; }
|
||||||
.log { margin: 0; max-height: 38rem; overflow: auto; padding: .75rem 1rem; border-radius: .6rem; background: #1c1d1a; color: #d6d0c2; font: .78rem/1.4 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; }
|
.log { margin: 0; max-height: 38rem; overflow: auto; padding: .75rem 1rem; border-radius: .6rem; background: #1c1d1a; color: #d6d0c2; font: .78rem/1.4 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; }
|
||||||
.file-toolbar, .file-actions { display: flex; gap: .5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
.file-toolbar { display: flex; gap: .4rem; align-items: center; margin: 0; flex-wrap: wrap; }
|
||||||
.file-actions { margin: .75rem 0 0; }
|
.file-actions { display: flex; justify-content: flex-end; gap: .4rem; margin-top: .6rem; }
|
||||||
.file-button { display: inline-flex; align-items: center; padding: .4rem .8rem; border-radius: .5rem; cursor: pointer; }
|
.file-button { display: inline-flex; min-height: 1.9rem; align-items: center; padding: .3rem .6rem; border: 1px solid #55534e; border-radius: .4rem; color: #fff; background: #343532; font-size: .8rem; font-weight: 650; cursor: pointer; }
|
||||||
.file-reboot { margin-left: auto; }
|
.file-button:hover:not(:disabled) { background: #11120f; }
|
||||||
.file-list { display: grid; gap: .4rem; margin-bottom: 1rem; }
|
.file-clear { border-color: #b94a3b; color: #a52d20; background: transparent; }
|
||||||
.file-directory { padding: .35rem .55rem; color: #625e55; font-weight: 700; }
|
.file-clear:hover:not(:disabled) { color: #fff; background: #a52d20; }
|
||||||
.file-list button { display: flex; justify-content: space-between; gap: 1rem; width: 100%; min-height: 2.4rem; text-align: left; }
|
.file-reboot { margin-left: auto; background: #d2752b; border-color: #b66020; }
|
||||||
.file-list button.selected { outline: 2px solid #625e55; }
|
.file-reboot:hover:not(:disabled) { background: #b66020; }
|
||||||
.file-list small { color: #cbc5b9; font-weight: 400; }
|
.file-location { display: flex; align-items: center; gap: .5rem; margin-bottom: .5rem; color: #625e55; }
|
||||||
.file-preview { min-height: 12rem; max-height: 28rem; overflow: auto; margin: 0; padding: .75rem 1rem; border-radius: .6rem; background: #fff; white-space: pre-wrap; }
|
.file-location button { min-height: 1.9rem; padding: .2rem .55rem; font-size: .8rem; }
|
||||||
textarea.file-preview { width: 100%; box-sizing: border-box; resize: vertical; font-family: inherit; }
|
.file-location code { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-list { width: 100%; border-collapse: collapse; margin-bottom: .75rem; border: 1px solid #c8c2b6; border-radius: .6rem; background: #fff; font-size: .82rem; }
|
||||||
|
.file-list td { padding: .28rem .5rem; border-bottom: 1px solid #eee9df; }
|
||||||
|
.file-list tr:last-child td { border-bottom: 0; }
|
||||||
|
.file-list tbody tr:hover { background: #f1ede4; }
|
||||||
|
.file-list tr.file, .file-list tr.directory { cursor: pointer; }
|
||||||
|
.file-list td:first-child { width: 1.4rem; text-align: center; }
|
||||||
|
.file-list tr.directory td:nth-child(2) { font-weight: 700; }
|
||||||
|
.file-list td:nth-child(3) { text-align: right; color: #8a8479; white-space: nowrap; }
|
||||||
|
.file-list td:last-child { width: 2rem; padding: 0; text-align: center; }
|
||||||
|
.file-empty { text-align: center; color: #777269; padding: 1.25rem !important; }
|
||||||
|
.file-del { min-height: 0; padding: .1rem .35rem; border: 0; border-radius: .3rem; color: #a52d20; background: transparent; font-size: .85rem; line-height: 1; }
|
||||||
|
.file-del:hover { color: #fff; background: #a52d20; }
|
||||||
|
.file-preview { width: 100%; min-height: 14rem; max-height: 28rem; overflow: auto; margin: 0; padding: .85rem 1rem; border: 1px solid #c8c2b6; border-radius: .6rem; color: #343532; background: #fff; line-height: 1.45; white-space: pre-wrap; }
|
||||||
|
textarea.file-preview { box-sizing: border-box; resize: vertical; font-family: ui-monospace, monospace; }
|
||||||
|
textarea.file-preview:focus { outline: 3px solid #d2752b; outline-offset: 1px; }
|
||||||
|
textarea.file-preview:read-only { color: #777269; background: #f1ede4; }
|
||||||
.debug-stats { display: grid; gap: .65rem; margin: 0; }
|
.debug-stats { display: grid; gap: .65rem; margin: 0; }
|
||||||
.debug-stats div { display: flex; justify-content: space-between; gap: 1rem; padding-bottom: .65rem; border-bottom: 1px solid #d8d2c7; }
|
.debug-stats div { display: flex; justify-content: space-between; gap: 1rem; padding-bottom: .65rem; border-bottom: 1px solid #d8d2c7; }
|
||||||
.debug-stats dt { font-weight: 700; }
|
.debug-stats dt { font-weight: 700; }
|
||||||
|
|||||||
Reference in New Issue
Block a user