Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@
<div class="popup-modal shadow connect-dialog closable" data-popup-modal="ble-connect">
<i class="fa-solid fa-2x fa-xmark text-white bg-primary p-3 popup-modal__close"></i>
<div id="ble-instructions" class="connection-layout content">
<p class="connect-back-row"><a href="#" class="connect-back"><i class="fa-solid fa-arrow-left"></i> Choose a different workflow</a></p>
<p>If you are connecting to a new device, follow the steps below. Otherwise click the Reconnect button.</p>
<p><button class="purple-button" id="bleReconnect">Reconnect</button></p>
<section class="step">
Expand Down Expand Up @@ -268,6 +269,7 @@ <h1>Request Bluetooth Device</h1>
<div class="popup-modal shadow connect-dialog closable" data-popup-modal="web-connect">
<i class="fa-solid fa-2x fa-xmark text-white bg-primary p-3 popup-modal__close"></i>
<div id="web-instructions" class="connection-layout content">
<p class="connect-back-row"><a href="#" class="connect-back"><i class="fa-solid fa-arrow-left"></i> Choose a different workflow</a></p>
<section class="step">
<div class="step-number"></div>
<div class="step-content">
Expand Down Expand Up @@ -309,6 +311,7 @@ <h1>Navigate to your Device</h1>
<div class="popup-modal shadow connect-dialog closable" data-popup-modal="usb-connect">
<i class="fa-solid fa-2x fa-xmark text-white bg-primary p-3 popup-modal__close"></i>
<div id="usb-instructions" class="connection-layout content">
<p class="connect-back-row"><a href="#" class="connect-back"><i class="fa-solid fa-arrow-left"></i> Choose a different workflow</a></p>
<section class="step">
<div class="step-number"></div>
<div class="step-content">
Expand Down
65 changes: 62 additions & 3 deletions js/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,34 @@ const messageDialog = new MessageModal("message");
const connectionType = new ButtonValueDialog("connection-type");
const settings = new Settings();

// localStorage key used to remember the most recently chosen backend
// ("web" | "ble" | "usb"). When the user clicks Connect after a
// disconnect, we prefer the last backend over re-prompting for one.
const LAST_BACKEND_KEY = "webeditor.lastBackend";

function getLastBackend() {
try {
const name = window.localStorage.getItem(LAST_BACKEND_KEY);
if (name && isValidBackend(name)) {
return getBackendWorkflow(name);
}
} catch (e) {
// localStorage may be unavailable (privacy mode, etc.) — that's fine
}
return null;
}

function rememberLastBackend(workflowType) {
try {
const name = getWorkflowBackendName(workflowType);
if (name) {
window.localStorage.setItem(LAST_BACKEND_KEY, name);
}
} catch (e) {
// ignore — non-fatal
}
}

const editorTheme = EditorView.theme({}, {dark: getCssVar('editor-theme-dark').trim() === '1'});

