From 371c109def9d8aa5d004ef17faf2e9fa89e45e4a Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 19 May 2026 12:18:52 -0700 Subject: [PATCH 1/8] Make Save+Run wait for the save and stop on failure Refactor saveFileContents to use an awaitable retry loop and propagate its real success/failure to callers. Previously the catch block did a fire-and-forget setTimeout for retries while the outer call returned normally, so workflow.saveFile() always reported success even when the PUT had failed. That let saveRunFile() proceed to runCurrentCode() (soft-restart for code.py, import for everything else) before the file on the device was actually updated -- exactly the 'edits disappear after Save+Run' symptom from issue #460. js/script.js: - saveFileContents() now retries inline (3 attempts, 2s apart) and returns true on success / false on exhausted retries. It also early-exits if the workflow disconnects mid-retry, and refuses re-entrant invocations via a saveInFlight flag. - On final failure it leaves the editor marked dirty (setSaved(false)) so the user can see the file on the board is still stale, instead of silently flipping the UI back to 'saved'. - saveRunFile() drops the redundant setSaved(true); saveFileContents() already handles it on success, and we only reach runCurrentCode() when the save actually worked. - disconnectCallback() no longer juggles the retry timeout (the inline loop owns its own state now). js/workflows/workflow.js: - saveFile() returns the underlying _saveFileContents() result instead of unconditionally returning true. Uses result !== false so legacy callbacks that return undefined still count as success. Build verified with vite. The Qualia-specific root cause (RGB display contention starving the web server) is a firmware/board concern; this PR makes sure the editor stops pretending writes succeeded when they did not, and keeps the unsaved buffer recoverable. Closes #460 --- js/script.js | 118 ++++++++++++++++++++++++--------------- js/workflows/workflow.js | 10 +++- 2 files changed, 82 insertions(+), 46 deletions(-) diff --git a/js/script.js b/js/script.js index 6b35335..aa8876e 100644 --- a/js/script.js +++ b/js/script.js @@ -229,8 +229,11 @@ async function newFile() { async function saveRunFile() { if (await checkConnected()) { + // workflow.saveFile() now propagates the real save result -- only + // soft-restart / re-import once the PUT actually succeeded. Otherwise + // we would reboot the board running the old code.py while the editor + // still had the unsaved edits (issue #460). if (await workflow.saveFile()) { - setSaved(true); await workflow.runCurrentCode(); } } @@ -535,47 +538,80 @@ async function loadEditor() { } var editor; -var currentTimeout = null; -var saveRetryCount = 0; const MAX_SAVE_RETRIES = 3; +const SAVE_RETRY_DELAY_MS = 2000; +let saveInFlight = false; -// Save the File Contents and update the UI +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Save the File Contents and update the UI. Returns true on success, false +// on final failure (after all retries). Retries inline so callers (Save+Run, +// hotkeys, dialogs) can actually await the outcome -- previously this used +// a fire-and-forget setTimeout, which let Save+Run soft-restart the board +// before the PUT had succeeded (issue #460). async function saveFileContents(path) { - // If this is a different file, we write everything - if (path !== workflow.currentFilename) { - unchanged = 0; - } - let doc = editor.state.doc; - let offset = 0; - let contents = doc.sliceString(0); - if (workflow.partialWrites) { - offset = unchanged; - console.log("sync starting at", unchanged, "to", editor.state.doc.length); + if (saveInFlight) { + // Re-entrant save (e.g. user mashing Ctrl-S / Save+Run). The first + // call will report success/failure; the second would race the same + // bytes onto the wire and confuse partialWrites bookkeeping. + console.log("saveFileContents: already in flight, ignoring re-entry"); + return false; } - let oldUnchanged = unchanged; - unchanged = doc.length; + saveInFlight = true; try { - if (await workflow.writeFile(path, contents, offset)) { - setFilename(workflow.currentFilename); - setSaved(true); - saveRetryCount = 0; - } else { - await showMessage(`Saving file '${workflow.currentFilename}' failed.`); - } - } catch (e) { - console.error("write failed", e, e.stack); - unchanged = Math.min(oldUnchanged, unchanged); - if (currentTimeout != null) { - clearTimeout(currentTimeout); + // If this is a different file, we write everything + if (path !== workflow.currentFilename) { + unchanged = 0; } - saveRetryCount++; - if (saveRetryCount < MAX_SAVE_RETRIES) { - console.log(`Save retry ${saveRetryCount} of ${MAX_SAVE_RETRIES}...`); - currentTimeout = setTimeout(() => saveFileContents(path), 2000); - } else { - saveRetryCount = 0; - await showMessage(`Saving file '${workflow.currentFilename}' failed after multiple attempts. Check your connection and try again.`); + let doc = editor.state.doc; + let contents = doc.sliceString(0); + let baseUnchanged = unchanged; + let docLengthAtStart = doc.length; + + for (let attempt = 1; attempt <= MAX_SAVE_RETRIES; attempt++) { + // Recompute offset each attempt -- if onTextChange fired between + // retries, `unchanged` may have shrunk and we need to resend more. + let offset = 0; + if (workflow.partialWrites) { + offset = Math.min(baseUnchanged, unchanged); + console.log("sync starting at", offset, "to", editor.state.doc.length); + } + // Optimistically mark the bytes-being-sent as unchanged. If the + // write throws we'll roll back to baseUnchanged for the next try. + unchanged = docLengthAtStart; + try { + if (await workflow.writeFile(path, contents, offset)) { + setFilename(workflow.currentFilename); + setSaved(true); + return true; + } + // writeFile returned a falsy value without throwing -- treat + // as a soft failure and surface a message immediately. + await showMessage(`Saving file '${workflow.currentFilename}' failed.`); + setSaved(false); + return false; + } catch (e) { + console.error(`write failed (attempt ${attempt} of ${MAX_SAVE_RETRIES})`, e, e.stack); + unchanged = Math.min(baseUnchanged, unchanged); + if (attempt < MAX_SAVE_RETRIES) { + await sleep(SAVE_RETRY_DELAY_MS); + // Bail out if the user disconnected mid-retry. + if (!workflow || !workflow.connectionStatus()) { + setSaved(false); + return false; + } + } + } } + // All retries exhausted. Leave the editor marked dirty so the user + // knows the file on the board is still stale. + setSaved(false); + await showMessage(`Saving file '${workflow.currentFilename}' failed after multiple attempts. Check your connection and try again.`); + return false; + } finally { + saveInFlight = false; } } @@ -611,19 +647,13 @@ async function onTextChange(update) { unchanged = 0; } - if (currentTimeout != null) { - clearTimeout(currentTimeout); - } - setSaved(false); } function disconnectCallback() { - if (currentTimeout != null) { - clearTimeout(currentTimeout); - currentTimeout = null; - } - saveRetryCount = 0; + // saveInFlight is intentionally not forced here -- the in-flight + // saveFileContents loop checks connectionStatus() between retries and + // exits cleanly on its own, then clears the flag in its finally block. updateUIConnected(false); } diff --git a/js/workflows/workflow.js b/js/workflows/workflow.js index b472640..1e0ee7d 100644 --- a/js/workflows/workflow.js +++ b/js/workflows/workflow.js @@ -442,8 +442,14 @@ except ImportError: // canceled or rejected) is treated the same as null and does not get // forwarded to writeFile, where it would crash in _splitPath. See #327. if (path != null) { - await this._saveFileContents(path); - return true; + // Propagate the actual save result so Save+Run and other callers + // can avoid taking follow-up actions (soft-restart, import) when + // the underlying PUT failed. _saveFileContents returns false on + // exhausted retries; treating only an explicit `false` as failure + // keeps backwards compatibility with older saveFileFunc callbacks + // that returned undefined on success (issue #460). + const result = await this._saveFileContents(path); + return result !== false; } return false; } From fa2603732149d4af7f0748ca5433ebe9801d8ceb Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 19 May 2026 13:25:42 -0700 Subject: [PATCH 2/8] Surface 'eject CIRCUITPY' hint when save fails because filesystem is locked When the host has CIRCUITPY mounted over USB Mass Storage, the CircuitPython side of FatFS can't write through the block device and the web workflow PUT /fs/ fails. Older firmware returns HTTP 500 for this (lumped into the generic 'result != FR_OK' branch); a pending firmware fix (adafruit/circuitpython#11017) will return HTTP 409 Conflict to match DELETE / MOVE / mkdir-PUT in the same file. Users will be on mixed firmware versions for years, so the editor should handle both the same way. Changes: - web-file-transfer.js _fetch(): instead of throwing a bare ProtocolError(response.statusText), attach the numeric status, method, and path to the error. When the failure is a 409 OR 500 on a /fs/ PUT, tag the error with .writeProtected = true and a human-readable .hint string. Generic fetch errors are unaffected. - web-file-transfer.js writeFile(): return response.ok so the workflow layer sees real success / failure (matches what makeDir already does). - script.js saveFileContents(): when the caught error is tagged .writeProtected, skip the remaining retries (they can't help -- the host owns the disk) and show an actionable message naming USB-MSC and boot.py. Leaves the editor dirty so the unsaved buffer is recoverable once the user releases the drive. Backwards compatible with existing firmware: same retry loop applies to anything that isn't a recognized write-protected response, and the new error fields are additive (callers that ignore them get the same ProtocolError instance they used to). Refs adafruit/circuitpython#11017 Closes #460 --- js/common/web-file-transfer.js | 26 ++++++++++++++++++++++++-- js/script.js | 16 ++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/js/common/web-file-transfer.js b/js/common/web-file-transfer.js index ab2a010..d74127f 100644 --- a/js/common/web-file-transfer.js +++ b/js/common/web-file-transfer.js @@ -72,7 +72,8 @@ class FileTransferClient { options.headers['Content-Type'] = "application/octet-stream"; } - await this._fetch(`/fs${path}`, options); + const response = await this._fetch(`/fs${path}`, options); + return response.ok; } // Makes the directory and any missing parents @@ -120,7 +121,28 @@ class FileTransferClient { } if (!response.ok) { - throw new ProtocolError(response.statusText); + // Attach the status code + a friendly hint when we recognize + // the failure mode, so callers can branch on it (e.g. show an + // actionable message and skip retries that won't help). + const err = new ProtocolError(response.statusText || `HTTP ${response.status}`); + err.status = response.status; + err.method = (fetchOptions.method || "GET").toUpperCase(); + err.path = location; + // /fs/ PUT against a write-protected filesystem currently returns + // 500 on shipped CircuitPython firmware. A fix is pending to + // return 409 Conflict (matching DELETE / MOVE / mkdir-PUT in + // the same file). Treat both the same way until enough users + // are on the patched firmware that 500 can be left generic. + const isFsWrite = err.method === "PUT" && + typeof location === "string" && + location.startsWith("/fs/"); + if (isFsWrite && (response.status === 409 || response.status === 500)) { + err.writeProtected = true; + err.hint = "CIRCUITPY may be mounted on your computer. " + + "Eject it (or disable USB Mass Storage in boot.py) " + + "and try again."; + } + throw err; } return response; diff --git a/js/script.js b/js/script.js index aa8876e..b5382b2 100644 --- a/js/script.js +++ b/js/script.js @@ -595,6 +595,22 @@ async function saveFileContents(path) { } catch (e) { console.error(`write failed (attempt ${attempt} of ${MAX_SAVE_RETRIES})`, e, e.stack); unchanged = Math.min(baseUnchanged, unchanged); + // If the device cleanly told us the filesystem is held by + // someone else (most commonly USB-MSC: the host has + // CIRCUITPY mounted), retrying won't help -- surface an + // actionable hint immediately and bail. Older CircuitPython + // firmware returns 500 for this case, newer firmware + // returns 409 Conflict; web-file-transfer.js tags both + // with `writeProtected` so we can treat them the same way. + if (e && e.writeProtected) { + setSaved(false); + const hint = e.hint || "The filesystem is currently read-only."; + await showMessage( + `Saving file '${workflow.currentFilename}' failed: ${hint} ` + + `Your edits are still in the editor -- save again once the drive is released.` + ); + return false; + } if (attempt < MAX_SAVE_RETRIES) { await sleep(SAVE_RETRY_DELAY_MS); // Bail out if the user disconnected mid-retry. From 7f0a8b2293ca9407c28471a640725052dcd8afb2 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 19 May 2026 13:32:23 -0700 Subject: [PATCH 3/8] Soften 'eject CIRCUITPY' hint to match real-world behavior Testing on macOS showed that simply ejecting the CIRCUITPY drive in Finder does not always release the SCSI lock CircuitPython sees as STA_PROTECT -- the OS may unmount the filesystem locally without issuing the SCSI START_STOP_UNIT eject command that would trigger the board-side blockdev_unlock(). The save still fails until the USB cable is disconnected or USB MSC is disabled in boot.py and the board is reset. Reword the user-facing hint to: - Lead with what actually works (unplug USB / disable MSC in boot.py + reset). - Mention 'eject in your OS may not be enough on its own' so users who already tried eject don't bounce off the dialog. - Stop telling them to 'save again once the drive is released' -- releasing the OS-level mount isn't the meaningful state change; the filesystem being writable on the board is. No code path changes -- this is purely message wording. --- js/common/web-file-transfer.js | 10 +++++++--- js/script.js | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/js/common/web-file-transfer.js b/js/common/web-file-transfer.js index d74127f..8c28dfd 100644 --- a/js/common/web-file-transfer.js +++ b/js/common/web-file-transfer.js @@ -138,9 +138,13 @@ class FileTransferClient { location.startsWith("/fs/"); if (isFsWrite && (response.status === 409 || response.status === 500)) { err.writeProtected = true; - err.hint = "CIRCUITPY may be mounted on your computer. " + - "Eject it (or disable USB Mass Storage in boot.py) " + - "and try again."; + err.hint = "The board's filesystem is currently locked, " + + "usually because CIRCUITPY is mounted on a " + + "computer over USB. Disconnect the USB cable, " + + "or disable USB Mass Storage in boot.py, then " + + "reset the board and try saving again. " + + "(Ejecting the drive in your OS may not be " + + "enough on its own.)"; } throw err; } diff --git a/js/script.js b/js/script.js index b5382b2..56410b6 100644 --- a/js/script.js +++ b/js/script.js @@ -606,8 +606,8 @@ async function saveFileContents(path) { setSaved(false); const hint = e.hint || "The filesystem is currently read-only."; await showMessage( - `Saving file '${workflow.currentFilename}' failed: ${hint} ` + - `Your edits are still in the editor -- save again once the drive is released.` + `Saving file '${workflow.currentFilename}' failed. ${hint} ` + + `Your edits are still here in the editor -- save again once the board's filesystem is writable.` ); return false; } From 702f8b835c59b4757324b885e1375ee460b83533 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 19 May 2026 13:36:07 -0700 Subject: [PATCH 4/8] Link the dialog to the Adafruit Learn 'Disabling USB Mass Storage' section Add a clickable link in the write-protected save-failure dialog to the canonical Adafruit Learn guide section that walks users through disabling USB MSC in boot.py: https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964 Implementation: - web-file-transfer.js: split the help URL out of the prose hint into separate err.helpUrl + err.helpLabel fields. Keeps the hint string pure text and lets the dialog decide on markup. - script.js: when rendering the dialog, append a real tag with target=_blank rel=noopener noreferrer. MessageModal uses innerHTML so the anchor is clickable; opening in a new tab avoids nav-ing away from the editor and losing the unsaved buffer. Falls back to the plain text hint when helpUrl is absent, so callers that don't set it (or future writeProtected variants) still render sensibly. --- js/common/web-file-transfer.js | 2 ++ js/script.js | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/js/common/web-file-transfer.js b/js/common/web-file-transfer.js index 8c28dfd..eb2d880 100644 --- a/js/common/web-file-transfer.js +++ b/js/common/web-file-transfer.js @@ -145,6 +145,8 @@ class FileTransferClient { "reset the board and try saving again. " + "(Ejecting the drive in your OS may not be " + "enough on its own.)"; + err.helpUrl = "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964"; + err.helpLabel = "Disabling USB Mass Storage (Adafruit Learn)"; } throw err; } diff --git a/js/script.js b/js/script.js index 56410b6..bde30c6 100644 --- a/js/script.js +++ b/js/script.js @@ -605,8 +605,16 @@ async function saveFileContents(path) { if (e && e.writeProtected) { setSaved(false); const hint = e.hint || "The filesystem is currently read-only."; + let helpLink = ""; + if (e.helpUrl) { + const label = e.helpLabel || "More info"; + // MessageModal renders via innerHTML, so a real + // tag is clickable. target=_blank + noopener so we + // don't nav away from the editor. + helpLink = ` ${label}.`; + } await showMessage( - `Saving file '${workflow.currentFilename}' failed. ${hint} ` + + `Saving file '${workflow.currentFilename}' failed. ${hint}${helpLink} ` + `Your edits are still here in the editor -- save again once the board's filesystem is writable.` ); return false; From 5d9b159405cf97422f8530a7d919493355bb5440 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 19 May 2026 13:45:41 -0700 Subject: [PATCH 5/8] Debug logging for writeProtected save-failure detection Add console.warn at two points: - web-file-transfer.js _fetch: log method+path+status on any non-OK response, so we can see what the firmware actually sent back. - script.js saveFileContents catch block: log the relevant fields on the caught error (name/message/status/method/path/writeProtected), so we can see whether the heuristic in _fetch tagged it correctly. Temporary; remove once we've confirmed the field paths fire on real hardware. Cheap (warn-level, only runs on actual errors). --- js/common/web-file-transfer.js | 9 +++++++++ js/script.js | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/js/common/web-file-transfer.js b/js/common/web-file-transfer.js index eb2d880..27a5f6b 100644 --- a/js/common/web-file-transfer.js +++ b/js/common/web-file-transfer.js @@ -128,6 +128,15 @@ class FileTransferClient { err.status = response.status; err.method = (fetchOptions.method || "GET").toUpperCase(); err.path = location; + // DEBUG: leave this in until we're sure the writeProtected + // detection is firing in the field. Cheap, runs only on + // non-OK responses. + console.warn("[web-file-transfer] non-OK response", { + method: err.method, + path: err.path, + status: err.status, + statusText: response.statusText, + }); // /fs/ PUT against a write-protected filesystem currently returns // 500 on shipped CircuitPython firmware. A fix is pending to // return 409 Conflict (matching DELETE / MOVE / mkdir-PUT in diff --git a/js/script.js b/js/script.js index bde30c6..832fa6e 100644 --- a/js/script.js +++ b/js/script.js @@ -594,6 +594,14 @@ async function saveFileContents(path) { return false; } catch (e) { console.error(`write failed (attempt ${attempt} of ${MAX_SAVE_RETRIES})`, e, e.stack); + console.warn("[saveFileContents] caught error fields", { + name: e && e.name, + message: e && e.message, + status: e && e.status, + method: e && e.method, + path: e && e.path, + writeProtected: e && e.writeProtected, + }); unchanged = Math.min(baseUnchanged, unchanged); // If the device cleanly told us the filesystem is held by // someone else (most commonly USB-MSC: the host has From ca6ed808c4dd9b3079c2da7cfbc3709fb9721bff Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 19 May 2026 13:48:15 -0700 Subject: [PATCH 6/8] Route _checkWritable and connect-time read-only popup through the writeProtected dialog The previous PR handled HTTP 409/500 on PUT /fs/* by tagging the ProtocolError with .writeProtected and showing an actionable dialog in saveFileContents. But there are two earlier paths users hit FIRST that were still using the old generic wording: 1. At connect, checkReadOnly() in script.js queries /fs/ for the writable flag. If false, it popped a one-line warning that said 'Disable the USB drive' without telling users how, what 'eject may not be enough' means, or where to read more. 2. On save, writeFile() calls _checkWritable() FIRST. If the cached _writable flag is false, _checkWritable threw a plain Error (not a ProtocolError, no .writeProtected tag), so the catch block in saveFileContents never recognized it as the write-protected case and fell through to 'Saving file failed after multiple attempts.' This commit: - Factors the writeProtected ProtocolError shape into a new _writeProtectedError() helper on FileTransferClient, so both _checkWritable and the _fetch 409/500 handler return identical tagged errors (same .hint, .helpUrl, .helpLabel). - _checkWritable now invalidates the cached _writable flag before re-checking. Means users who release the drive can save again without disconnecting/reconnecting the workflow. - Updates checkReadOnly() in script.js to use the same wording + clickable Learn-guide link as the per-save dialog. Connect-time popup is now genuinely informative instead of cryptic. - Removes the debug console.warn lines from the previous commit now that we've confirmed the path (the read-only branch was bypassing the tagged ProtocolError, not a bug in _fetch). After this change, users see the same actionable message at all three trip points (connect, pre-save check, server-side response). The message names USB MSC, points at boot.py, warns that eject alone is unreliable on macOS, and links to the Learn guide section that walks them through disabling it. --- js/common/web-file-transfer.js | 54 ++++++++++++++++++++++------------ js/script.js | 22 ++++++++------ 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/js/common/web-file-transfer.js b/js/common/web-file-transfer.js index 27a5f6b..e0d5df1 100644 --- a/js/common/web-file-transfer.js +++ b/js/common/web-file-transfer.js @@ -50,9 +50,36 @@ class FileTransferClient { } } + // Build a ProtocolError-shaped error that callers can recognize as + // "the device's filesystem is currently held by something else" + // (typically USB MSC). Tagged identically to the runtime PUT 409/500 + // path so `saveFileContents()` can show the same actionable dialog + // whether the check trips on the cached writable flag or on the + // actual response from the device. + _writeProtectedError() { + const err = new ProtocolError("File System is Read Only."); + err.status = 409; + err.writeProtected = true; + err.hint = "The board's filesystem is currently locked, " + + "usually because CIRCUITPY is mounted on a " + + "computer over USB. Disconnect the USB cable, " + + "or disable USB Mass Storage in boot.py, then " + + "reset the board and try saving again. " + + "(Ejecting the drive in your OS may not be " + + "enough on its own.)"; + err.helpUrl = "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964"; + err.helpLabel = "Disabling USB Mass Storage (Adafruit Learn)"; + return err; + } + async _checkWritable() { + // Force a re-read of the writable flag so the user can recover + // without disconnecting: if they just released the drive (or + // disabled USB MSC and reset), the next save attempt should + // succeed, not bounce off a stale `false` cache. + this._writable = null; if (await this.readOnly()) { - throw new Error("File System is Read Only. Try disabling the USB Drive."); + throw this._writeProtectedError(); } } @@ -128,15 +155,6 @@ class FileTransferClient { err.status = response.status; err.method = (fetchOptions.method || "GET").toUpperCase(); err.path = location; - // DEBUG: leave this in until we're sure the writeProtected - // detection is firing in the field. Cheap, runs only on - // non-OK responses. - console.warn("[web-file-transfer] non-OK response", { - method: err.method, - path: err.path, - status: err.status, - statusText: response.statusText, - }); // /fs/ PUT against a write-protected filesystem currently returns // 500 on shipped CircuitPython firmware. A fix is pending to // return 409 Conflict (matching DELETE / MOVE / mkdir-PUT in @@ -146,16 +164,14 @@ class FileTransferClient { typeof location === "string" && location.startsWith("/fs/"); if (isFsWrite && (response.status === 409 || response.status === 500)) { + // Reuse the same wording/hint as the cached-flag + // _checkWritable() path, so users see one consistent + // message regardless of which layer caught the lock. + const wp = this._writeProtectedError(); err.writeProtected = true; - err.hint = "The board's filesystem is currently locked, " + - "usually because CIRCUITPY is mounted on a " + - "computer over USB. Disconnect the USB cable, " + - "or disable USB Mass Storage in boot.py, then " + - "reset the board and try saving again. " + - "(Ejecting the drive in your OS may not be " + - "enough on its own.)"; - err.helpUrl = "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964"; - err.helpLabel = "Disabling USB Mass Storage (Adafruit Learn)"; + err.hint = wp.hint; + err.helpUrl = wp.helpUrl; + err.helpLabel = wp.helpLabel; } throw err; } diff --git a/js/script.js b/js/script.js index 832fa6e..cfc0581 100644 --- a/js/script.js +++ b/js/script.js @@ -331,7 +331,19 @@ async function checkReadOnly() { await showMessage(readOnly); return false; } else if (readOnly) { - await showMessage("Warning: File System is in read only mode. Disable the USB drive to allow write access."); + // Same wording as the per-save dialog, with the Learn-guide + // link, so the very first popup users see at connect already + // points them at the right fix. + const learnUrl = "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964"; + const learnLabel = "Disabling USB Mass Storage (Adafruit Learn)"; + await showMessage( + "Warning: the board's filesystem is read-only, usually because " + + "CIRCUITPY is mounted on a computer over USB. You can browse and " + + "open files, but saving will fail until the lock is released. " + + "Disconnect the USB cable, or disable USB Mass Storage in boot.py, " + + "then reset the board. (Ejecting the drive in your OS may not be " + + `enough on its own.) ${learnLabel}.` + ); } return true; } @@ -594,14 +606,6 @@ async function saveFileContents(path) { return false; } catch (e) { console.error(`write failed (attempt ${attempt} of ${MAX_SAVE_RETRIES})`, e, e.stack); - console.warn("[saveFileContents] caught error fields", { - name: e && e.name, - message: e && e.message, - status: e && e.status, - method: e && e.method, - path: e && e.path, - writeProtected: e && e.writeProtected, - }); unchanged = Math.min(baseUnchanged, unchanged); // If the device cleanly told us the filesystem is held by // someone else (most commonly USB-MSC: the host has From 4bed4dff303d14b97c8b56cb8249c06d2e0a9fb0 Mon Sep 17 00:00:00 2001 From: Piclaw Date: Tue, 19 May 2026 13:52:07 -0700 Subject: [PATCH 7/8] Trim connect-time warning, structure save-failure dialog into sections Feedback from in-editor testing: the connect-time read-only warning was too wordy for a popup that appears on every connect to a read-only board, and the save-failure dialog was a wall of prose that's hard to scan at the moment a user actually has a problem. Connect-time warning is now a single line with a 'How to fix' link: Filesystem is read-only - you can browse files, but saving will fail until USB Mass Storage is released. [How to fix]. Save-failure dialog is now structured with real

, ,