project saving on device and browser

main
Jake 2026-02-20 15:57:54 +08:00
parent 3fdf4b1573
commit 1e6fe41ddd
4 changed files with 518 additions and 97 deletions

View File

@ -34,11 +34,8 @@
<button id="btn-save" title="Save code to device as main.py" disabled>
<span class="icon">&#128190;</span> Save
</button>
<button id="btn-save-workspace" title="Save Blockly workspace to device" disabled>
<span class="icon">💾</span> Save WS
</button>
<button id="btn-load-workspace" title="Load Blockly workspace from device" disabled>
<span class="icon">📂</span> Load WS
<button id="btn-projects" title="Open/save projects (browser or device)">
<span class="icon">&#128194;</span> Projects
</button>
<span id="connection-status" class="status-disconnected">Disconnected</span>
</div>
@ -68,6 +65,47 @@
</div>
</main>
<!-- Projects dialog -->
<div id="projects-overlay" class="hidden">
<div id="projects-modal">
<div class="projects-header">
<h3>Projects</h3>
<button id="projects-close">&times;</button>
</div>
<div class="projects-columns">
<div class="projects-col">
<h4>Browser</h4>
<ul class="projects-list" id="browser-list"></ul>
<div class="projects-actions">
<div class="projects-name-row">
<input type="text" id="browser-save-name" placeholder="Project name..." />
<button id="browser-save-btn">Save</button>
</div>
<div class="projects-btn-row">
<button id="browser-load-btn" disabled>Load</button>
<button id="browser-delete-btn" class="btn-danger" disabled>Delete</button>
</div>
</div>
</div>
<div class="projects-col">
<h4>Device</h4>
<ul class="projects-list" id="device-list"></ul>
<div class="projects-actions">
<div class="projects-name-row">
<input type="text" id="device-save-name" placeholder="Project name..." />
<button id="device-save-btn">Save</button>
</div>
<div class="projects-btn-row">
<button id="device-load-btn" disabled>Load</button>
<button id="device-delete-btn" class="btn-danger" disabled>Delete</button>
</div>
</div>
<div id="device-status" class="projects-note"></div>
</div>
</div>
</div>
</div>
<!-- Flash progress overlay -->
<div id="flash-overlay" class="hidden">
<div id="flash-modal">

View File