document.addEventListener('DOMContentLoaded', function() {
Expand Down Expand Up @@ -218,9 +246,20 @@ function setSaved(saved) {

async function checkConnected() {
if (!workflow || !workflow.connectionStatus()) {
let connType = await chooseConnection();
if (!connType) {
return false;
let connType;

// Prefer the last backend the user successfully connected with
// (issue #373) so clicking Connect after a disconnect skips the
// chooser. The connect dialog itself has a "back" link that calls
// chooseAndShowConnect() if the user wants to switch workflows.
const lastBackend = getLastBackend();
if (lastBackend) {
connType = lastBackend;
} else {
connType = await chooseConnection();
if (!connType) {
return false;
}
}
await loadWorkflow(connType);

Expand All @@ -243,6 +282,24 @@ async function checkConnected() {
return true;
}

// Closes whatever connect dialog is open and re-opens the workflow chooser,
// then loads/connects to whichever workflow the user picks. Used by the
// "back" button inside each connect dialog (issue #373).
async function chooseAndShowConnect() {
if (workflow && workflow.connectDialog && workflow.connectDialog.isOpen()) {
workflow.connectDialog.close();
}
let connType = await chooseConnection();
if (!connType) {
return false;
}
await loadWorkflow(connType);
if (!workflow.connectionStatus()) {
await workflow.showConnect(getDocState());
}
return true;
}

function getDocState() {
return workflow.makeDocState(editor.state.doc.sliceString(0), unchanged);
}
Expand Down Expand Up @@ -361,6 +418,7 @@ async function loadWorkflow(workflowType = null) {
}
}
workflow = workflows[workflowType];
rememberLastBackend(workflowType);
// Initialize the workflow
await workflow.init({
terminal: state.terminal,
Expand All @@ -376,6 +434,7 @@ async function loadWorkflow(workflowType = null) {
showMessageFunc: showMessage,
currentFilename: currentFilename,
showSerialFunc: showSerial,
chooseConnectionFunc: chooseAndShowConnect,
});
} else {
console.log("Reload workflow");
Expand Down
1 change: 1 addition & 0 deletions js/workflows/ble.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class BLEWorkflow extends Workflow {
async showConnect(documentState) {
let p = this.connectDialog.open();
let modal = this.connectDialog.getModal();
this._wireBackToChooser(modal);
btnRequestBluetoothDevice = modal.querySelector('#requestBluetoothDevice');
btnReconnect = modal.querySelector('#bleReconnect');

Expand Down
65 changes: 55 additions & 10 deletions js/workflows/usb.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,28 @@ class USBWorkflow extends Workflow {
}

async onDisconnected(e, reconnect = true) {
// Guard against the disconnect callbacks firing twice (e.g. the
// "disconnect" event AND the read-loop catch both call us when a
// device is unplugged — see issue #373).
if (this._isDisconnecting) {
return;
}
this._isDisconnecting = true;

// If we got here because the underlying device was lost (NetworkError),
// the writable stream is errored and any further writes will throw
// unhandled NetworkErrors. Force reconnect=false so we don't loop
// back through connect() on a gone device; the user can click Connect
// (which now remembers the last backend — issue #373) to try again.
let safeReconnect = reconnect;

if (this.reader) {
try {
await this.reader.cancel();
} catch (error) {
// Reader cancel can reject with NetworkError on a lost device
console.warn("Error calling reader.cancel:", error);
safeReconnect = false;
}
this.reader = null;
}
Expand All @@ -65,6 +82,7 @@ class USBWorkflow extends Workflow {
await this.writer.releaseLock();
} catch (error) {
console.warn("Error calling writer.releaseLock:", error);
safeReconnect = false;
}
this.writer = null;
}
Expand All @@ -74,24 +92,45 @@ class USBWorkflow extends Workflow {
await this._serialDevice.close();
} catch (error) {
console.warn("Error calling _serialDevice.close:", error);
safeReconnect = false;
}
this._serialDevice = null;
}

super.onDisconnected(e, reconnect);
try {
await super.onDisconnected(e, safeReconnect);
} finally {
this._isDisconnecting = false;
}
}

async serialTransmit(msg) {
const encoder = new TextEncoder();
if (this.writer) {
const encMessage = encoder.encode(msg);
await this.writer.ready.catch((err) => {
console.error(`Ready error: ${err}`);
});
await this.writer.write(encMessage).catch((err) => {
console.error(`Chunk error: ${err}`);
});
if (!this.writer) {
return;
}
const encMessage = encoder.encode(msg);

// Any of these awaits can reject with NetworkError if the device was
// unplugged or otherwise lost (#373). We treat any of those as a
// disconnect event and stop touching the writer instead of letting the
// unhandled rejection escape (which used to spam the console at
// script.js:592).
const handleTransmitError = async (err) => {
console.warn("Serial transmit error, treating as disconnect:", err);
// Don't try to reconnect automatically — the device is gone, and
// the user clicking Connect will pick up the last backend.
await this.onDisconnected(null, false);
};

try {
await this.writer.ready;
if (!this.writer) return; // disconnected during await
await this.writer.write(encMessage);
if (!this.writer) return;
await this.writer.ready;
} catch (err) {
await handleTransmitError(err);
}
}

Expand Down Expand Up @@ -166,6 +205,7 @@ class USBWorkflow extends Workflow {
async showConnect(documentState) {
let p = this.connectDialog.open();
let modal = this.connectDialog.getModal();
this._wireBackToChooser(modal);
btnRequestSerialDevice = modal.querySelector('#requestSerialDevice');
btnSelectHostFolder = modal.querySelector('#selectHostFolder');
btnUseHostFolder = modal.querySelector('#useHostFolder');
Expand Down Expand Up @@ -298,9 +338,14 @@ class USBWorkflow extends Workflow {
console.log("switch to", this._serialDevice);
await this._serialDevice.open({baudRate: 115200}); // Throws if something else is already connected or it isn't found.
console.log("Starting Read Loop");
// Pass reconnect=false: if the read loop bails out, the device is
// most likely gone (NetworkError: device has been lost). Trying to
// immediately connect() again would re-enter this method on a dead
// device and surface another unhandled NetworkError (#373).
this._readLoopPromise = this._readSerialLoop().catch(
async function(error) {
await this.onDisconnected();
console.warn("Read loop ended with error:", error);
await this.onDisconnected(null, false);
}.bind(this)
);

Expand Down
40 changes: 32 additions & 8 deletions js/workflows/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,21 @@ class WebWorkflow extends Workflow {

async serialTransmit(msg) {
// Use an open web socket to transmit serial data
if (this.websocket) {
let value = decodeURIComponent(escape(msg));
try {
this.websocket.send(value);
} catch (e) {
console.log("caught write error", e, e.stack);
}
if (!this.websocket) {
return;
}
// If the socket is closing/closed, don't even try — just clean up.
if (this.websocket.readyState !== WebSocket.OPEN) {
console.warn("Serial transmit on a non-open websocket; treating as disconnect");
await this.onDisconnected(null, false);
return;
}
let value = decodeURIComponent(escape(msg));
try {
this.websocket.send(value);
} catch (e) {
console.warn("caught write error, treating as disconnect:", e);
await this.onDisconnected(null, false);
}
}

Expand All @@ -61,7 +69,13 @@ class WebWorkflow extends Workflow {
async onConnected(e) {
this.debugLog("connected");
await super.onConnected(e);
//this.connIntervalId = setInterval(this._checkConnection.bind(this), PING_INTERVAL_MS);
// Periodic ping so we notice silent network drops (issue #373).
// _checkConnection() calls onDisconnected(null, false) when a ping
// times out, which flips the UI back to "Connect".
if (this.connIntervalId) {
clearInterval(this.connIntervalId);
}
this.connIntervalId = setInterval(this._checkConnection.bind(this), PING_INTERVAL_MS);
}

async onDisconnected(e, reconnect = true) {
Expand All @@ -85,6 +99,7 @@ class WebWorkflow extends Workflow {
async showConnect(documentState) {
const p = this.connectDialog.open();
const modal = this.connectDialog.getModal();
this._wireBackToChooser(modal);
const deviceLink = modal.querySelector("#device-link");
deviceLink.addEventListener("click", (event) => {
event.preventDefault();
Expand Down Expand Up @@ -131,6 +146,15 @@ class WebWorkflow extends Workflow {
this.websocket.onopen = this.onConnected.bind(this);
this.websocket.onmessage = this.onSerialReceive.bind(this);
this.websocket.onclose = this.onDisconnected.bind(this);
// A websocket error (network drop, device reboot) does not
// always fire onclose cleanly. Treat onerror as a hard
// disconnect so the UI flips back to "Connect" (issue #373).
this.websocket.onerror = (event) => {
console.warn("WebSocket error, treating as disconnect:", event);
this.onDisconnected(event, false).catch((err) => {
console.warn("onDisconnected after socket error failed:", err);
});
};
return true;
} catch (e) {
//console.log(e, e.stack);
Expand Down
30 changes: 30 additions & 0 deletions js/workflows/workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class Workflow {
this.plotterChart = false;
this.buttonStates = [];
this.connectButtons = {};
// Caller-supplied callback used by the "back to workflow chooser"
// button on each connect dialog (issue #373). Set in init().
this.chooseConnection = null;
}

async init(params) {
Expand All @@ -73,6 +76,9 @@ class Workflow {
}
this.currentFilename = params.currentFilename;
this._showSerial = params.showSerialFunc;
if (params.chooseConnectionFunc) {
this.chooseConnection = params.chooseConnectionFunc;
}

this.repl.setTitle = this.setTerminalTitle.bind(this);
this.repl.writeToTerminal = this.writeToTerminal.bind(this);
Expand Down Expand Up @@ -221,6 +227,30 @@ except ImportError:
return await this.connectDialog.open();
}

// Wires up the "Choose a different workflow" link inside a connect
// dialog. Each subclass calls this from showConnect() after it has
// resolved its modal. The link/button is selected by the
// `.connect-back` class so the markup stays consistent across dialogs.
_wireBackToChooser(modal) {
if (!modal || !this.chooseConnection) {
return;
}
const backLinks = modal.querySelectorAll('.connect-back');
backLinks.forEach((el) => {
const handler = async (e) => {
e.preventDefault();
e.stopPropagation();
await this.chooseConnection();
};
// Remove a previously attached handler (idempotent re-open)
if (el._connectBackHandler) {
el.removeEventListener('click', el._connectBackHandler);
}
el._connectBackHandler = handler;
el.addEventListener('click', handler);
});
}

async runCurrentCode() {
let path = this.currentFilename;

Expand Down
22 changes: 22 additions & 0 deletions sass/layout/_layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,28 @@
}
}

// "Choose a different workflow" back link inside each connect dialog
// (issue #373). Sits above the existing instructions, indented in from
// the modal close button.
.connect-back-row {
margin: 0 0 1rem 0;
font-size: 0.9rem;

a.connect-back {
text-decoration: none;
opacity: 0.8;

&:hover {
opacity: 1;
text-decoration: underline;
}

i {
margin-right: 0.35rem;
}
}
}

.loader {
display: none;
z-index: 9998;
Expand Down