diff --git a/src/main.js b/src/main.js
index 7904909..53845ee 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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') {
diff --git a/src/style.css b/src/style.css
index b904e91..26c183d 100644
--- a/src/style.css
+++ b/src/style.css
@@ -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; }
diff --git a/src/ui/projectsDialog.js b/src/ui/projectsDialog.js
new file mode 100644
index 0000000..ccaf42c
--- /dev/null
+++ b/src/ui/projectsDialog.js
@@ -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 = '
No saved projects';
+ 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 = 'Not connected';
+ 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 = 'No saved projects';
+ 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 = 'Error reading device';
+ 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';
+ }
+}