@ -14,6 +14,7 @@ import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './s
import { flashFirmware } from './serial/flasher.js';
import { appendToTerminal, clearTerminal } from './ui/terminal.js';
import { initResizablePanels } from './ui/panels.js';
import { initProjectsDialog, open as openProjects } from './ui/projectsDialog.js';
import './style.css';
// ─── Blockly Workspace ───────────────────────────────────
@ -93,8 +94,7 @@ const btnFlash = document.getElementById('btn-flash');
const btnRun = document.getElementById('btn-run');
const btnStop = document.getElementById('btn-stop');
const btnSave = document.getElementById('btn-save');
const btnSaveWorkspace = document.getElementById('btn-save-workspace');
const btnLoadWorkspace = document.getElementById('btn-load-workspace');
const btnProjects = document.getElementById('btn-projects');
const statusEl = document.getElementById('connection-status');
const terminalInput = document.getElementById('terminal-input');
@ -118,36 +118,32 @@ function setConnectedUI(connected) {
btnRun.disabled = !connected;
btnStop.disabled = !connected;
btnSave.disabled = !connected;
btnSaveWorkspace.disabled = !connected;
btnLoadWorkspace.disabled = !connected;
terminalInput.disabled = !connected;
statusEl.textContent = connected ? 'Connected' : 'Disconnected';
statusEl.className = connected ? 'status-connected' : 'status-disconnected';
}
// ─── Serial Event Listeners ──────────────────────────────
// ─── Serial Capture (reusable promise-based) ─────────────
// Workspace loading state
let workspaceCaptureState = null;
let captureState = null;
onData((text) => {
if (!workspaceCaptureState) {
if (!captureState) {
appendToTerminal(text);
return;
}
const { startMarker, endMarker } = workspaceCaptureState;
workspaceCaptureState.raw += text;
const raw = workspaceCaptureState.raw;
const { startMarker, endMarker } = captureState;
captureState.raw += text;
const raw = captureState.raw;
const startIdx = raw.indexOf(startMarker);
if (startIdx === -1) {
// No start marker yet — flush text that can't be part of the marker
const keep = startMarker.length - 1;
if (raw.length > keep) {
appendToTerminal(raw.substring(0, raw.length - keep));
workspaceCaptureState.raw = raw.substring(raw.length - keep);
captureState.raw = raw.substring(raw.length - keep);
}
return;
}
@ -156,35 +152,56 @@ onData((text) => {
const endIdx = raw.indexOf(endMarker, contentStart);
if (endIdx === -1) {
// Have start but no end yet — show text before start marker once
if (!workspaceCaptureState.flushedPre && startIdx > 0) {
if (!captureState.flushedPre && startIdx > 0) {
appendToTerminal(raw.substring(0, startIdx));
workspaceCaptureState.flushedPre = true;
captureState.flushedPre = true;
}
return;
}
// Both markers found — extract content and load
const jsonContent = raw.substring(contentStart, endIdx).trim();
const beforeStart = startIdx > 0 && !workspaceCaptureState.flushedPre
const content = raw.substring(contentStart, endIdx);
const beforeStart = startIdx > 0 && !captureState.flushedPre
? raw.substring(0, startIdx) : '';
const afterEnd = raw.substring(endIdx + endMarker.length);
const resolve = captureState.resolve;
workspaceCaptureState = null;
captureState = null;
if (beforeStart) appendToTerminal(beforeStart);
try {
const state = JSON.parse(jsonContent);
Blockly.serialization.workspaces.load(state, workspace);
appendToTerminal('Workspace loaded successfully!\n');
} catch (parseErr) {
appendToTerminal(`\nParse error: ${parseErr.message}\n`);
}
if (afterEnd.trim()) appendToTerminal(afterEnd);
resolve(content);
});
function captureDeviceOutput(script) {
return new Promise((resolve, reject) => {
const ts = Date.now();
const sm = `__CAP_S_${ts}__`;
const em = `__CAP_E_${ts}__`;
captureState = {
startMarker: sm,
endMarker: em,
raw: '',
flushedPre: false,
resolve,
};
const wrapped = `print('${sm}',end='')\n${script}\nprint('${em}',end='')`;
executeCode(wrapped).catch(err => {
captureState = null;
reject(err);
});
setTimeout(() => {
if (captureState?.resolve === resolve) {
captureState = null;
reject(new Error('Timeout waiting for device response'));
}
}, 10000);
});
}
// ─── Toolbar Buttons ─────────────────────────────────────
btnConnect.addEventListener('click', async () => {
@ -303,72 +320,17 @@ btnSave.addEventListener('click', async () => {
}
});
// ─── Workspace Save/Load ───────────────────────────────────
// ─── Projects Dialog ────────────────────────────────────
async function saveWorkspaceToDevice() {
try {
const state = Blockly.serialization.workspaces.save(workspace);
const json = JSON.stringify(state);
appendToTerminal('\nSaving workspace to device...\n');
await writeFileToDevice(json, 'workspace.json');
appendToTerminal('Workspace saved to workspace.json\n');
} catch (err) {
appendToTerminal(`\nSave workspace error: ${err.message}\n`);
}
}
async function loadWorkspaceFromDevice() {
try {
appendToTerminal('\nLoading workspace from device...\n');
// Use unique markers to identify workspace content
const timestamp = Date.now();
const startMarker = `__WS_START_${timestamp}__`;
const endMarker = `__WS_END_${timestamp}__`;
workspaceCaptureState = {
startMarker,
endMarker,
raw: '',
flushedPre: false,
};
const script = [
`try:`,
` f = open('workspace.json', 'r')`,
` data = f.read()`,
` f.close()`,
` print('${startMarker}')`,
` print(data, end='')`,
` print('${endMarker}')`,
`except Exception as e:`,
` print('Error reading workspace.json: ' + str(e))`,
].join('\n');
await executeCode(script);
// Clean up capture state after timeout
setTimeout(() => {
if (workspaceCaptureState) {
appendToTerminal('\nTimeout waiting for workspace data\n');
workspaceCaptureState = null;
}
}, 10000);
} catch (err) {
appendToTerminal(`\nLoad workspace error: ${err.message}\n`);
workspaceCaptureState = null;
}
}
btnSaveWorkspace.addEventListener('click', async () => {
await saveWorkspaceToDevice();
initProjectsDialog({
workspace,
captureDeviceOutput,
executeCode,
writeFileToDevice,
isConnected,
});
btnLoadWorkspace.addEventListener('click', async () => {
await loadWorkspaceFromDevice();
});
btnProjects.addEventListener('click', () => openProjects());
terminalInput.addEventListener('keydown', async (e) => {
if (e.key === 'Enter') {

View File

@ -291,6 +291,185 @@ html, body {
background: var(--text-muted);
}
/* --- Projects Dialog --- */
#projects-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
#projects-modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px 28px;
width: 680px;
max-width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
gap: 16px;
}
.projects-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.projects-header h3 {
margin: 0;
color: var(--accent);
font-size: 16px;
}
#projects-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
#projects-close:hover {
color: var(--text-primary);
}
.projects-columns {
display: flex;
gap: 16px;
}
.projects-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.projects-col h4 {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.6px;
}
.projects-list {
list-style: none;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
min-height: 180px;
max-height: 260px;
overflow-y: auto;
padding: 4px;
}
.projects-list li {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.projects-list li:hover {
background: var(--bg-surface);
}
.projects-list li.selected {
background: var(--accent);
color: var(--bg-toolbar);
}
.projects-list .empty-msg {
padding: 8px 12px;
color: var(--text-muted);
font-size: 12px;
font-style: italic;
cursor: default;
}
.projects-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.projects-name-row {
display: flex;
gap: 6px;
}
.projects-name-row input {
flex: 1;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
outline: none;
min-width: 0;
}
.projects-name-row input:focus {
border-color: var(--accent);
}
.projects-btn-row {
display: flex;
gap: 6px;
}
.projects-actions button {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
flex: 1;
}
.projects-actions button:hover:not(:disabled) {
background: var(--accent);
color: var(--bg-toolbar);
border-color: var(--accent);
}
.projects-actions button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.projects-actions button.btn-danger:hover:not(:disabled) {
background: var(--red);
border-color: var(--red);
}
.projects-note {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
min-height: 18px;
}
/* --- Flash Progress Overlay --- */
.hidden { display: none !important; }

242
src/ui/projectsDialog.js Normal file
View File

@ -0,0 +1,242 @@
import * as Blockly from 'blockly';
const BROWSER_STORAGE_KEY = 'esp32block_projects';
let workspace = null;
let captureOutput = null;
let execCode = null;
let writeFile = null;
let checkConnected = null;
// DOM refs (cached on first open)
let overlay, browserList, deviceList;
let browserNameInput, deviceNameInput;
let browserSaveBtn, browserLoadBtn, browserDeleteBtn;
let deviceSaveBtn, deviceLoadBtn, deviceDeleteBtn;
let deviceStatus;
let browserSelected = null;
let deviceSelected = null;
export function initProjectsDialog(deps) {
workspace = deps.workspace;
captureOutput = deps.captureDeviceOutput;
execCode = deps.executeCode;
writeFile = deps.writeFileToDevice;
checkConnected = deps.isConnected;
overlay = document.getElementById('projects-overlay');
browserList = document.getElementById('browser-list');
deviceList = document.getElementById('device-list');
browserNameInput = document.getElementById('browser-save-name');
deviceNameInput = document.getElementById('device-save-name');
browserSaveBtn = document.getElementById('browser-save-btn');
browserLoadBtn = document.getElementById('browser-load-btn');
browserDeleteBtn = document.getElementById('browser-delete-btn');
deviceSaveBtn = document.getElementById('device-save-btn');
deviceLoadBtn = document.getElementById('device-load-btn');
deviceDeleteBtn = document.getElementById('device-delete-btn');
deviceStatus = document.getElementById('device-status');
document.getElementById('projects-close').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
browserSaveBtn.addEventListener('click', saveBrowser);
browserLoadBtn.addEventListener('click', loadBrowser);
browserDeleteBtn.addEventListener('click', deleteBrowser);
deviceSaveBtn.addEventListener('click', saveDevice);
deviceLoadBtn.addEventListener('click', loadDevice);
deviceDeleteBtn.addEventListener('click', deleteDevice);
}
export function open() {
browserSelected = null;
deviceSelected = null;
overlay.classList.remove('hidden');
refreshBrowserList();
refreshDeviceList();
}
function close() {
overlay.classList.add('hidden');
}
// ─── Browser column ──────────────────────────────────────
function getBrowserProjects() {
try {
return JSON.parse(localStorage.getItem(BROWSER_STORAGE_KEY) || '{}');
} catch { return {}; }
}
function setBrowserProjects(projects) {
localStorage.setItem(BROWSER_STORAGE_KEY, JSON.stringify(projects));
}
function refreshBrowserList() {
const projects = getBrowserProjects();
const names = Object.keys(projects).sort();
browserList.innerHTML = '';
browserSelected = null;
updateBrowserButtons();
if (names.length === 0) {
browserList.innerHTML = '<li class="empty-msg">No saved projects</li>';
return;
}
for (const name of names) {
const li = document.createElement('li');
li.textContent = name;
li.addEventListener('click', () => selectBrowserItem(name, li));
li.addEventListener('dblclick', () => { selectBrowserItem(name, li); loadBrowser(); });
browserList.appendChild(li);
}
}
function selectBrowserItem(name, li) {
browserList.querySelectorAll('li').forEach(el => el.classList.remove('selected'));
li.classList.add('selected');
browserSelected = name;
browserNameInput.value = name;
updateBrowserButtons();
}
function updateBrowserButtons() {
browserLoadBtn.disabled = !browserSelected;
browserDeleteBtn.disabled = !browserSelected;
}
function saveBrowser() {
const name = browserNameInput.value.trim();
if (!name) return;
const projects = getBrowserProjects();
projects[name] = Blockly.serialization.workspaces.save(workspace);
setBrowserProjects(projects);
browserNameInput.value = '';
refreshBrowserList();
}
function loadBrowser() {
if (!browserSelected) return;
const projects = getBrowserProjects();
const state = projects[browserSelected];
if (!state) return;
Blockly.serialization.workspaces.load(state, workspace);
close();
}
function deleteBrowser() {
if (!browserSelected) return;
const projects = getBrowserProjects();
delete projects[browserSelected];
setBrowserProjects(projects);
refreshBrowserList();
}
// ─── Device column ───────────────────────────────────────
async function refreshDeviceList() {
deviceList.innerHTML = '';
deviceSelected = null;
updateDeviceButtons();
if (!checkConnected()) {
deviceStatus.textContent = 'Connect a device to see its projects';
deviceSaveBtn.disabled = true;
deviceList.innerHTML = '<li class="empty-msg">Not connected</li>';
return;
}
deviceStatus.textContent = 'Loading...';
deviceSaveBtn.disabled = false;
try {
const raw = await captureOutput(
"import os\n" +
"for f in os.listdir('/'):\n" +
" if f.endswith('.blk'): print(f)"
);
const files = raw.trim().split('\n').filter(Boolean).map(f => f.trim());
deviceList.innerHTML = '';
if (files.length === 0) {
deviceList.innerHTML = '<li class="empty-msg">No saved projects</li>';
deviceStatus.textContent = '';
return;
}
for (const file of files) {
const displayName = file.replace(/\.blk$/, '');
const li = document.createElement('li');
li.textContent = displayName;
li.addEventListener('click', () => selectDeviceItem(displayName, file, li));
li.addEventListener('dblclick', () => { selectDeviceItem(displayName, file, li); loadDevice(); });
deviceList.appendChild(li);
}
deviceStatus.textContent = '';
} catch {
deviceList.innerHTML = '<li class="empty-msg">Error reading device</li>';
deviceStatus.textContent = 'Could not list files';
}
}
function selectDeviceItem(displayName, filename, li) {
deviceList.querySelectorAll('li').forEach(el => el.classList.remove('selected'));
li.classList.add('selected');
deviceSelected = filename;
deviceNameInput.value = displayName;
updateDeviceButtons();
}
function updateDeviceButtons() {
const connected = checkConnected();
deviceSaveBtn.disabled = !connected;
deviceLoadBtn.disabled = !deviceSelected || !connected;
deviceDeleteBtn.disabled = !deviceSelected || !connected;
}
async function saveDevice() {
const name = deviceNameInput.value.trim();
if (!name || !checkConnected()) return;
const filename = name.endsWith('.blk') ? name : name + '.blk';
const state = Blockly.serialization.workspaces.save(workspace);
const json = JSON.stringify(state);
deviceStatus.textContent = 'Saving...';
deviceSaveBtn.disabled = true;
try {
await writeFile(json, filename);
deviceNameInput.value = '';
await refreshDeviceList();
} catch {
deviceStatus.textContent = 'Save failed';
}
}
async function loadDevice() {
if (!deviceSelected || !checkConnected()) return;
deviceStatus.textContent = 'Loading...';
try {
const raw = await captureOutput(
`f=open('${deviceSelected}','r')\nprint(f.read(),end='')\nf.close()`
);
const state = JSON.parse(raw.trim());
Blockly.serialization.workspaces.load(state, workspace);
close();
} catch {
deviceStatus.textContent = 'Load failed';
}
}
async function deleteDevice() {
if (!deviceSelected || !checkConnected()) return;
deviceStatus.textContent = 'Deleting...';
try {
await execCode(`import os\nos.remove('${deviceSelected}')`);
await new Promise(r => setTimeout(r, 300));
await refreshDeviceList();
} catch {
deviceStatus.textContent = 'Delete failed';
}
}