-
Notifications
You must be signed in to change notification settings - Fork 28
Expand file tree
/
Copy pathscript.js
More file actions
976 lines (874 loc) · 35.2 KB
/
Copy pathscript.js
File metadata and controls
976 lines (874 loc) · 35.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
import { basicSetup } from "codemirror";
import { EditorView, keymap } from "@codemirror/view";
import { EditorState, Compartment } from "@codemirror/state";
import { indentWithTab } from "@codemirror/commands"
import { python } from "@codemirror/lang-python";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { javascript } from "@codemirror/lang-javascript";
import { xml } from "@codemirror/lang-xml";
import { markdown } from "@codemirror/lang-markdown";
import { syntaxHighlighting, indentUnit } from "@codemirror/language";
import { classHighlighter } from "@lezer/highlight";
import { circuitpythonHighlight } from "./common/circuitpython_highlight.js";
import { getFileIcon } from "./common/file_dialog.js";
import { Terminal } from '@xterm/xterm';
import { WebLinksAddon } from '@xterm/addon-web-links';
import state from './state.js'
import { BLEWorkflow } from './workflows/ble.js';
import { USBWorkflow } from './workflows/usb.js';
import { WebWorkflow } from './workflows/web.js';
import { isValidBackend, getBackendWorkflow, getWorkflowBackendName } from './workflows/workflow.js';
import { ButtonValueDialog, MessageModal } from './common/dialogs.js';
import { isLocal, isMdns, isIp, switchUrl, getUrlParam } from './common/utilities.js';
import { Settings } from './common/settings.js';
import { CONNTYPE } from './constants.js';
import './layout.js'; // load for side effects only
import { setupPlotterChart } from "./common/plotter.js";
import { mainContent, showSerial } from './layout.js';
// Instantiate workflows
let workflows = {};
workflows[CONNTYPE.Ble] = new BLEWorkflow();
workflows[CONNTYPE.Usb] = new USBWorkflow();
workflows[CONNTYPE.Web] = new WebWorkflow();
let workflow = null;
let unchanged = 0;
let connectionPromise = null;
let debugMessageAnsi = null;
const btnRestart = document.querySelector('.btn-restart');
const btnHalt = document.querySelector('.btn-halt');
const btnPlotter = document.querySelector('.btn-plotter');
const btnClear = document.querySelector('.btn-clear');
const btnConnect = document.querySelectorAll('.btn-connect');
const btnNew = document.querySelectorAll('.btn-new');
const btnOpen = document.querySelectorAll('.btn-open');
const btnSave = document.querySelectorAll('.btn-save');
const btnSaveAs = document.querySelectorAll('.btn-save-as');
const btnSaveRun = document.querySelectorAll('.btn-save-run');
const btnInfo = document.querySelector('.btn-info');
const btnSettings = document.querySelector('.btn-settings');
const terminalTitle = document.getElementById('terminal-title');
const serialPlotter = document.getElementById('plotter');
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'});
// Map file extensions to a CodeMirror 6 language extension factory.
// Anything not in this map falls back to plain text (no language plugin).
// Python is handled separately because it also gets the CircuitPython
// highlight overlay.
const LANGUAGE_EXTENSION_MAP = {
"css": css,
"htm": html,
"html": html,
"js": javascript,
"json": json,
"md": markdown,
"xml": xml,
};
function getFileExtensionFromPath(path) {
if (!path) return null;
// Use the basename so a dotted directory in the path doesn't fool us.
const base = path.split("/").pop();
if (!base || base.indexOf(".") < 0) return null;
return base.split(".").pop().toLowerCase();
}
// Pick the CodeMirror language extensions to use for a given file path.
// Returns an array so callers can spread it directly into the editor's
// extension list. New (untitled) docs default to Python so the editor
// behaves the same as before for the common "create code.py" case.
function languageExtensionsForPath(path) {
if (path === null || path === undefined) {
return [python(), circuitpythonHighlight];
}
const ext = getFileExtensionFromPath(path);
if (ext === "py") {
return [python(), circuitpythonHighlight];
}
if (ext && Object.prototype.hasOwnProperty.call(LANGUAGE_EXTENSION_MAP, ext)) {
return [LANGUAGE_EXTENSION_MAP[ext]()];
}
return [];
}
// Compartment used so we can hot-swap the language plugin when the
// active file's extension changes (e.g. user opens an .html file, or
// uses Save As to rename code.py to test.html).
const languageCompartment = new Compartment();
// Track which path the editor's language plugin is currently configured
// for, so we can decide whether a reconfigure is actually needed. We
// can't compare against `workflow.currentFilename` because
// `workflow.saveFileAs()` mutates that BEFORE the post-save
// `setFilename` callback runs, so by the time we'd see it the
// "old" path is already gone.
let editorLanguagePath = null;
function extensionKey(path) {
if (path === null || path === undefined) return "__null__";
const ext = getFileExtensionFromPath(path);
return ext || "__noext__";
}
// Apply the language plugin matching `path` to the running editor.
// Safe to call before `editor` exists (the initial state already gets
// the correct language via languageCompartment.of(...) below).
function setEditorLanguageForPath(path) {
if (!editor) {
editorLanguagePath = path;
return;
}
if (extensionKey(path) === extensionKey(editorLanguagePath)) {
// Same language plugin would be installed — skip the
// reconfigure to avoid needlessly resetting language-internal
// state (folds, parser caches, etc.).
return;
}
editorLanguagePath = path;
editor.dispatch({
effects: languageCompartment.reconfigure(
languageExtensionsForPath(path),
),
});
}
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('mobile-menu-button').addEventListener('click', handleMobileToggle);
document.querySelectorAll('#mobile-menu-contents li a').forEach((element) => {
element.addEventListener('click', handleMobileToggle);
});
});
function handleMobileToggle(event) {
event.preventDefault();
var menuContainer = document.getElementById('mobile-menu-contents');
menuContainer.classList.toggle('hidden');
var menuIcon = document.querySelector('#mobile-menu-button > i');
if (menuContainer.classList.contains('hidden')) {
menuIcon.classList.replace('fa-times', 'fa-bars');
} else {
menuIcon.classList.replace('fa-bars', 'fa-times');
}
}
// New Link/Button (Mobile and Desktop Layout)
btnNew.forEach((element) => {
element.addEventListener('click', async function(e) {
e.preventDefault();
e.stopPropagation();
await newFile();
});
});
// Open Link/Button (Mobile and Desktop Layout)
btnOpen.forEach((element) => {
element.addEventListener('click', async function(e) {
e.preventDefault();
e.stopPropagation();
await openFile();
});
});
// Save Link/Button (Mobile and Desktop Layout)
btnSave.forEach((element) => {
element.addEventListener('click', async function(e) {
e.preventDefault();
e.stopPropagation();
await saveFile();
});
});
// Save As Link/Button (Mobile and Desktop Layout)
btnSaveAs.forEach((element) => {
element.addEventListener('click', async function(e) {
e.preventDefault();
e.stopPropagation();
if (await checkConnected()) {
let path = await workflow.saveFileAs();
if (path !== null) {
console.log("Current File Changed to: " + workflow.currentFilename);
}
}
});
});
// Save + Run Link/Button (Mobile and Desktop Layout)
btnSaveRun.forEach((element) => {
element.addEventListener('click', async function(e) {
e.preventDefault();
e.stopPropagation();
await saveRunFile();
});
});
// Restart Button
btnRestart.addEventListener('click', async function(e) {
if (await checkConnected()) {
// Perform a device soft restart
await workflow.restartDevice();
}
});
// Halt Button
btnHalt.addEventListener('click', async function(e) {
if (await checkConnected()) {
// Perform a device soft halt
await workflow.haltScript();
}
});
// Clear Button
btnClear.addEventListener('click', async function(e) {
if (workflow.plotterChart){
workflow.plotterChart.data.datasets.forEach((dataSet, index) => {
workflow.plotterChart.data.datasets[index].data = [];
});
workflow.plotterChart.data.labels = [];
workflow.plotterChart.options.scales.y.min = -1;
workflow.plotterChart.options.scales.y.max = 1;
workflow.plotterChart.update();
}
state.terminal.clear();
});
// Plotter Button
btnPlotter.addEventListener('click', async function(e){
serialPlotter.classList.toggle("hidden");
if (workflow && !workflow.plotterEnabled){
await setupPlotterChart(workflow);
workflow.plotterEnabled = true;
}
});
btnInfo.addEventListener('click', async function(e) {
if (await checkConnected()) {
await workflow.showInfo(getDocState());
}
});
btnSettings.addEventListener('click', async function(e) {
if (await settings.showDialog()) {
applySettings();
}
});
// Basic functions used for buttons and hotkeys
async function openFile() {
if (await checkConnected()) {
workflow.openFile();
}
}
async function saveFile() {
if (await checkConnected()) {
await workflow.saveFile();
}
}
async function newFile() {
if (await checkConnected()) {
if (await workflow.checkSaved()) {
loadFileContents(null, "");
}
}
}
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()) {
await workflow.runCurrentCode();
}
}
}
function setSaved(saved) {
if (saved) {
mainContent.classList.remove("unsaved");
} else {
mainContent.classList.add("unsaved");
}
}
async function checkConnected() {
if (!workflow || !workflow.connectionStatus()) {
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);
// Connect if we're local (Web Workflow Only)
if ((isLocal()) && workflow.host) {
if (await workflowConnect()) {
await checkReadOnly();
}
}
if (!workflow.connectionStatus()) {
// Display the appropriate connection dialog
await workflow.showConnect(getDocState());
}
// Note: the Device Info dialog is now opened from loadEditor() so that
// BLE/USB/Web all behave the same way after a fresh connect.
}
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);
}
async function workflowConnect() {
let returnVal;
if (!workflow) return false;
if ((returnVal = await workflow.showBusy(workflow.connect())) instanceof Error) {
await showMessage(`Unable to connect. ${returnVal.message}`);
return false;
}
return true;
}
async function checkReadOnly() {
const readOnly = await workflow.readOnly();
btnSaveAs.forEach((element) => {
element.disabled = readOnly;
});
btnSaveRun.forEach((element) => {
element.disabled = readOnly;
});
if (readOnly instanceof Error) {
await showMessage(readOnly);
return false;
} else if (readOnly) {
// Concise connect-time notice that the filesystem is read-only,
// with a link to the Learn guide for users who want the fix now.
// The cause is the board's USB Mass Storage support being
// enabled (the default on most boards) — saving from web
// workflow stays blocked whether or not a host actively has
// CIRCUITPY mounted.
const learnUrl = "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964";
await showMessage(
"Filesystem is read-only — you can browse files, but saving " +
"will fail while USB Mass Storage is enabled on the board. " +
`<a href="${learnUrl}" target="_blank" rel="noopener noreferrer">How to fix</a>.`
);
}
return true;
}
/* Update the filename and update the UI */
function setFilename(path) {
// Refresh the CodeMirror language plugin whenever the active file
// changes — this is the single chokepoint that all filename
// changes route through (Open File, New File, Save As, backend
// load), so it's the right place to keep the language in sync.
setEditorLanguageForPath(path);
// Use the extension_map to figure out the file icon
let filename = path;
// Prepend an icon to the path
const [style, icon] = getFileIcon(path);
filename = `<i class="${style} ${icon}"></i> ` + filename;
if (path === null) {
filename = "[New Document]";
btnSave.forEach((b) => b.style.display = 'none');
} else if (!workflow) {
throw Error("Unable to set path when no workflow is loaded");
} else {
btnSave.forEach((b) => b.style.display = null);
}
if (workflow) {
workflow.currentFilename = path;
}
document.querySelector('#editor-bar .file-path').innerHTML = filename;
document.querySelector('#mobile-editor-bar .file-path').innerHTML = path === null ? filename : filename.split("/")[filename.split("/").length - 1];
}
async function chooseConnection() {
// Don't allow more than one dialog
if (connectionPromise) return;
// Get the promise first
connectionPromise = connectionType.open();
// Disable any buttons in validBackends, but not in workflows
let modal = connectionType.getModal();
let buttons = modal.querySelectorAll("button");
for (let button of buttons) {
if (!getBackendWorkflow(button.value)) {
button.disabled = true;
}
};
// Wait for the user to click a button
let connType = await connectionPromise;
connectionPromise = null
if (isValidBackend(connType)) {
return getBackendWorkflow(connType);
}
// Outside of dialog was clicked
return null;
}
// Dynamically Load a Workflow (where the magic happens)
async function loadWorkflow(workflowType = null) {
let currentFilename = null;
if (workflow && workflowType == null) {
// Get the last workflow
workflowType = workflow.type;
}
if (!(workflowType in workflows) && workflowType != CONNTYPE.None) {
return false;
}
// Unload anything from the current workflow
if (workflow != null) {
// Update Workflow specific UI elements
await workflow.deinit();
}
if (workflowType != CONNTYPE.None) {
// Is the requested workflow different than the currently loaded one?
if (workflow != workflows[workflowType]) {
console.log("Load different workflow");
if (workflow) {
currentFilename = workflow.currentFilename;
if (isLocal()) {
let url = "https://code.circuitpython.org";
if (location.hostname == "localhost" || location.hostname == "127.0.0.1") {
url = `${location.protocol}//${location.host}`;
}
switchUrl(url, getDocState(), getWorkflowBackendName(workflowType));
}
}
workflow = workflows[workflowType];
rememberLastBackend(workflowType);
// Initialize the workflow
await workflow.init({
terminal: state.terminal,
terminalTitle: terminalTitle,
loadEditorFunc: loadEditor,
debugLogFunc: debugLog,
disconnectFunc: disconnectCallback,
isDirtyFunc: isDirty,
setFilenameFunc: setFilename,
saveFileFunc: saveFileContents,
loadFileFunc: loadFileContents,
loadEditorContentsFunc: loadEditorContents,
showMessageFunc: showMessage,
currentFilename: currentFilename,
showSerialFunc: showSerial,
chooseConnectionFunc: chooseAndShowConnect,
});
} else {
console.log("Reload workflow");
}
} else {
console.log("Unload workflow");
if (workflow != null) {
// Update Workflow specific UI elements
await workflow.disconnectButtonHandler();
}
// Unload workflow
workflow = null;
}
}
const hotkeyMap = [
{ key: "Mod-s", run: saveFile },
{ key: "Mod-o", run: openFile },
{ key: "Alt-n", run: newFile },
{ key: "Mod-r", run: saveRunFile },
];
// Extensions that are always present, regardless of file type. The
// per-file language extensions live in `languageCompartment` so they
// can be swapped at runtime (e.g. on Save As to a different
// extension).
const baseEditorExtensions = [
basicSetup,
keymap.of([indentWithTab]),
keymap.of(hotkeyMap),
indentUnit.of(" "),
editorTheme,
syntaxHighlighting(classHighlighter),
EditorView.updateListener.of(onTextChange)
];
function buildEditorExtensions(path) {
return [
...baseEditorExtensions,
languageCompartment.of(languageExtensionsForPath(path)),
];
}
// Use the editor's function to check if anything has changed
function isDirty() {
if (unchanged == editor.state.doc.length) return false;
return true;
}
function loadEditorContents(content, path = null) {
editor.setState(EditorState.create({
doc: content,
extensions: buildEditorExtensions(path)
}));
// Keep our tracked language path in sync with the fresh state's
// compartment contents so the next setEditorLanguageForPath call
// can correctly skip a no-op reconfigure.
editorLanguagePath = path;
unchanged = editor.state.doc.length;
//console.log("doc length", unchanged);
}
setFilename(null);
async function showMessage(message) {
return await messageDialog.open(message);
}
async function debugLog(msg) {
if (debugMessageAnsi === null) {
const colorCode = getCssVar('debug-message-color').trim();
debugMessageAnsi = `\x1b[38;2;${parseInt(colorCode.slice(1,3),16)};${parseInt(colorCode.slice(3,5),16)};${parseInt(colorCode.slice(5,7),16)}m`;
}
state.terminal.writeln(''); // get a fresh line without any prior content (a '>>>' prompt might be there without newline)
state.terminal.writeln(`${debugMessageAnsi}${msg}\x1b[0m`);
}
function updateUIConnected(isConnected) {
if (isConnected) {
// Set to Connected State
btnConnect.forEach((element) => {
element.innerHTML = "Disconnect";
element.disabled = false;
});
if (workflow.showInfo !== undefined) {
btnInfo.disabled = false;
}
} else {
// Set to Disconnected State
btnConnect.forEach((element) => {
element.innerHTML = "Connect";
element.disabled = false;
});
btnInfo.disabled = true;
}
}
window.onbeforeunload = () => {
if (isDirty()) {
return "You have unsaved changed, exit anyways?";
}
};
// Tracks whether we've already shown the post-connect Device Info dialog
// for the current workflow. Reset to false in disconnectCallback() so that
// a fresh connect always re-shows it, while silent reconnects (which also
// run loadEditor) do not.
let shownDeviceInfoForCurrentSession = false;
async function loadEditor() {
let documentState = loadParameterizedContent();
if (documentState) {
loadFileContents(documentState.path, documentState.contents, null);
unchanged = documentState.pos;
setSaved(!isDirty());
}
updateUIConnected(true);
// Show the Device Info dialog once per fresh connect, regardless of
// workflow (Web / USB / BLE). This is where the firmware-update
// suggestion (issue #357) is surfaced, so the user notices it just
// after connecting without us introducing a new dialog.
//
// Fire-and-forget: we don't await the dialog because it stays open until
// the user dismisses it, and we don't want to block the rest of the
// post-connect flow (busy spinner, parameterized doc loading, etc.).
if (!shownDeviceInfoForCurrentSession
&& workflow
&& workflow.showInfo
&& workflow.connectionStatus && workflow.connectionStatus()) {
shownDeviceInfoForCurrentSession = true;
Promise.resolve()
.then(() => workflow.showInfo(getDocState()))
.catch((err) => console.warn("Could not show device info dialog", err));
}
}
var editor;
const MAX_SAVE_RETRIES = 3;
const SAVE_RETRY_DELAY_MS = 2000;
let saveInFlight = false;
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 (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;
}
saveInFlight = true;
try {
// If this is a different file, we write everything. The language
// plugin is refreshed by setFilename below (it routes through
// setEditorLanguageForPath), so no extra dispatch is needed here.
if (path !== workflow.currentFilename) {
unchanged = 0;
}
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 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 learnUrl = e.helpUrl || "https://learn.adafruit.com/getting-started-with-web-workflow-using-the-code-editor/device-setup#disabling-usb-mass-storage-3125964";
const learnLabel = e.helpLabel || "Disabling USB Mass Storage (Adafruit Learn)";
// MessageModal renders via innerHTML, so real markup
// (sections, list, link) is fine. Sections separate the
// 'what happened', 'why', and 'how to fix' so users can
// scan instead of parsing a wall of prose.
//
// We intentionally don't assert that a host has
// CIRCUITPY mounted: the board's filesystem becomes
// read-only to web workflow whenever USB Mass
// Storage is enabled on the board (the default for
// most CircuitPython boards), even on a power-only
// USB connection or a wall adapter. See #460 for
// the full discussion.
await showMessage(
`<p><strong>Could not save '${workflow.currentFilename}'.</strong></p>` +
`<p>The board's filesystem is in read-only mode for web workflow. ` +
`This happens whenever USB Mass Storage is enabled on the board ` +
`(the default for most CircuitPython boards), whether or not a ` +
`host computer is actively using it.</p>` +
`<p><strong>To fix:</strong></p>` +
`<ul style="margin: 0.25em 0 0.5em 1.25em; padding: 0;">` +
`<li>Disable USB Mass Storage in <code>boot.py</code>, then reset the board, <em>or</em></li>` +
`<li>If a computer has CIRCUITPY mounted, eject the drive and disconnect the USB data connection.</li>` +
`</ul>` +
`<p><em>Heads up:</em> disabling USB Mass Storage in <code>boot.py</code> means the CIRCUITPY drive won't appear on a host computer either — you'll edit through web workflow only. Many makers use a button or pin check in <code>boot.py</code> to choose between the two modes at startup.</p>` +
`<p><em>Note:</em> on macOS, ejecting the drive in Finder doesn't always release the board-side lock on its own.</p>` +
`<p><a href="${learnUrl}" target="_blank" rel="noopener noreferrer">${learnLabel}</a></p>` +
`<p>Your edits are still here — save again once the filesystem is writable.</p>`
);
return false;
}
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;
}
}
// Load the File Contents and Path into the UI
function loadFileContents(path, contents, saved = true) {
setFilename(path);
loadEditorContents(contents, path);
if (saved !== null) {
setSaved(saved);
}
console.log("Current File Changed to: " + workflow.currentFilename);
}
async function onTextChange(update) {
if (!update.docChanged) {
return;
}
var hasGap = false;
update.changes.desc.iterGaps(function(posA, posB, length) {
// this are unchanged gaps.
hasGap = true;
if (posA != 0 && posB != 0) {
return;
} else if (posA == 0 && posB == 0) {
unchanged = Math.min(length, unchanged);
} else {
unchanged = 0;
}
});
// Everything has changed.
if (!hasGap) {
unchanged = 0;
}
setSaved(false);
}
function disconnectCallback() {
// 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.
shownDeviceInfoForCurrentSession = false;
updateUIConnected(false);
}
editor = new EditorView({
state: EditorState.create({
doc: "",
extensions: buildEditorExtensions(null)
}),
parent: document.querySelector('#editor')
});
function getCssVar(varName) {
return window.getComputedStyle(document.body).getPropertyValue("--" + varName);
}
async function setupXterm() {
state.terminal = new Terminal({
theme: {
background: getCssVar('background-color'),
foreground: getCssVar('terminal-text-color'),
cursor: getCssVar('terminal-text-color'),
}
});
state.terminal.loadAddon(new WebLinksAddon());
state.terminal.open(document.getElementById('terminal'));
state.terminal.onData(async (data) => {
if (await checkConnected()) {
// Route through the flush-guard wrapper so a user-typed Ctrl-D
// right after a save waits for the host kernel to flush before
// the device reads code.py. See issue #229.
await workflow.serialTransmitWithFlushGuard(data);
}
});
}
function getBackend() {
let backend = getUrlParam("backend");
if (backend && isValidBackend(backend)) {
return getBackendWorkflow(backend);
} else if (isLocal()) {
// Only auto-select Web Workflow when we're actually running on a
// device (mdns/IP host serving /code/) or the user has supplied a
// host= override. Bare localhost should fall through to the connect
// dialog so the user can pick BLE/Serial/USB.
if (isMdns() || isIp() || getUrlParam("host", false)) {
return getBackendWorkflow("web");
}
}
return null;
}
function loadParameterizedContent() {
let documentState = getUrlParam("state");
if (documentState) {
documentState = JSON.parse(decodeURIComponent(documentState));
}
return documentState;
}
function applySettings() {
// ----- Themes -----
const theme = settings.getSetting('theme');
// Remove all theme-[option] classes from body
document.body.classList.forEach((className) => {
if (className.startsWith('theme-')) {
document.body.classList.remove(className);
}
});
// Add the selected theme class
document.body.classList.add(`theme-${theme}`);
// Apply to EditorView.theme dark parameter
editor.darkTheme = getCssVar('editor-theme-dark').trim() === '1';
// Apply to xterm
state.terminal.options.theme = {
background: getCssVar('background-color'),
foreground: getCssVar('terminal-text-color'),
cursor: getCssVar('terminal-text-color'),
};
debugMessageAnsi = null;
// Note: Debug Message color is applied on next debug message or reload
// I'm not sure how to go through the xterm's existing content and change escape sequences
// Changing the CSS style reverts to the old style on terminal update/redraw
}
document.addEventListener('DOMContentLoaded', async (event) => {
await setupXterm();
applySettings();
btnConnect.forEach((element) => {
element.addEventListener('click', async function(e) {
e.preventDefault();
e.stopPropagation();
// Check if we have an active connection
if (workflow != null && workflow.connectionStatus()) {
// If so, unload the current workflow
await workflow.disconnectButtonHandler(null);
} else {
// If not, it should display the available connections
await checkConnected();
}
});
});
// Check backend param and load appropriate type if specified
let backend = getBackend();
if (backend) {
await loadWorkflow(backend);
// If we don't have all the info we need to connect
let returnVal = await workflow.parseParams();
if (returnVal === true && await workflowConnect() && workflow.type === CONNTYPE.Web) {
// We're connected, local, no errors, and using Web Workflow.
// The Device Info dialog is opened from loadEditor() now, so we
// just need to verify read-only state here.
await checkReadOnly();
} else {
if (returnVal instanceof Error) {
await showMessage(returnVal);
} else {
loadEditor();
await workflow.showConnect(getDocState());
}
}
} else {
await checkConnected();
}
});