diff --git a/index.html b/index.html
index 1f1a1f3..098f57c 100644
--- a/index.html
+++ b/index.html
@@ -70,7 +70,6 @@
-
@@ -82,7 +81,6 @@
-
Save + Run
+
diff --git a/js/layout.js b/js/layout.js
index 14c2af1..7e5c859 100644
--- a/js/layout.js
+++ b/js/layout.js
@@ -99,6 +99,21 @@ export function showSerial() {
updatePageLayout(UPDATE_TYPE_SERIAL);
}
+function updateModeButtons() {
+ // Match the button state to the panel state to avoid getting out of sync
+ if (isEditorVisible()) {
+ btnModeEditor.classList.add('active');
+ } else {
+ btnModeEditor.classList.remove('active');
+ }
+
+ if (isSerialVisible()) {
+ btnModeSerial.classList.add('active');
+ } else {
+ btnModeSerial.classList.remove('active');
+ }
+}
+
// update type is used to indicate which button was clicked
function updatePageLayout(updateType) {
// If both are visible, show the separator
@@ -110,6 +125,7 @@ function updatePageLayout(updateType) {
editorPage.style.flex = null;
serialPage.style.width = null;
serialPage.style.flex = null;
+ updateModeButtons();
return;
}
@@ -133,18 +149,7 @@ function updatePageLayout(updateType) {
serialPage.style.flex = 'none';
}
- // Match the button state to the panel state to avoid getting out of sync
- if (isEditorVisible()) {
- btnModeEditor.classList.add('active');
- } else {
- btnModeEditor.classList.remove('active');
- }
-
- if (isSerialVisible()) {
- btnModeSerial.classList.add('active');
- } else {
- btnModeSerial.classList.remove('active');
- }
+ updateModeButtons();
if (isSerialVisible()) {
refitTerminal();
diff --git a/js/script.js b/js/script.js
index 5ce0e0f..fe7f497 100644
--- a/js/script.js
+++ b/js/script.js
@@ -23,7 +23,7 @@ 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 { ButtonValueDialog, MessageModal, UnsavedDialog } 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';
@@ -47,6 +47,9 @@ let debugMessageAnsi = null;
let formatterInitPromise = null;
let formatterWorkspace = null;
let currentEditorPath = null;
+let nextEditorTabId = 1;
+let editorTabs = [];
+let activeEditorTabId = null;
const btnRestart = document.querySelector('.btn-restart');
const btnHalt = document.querySelector('.btn-halt');
@@ -64,9 +67,12 @@ const btnSettings = document.querySelector('.btn-settings');
const terminalTitle = document.getElementById('terminal-title');
const serialPlotter = document.getElementById('plotter');
const connectionIndicator = document.getElementById('connection-indicator');
+const editorTabsElement = document.getElementById('editor-tabs');
+const editorTabSelect = document.getElementById('editor-tab-select');
const messageDialog = new MessageModal("message");
const connectionType = new ButtonValueDialog("connection-type");
+const closeTabDialog = new UnsavedDialog("unsaved");
const settings = new Settings();
const DEFAULT_EDITOR_FONT_SIZE = 16;
@@ -268,6 +274,241 @@ function setEditorLanguageForPath(path) {
});
}
+function getActiveTab() {
+ return editorTabs.find((tab) => tab.id === activeEditorTabId) || null;
+}
+
+function getTabDocumentLength(tab) {
+ if (!tab) {
+ return 0;
+ }
+ if (tab.id === activeEditorTabId && editor) {
+ return editor.state.doc.length;
+ }
+ return tab.editorState.doc.length;
+}
+
+function isTabDirty(tab) {
+ return !!tab && tab.unchanged !== getTabDocumentLength(tab);
+}
+
+function hasDirtyTabs() {
+ return editorTabs.some((tab) => isTabDirty(tab));
+}
+
+function captureActiveEditorState() {
+ const activeTab = getActiveTab();
+ if (activeTab && editor) {
+ activeTab.editorState = editor.state;
+ activeTab.unchanged = unchanged;
+ activeTab.scrollPosition = getEditorScrollPosition();
+ }
+}
+
+function getEditorScroller() {
+ return editor?.scrollDOM || document.querySelector("#editor .cm-scroller");
+}
+
+function getEditorScrollPosition() {
+ const scroller = getEditorScroller();
+ return {
+ left: scroller ? scroller.scrollLeft : 0,
+ top: scroller ? scroller.scrollTop : 0,
+ };
+}
+
+function restoreEditorScrollPosition(tab) {
+ const scrollPosition = tab?.scrollPosition;
+ if (!scrollPosition) {
+ return;
+ }
+
+ window.requestAnimationFrame(() => {
+ const scroller = getEditorScroller();
+ if (!scroller) {
+ return;
+ }
+ scroller.scrollLeft = scrollPosition.left;
+ scroller.scrollTop = scrollPosition.top;
+ });
+}
+
+function getTabLabel(tab) {
+ if (!tab.path) {
+ return "New Document";
+ }
+ return tab.path.split("/").pop() || tab.path;
+}
+
+function renderEditorTabs() {
+ if (!editorTabsElement) {
+ return;
+ }
+
+ const tabButtons = editorTabs.map((tab) => {
+ const tabButton = document.createElement("div");
+ tabButton.className = "editor-tab";
+ tabButton.setAttribute("role", "tab");
+ tabButton.setAttribute("aria-selected", tab.id === activeEditorTabId ? "true" : "false");
+ tabButton.tabIndex = tab.id === activeEditorTabId ? 0 : -1;
+ tabButton.title = tab.path || "New Document";
+ tabButton.dataset.tabId = tab.id;
+ tabButton.classList.toggle("active", tab.id === activeEditorTabId);
+ tabButton.classList.toggle("unsaved", isTabDirty(tab));
+
+ const [style, icon] = getFileIcon(tab.path);
+ const iconElement = document.createElement("i");
+ iconElement.className = `${style} ${icon}`;
+ iconElement.setAttribute("aria-hidden", "true");
+
+ const labelElement = document.createElement("span");
+ labelElement.className = "editor-tab-label";
+ labelElement.textContent = getTabLabel(tab);
+
+ const dirtyElement = document.createElement("span");
+ dirtyElement.className = "editor-tab-dirty";
+ dirtyElement.textContent = "*";
+ dirtyElement.title = "Unsaved changes";
+ dirtyElement.setAttribute("aria-label", "Unsaved changes");
+
+ const closeButton = document.createElement("button");
+ closeButton.type = "button";
+ closeButton.className = "editor-tab-close";
+ closeButton.title = `Close ${getTabLabel(tab)}`;
+ closeButton.setAttribute("aria-label", `Close ${getTabLabel(tab)}`);
+ closeButton.innerHTML = '';
+ closeButton.addEventListener("click", async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ await closeEditorTab(tab.id);
+ });
+
+ tabButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ activateEditorTab(tab.id);
+ });
+ tabButton.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ activateEditorTab(tab.id);
+ }
+ });
+
+ tabButton.append(iconElement, labelElement, dirtyElement, closeButton);
+ return tabButton;
+ });
+
+ editorTabsElement.replaceChildren(...tabButtons);
+ renderEditorTabSelect();
+ mainContent.classList.toggle("unsaved", !!getActiveTab() && isDirty());
+}
+
+function renderEditorTabSelect() {
+ if (!editorTabSelect) {
+ return;
+ }
+
+ const options = editorTabs.map((tab) => {
+ const option = document.createElement("option");
+ option.value = tab.id;
+ option.textContent = `${isTabDirty(tab) ? "* " : ""}${tab.path || "New Document"}`;
+ return option;
+ });
+
+ editorTabSelect.replaceChildren(...options);
+ editorTabSelect.value = activeEditorTabId || "";
+ editorTabSelect.hidden = editorTabs.length < 2;
+}
+
+function createEditorTab(path = null, contents = "", saved = true, activate = true) {
+ const savedPosition = saved === true ? contents.length : saved === false ? 0 : saved;
+ const tab = {
+ id: String(nextEditorTabId++),
+ path,
+ unchanged: savedPosition,
+ scrollPosition: { left: 0, top: 0 },
+ editorState: EditorState.create({
+ doc: contents,
+ extensions: buildEditorExtensions(path),
+ }),
+ };
+ editorTabs.push(tab);
+ if (activate) {
+ activateEditorTab(tab.id);
+ } else {
+ renderEditorTabs();
+ }
+ return tab;
+}
+
+function findTabByPath(path) {
+ if (path === null) {
+ return null;
+ }
+ return editorTabs.find((tab) => tab.path === path) || null;
+}
+
+function activateEditorTab(tabId) {
+ const nextTab = editorTabs.find((tab) => tab.id === tabId);
+ if (!nextTab) {
+ return false;
+ }
+
+ if (activeEditorTabId === tabId) {
+ renderEditorTabs();
+ return true;
+ }
+
+ captureActiveEditorState();
+ activeEditorTabId = tabId;
+ editor.setState(nextTab.editorState);
+ editorLanguagePath = nextTab.path;
+ unchanged = nextTab.unchanged;
+ setFilename(nextTab.path);
+ renderEditorTabs();
+ editor.focus();
+ restoreEditorScrollPosition(nextTab);
+ return true;
+}
+
+async function closeEditorTab(tabId) {
+ const tab = editorTabs.find((entry) => entry.id === tabId);
+ if (!tab) {
+ return false;
+ }
+
+ if (isTabDirty(tab)) {
+ if (tab.id !== activeEditorTabId) {
+ activateEditorTab(tab.id);
+ }
+ const result = await closeTabDialog.open("Current changes will be lost. Do you want to save?");
+ if (result === null) {
+ return false;
+ }
+ if (result && !await saveFile()) {
+ return false;
+ }
+ }
+
+ const closingIndex = editorTabs.findIndex((entry) => entry.id === tabId);
+ editorTabs.splice(closingIndex, 1);
+
+ if (editorTabs.length < 1) {
+ createEditorTab(null, "", true, true);
+ return true;
+ }
+
+ if (activeEditorTabId === tabId) {
+ const nextIndex = Math.min(closingIndex, editorTabs.length - 1);
+ activeEditorTabId = null;
+ activateEditorTab(editorTabs[nextIndex].id);
+ } else {
+ renderEditorTabs();
+ }
+ return true;
+}
+
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('mobile-menu-button').addEventListener('click', handleMobileToggle);
document.querySelectorAll('#mobile-menu-contents li a').forEach((element) => {
@@ -275,6 +516,12 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
+if (editorTabSelect) {
+ editorTabSelect.addEventListener('change', (event) => {
+ activateEditorTab(event.target.value);
+ });
+}
+
function handleMobileToggle(event) {
event.preventDefault();
@@ -403,7 +650,18 @@ btnSettings.addEventListener('click', async function(e) {
// Basic functions used for buttons and hotkeys
async function openFile() {
if (await checkConnected()) {
- workflow.openFile();
+ await workflow.openFileDialog(async (path) => {
+ if (path === null) {
+ return;
+ }
+ const existingTab = findTabByPath(path);
+ if (existingTab) {
+ activateEditorTab(existingTab.id);
+ return;
+ }
+ const contents = await workflow.readFile(path);
+ loadFileContents(path, contents);
+ });
}
}
@@ -462,9 +720,7 @@ async function saveFile() {
async function newFile() {
if (await checkConnected()) {
- if (await workflow.checkSaved()) {
- loadFileContents(null, "");
- }
+ createEditorTab(null, "", true, true);
}
}
@@ -481,11 +737,18 @@ async function saveRunFile() {
}
function setSaved(saved) {
+ const activeTab = getActiveTab();
if (saved) {
+ if (activeTab && editor) {
+ activeTab.unchanged = editor.state.doc.length;
+ activeTab.editorState = editor.state;
+ unchanged = activeTab.unchanged;
+ }
mainContent.classList.remove("unsaved");
} else {
mainContent.classList.add("unsaved");
}
+ renderEditorTabs();
}
async function checkConnected() {
@@ -589,6 +852,11 @@ async function checkReadOnly() {
/* Update the filename and update the UI */
function setFilename(path) {
+ const activeTab = getActiveTab();
+ if (activeTab) {
+ activeTab.path = path;
+ activeTab.editorState = editor.state;
+ }
currentEditorPath = path;
updateFormatterButtonState(path);
@@ -597,16 +865,11 @@ function setFilename(path) {
// 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 = ` ` + filename;
+ if (activeTab) {
+ activeTab.editorState = editor.state;
+ }
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");
@@ -616,8 +879,7 @@ function setFilename(path) {
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];
+ renderEditorTabs();
}
async function chooseConnection() {
@@ -648,7 +910,7 @@ async function chooseConnection() {
// Dynamically Load a Workflow (where the magic happens)
async function loadWorkflow(workflowType = null) {
- let currentFilename = null;
+ let currentFilename = currentEditorPath;
if (workflow && workflowType == null) {
// Get the last workflow
@@ -744,12 +1006,18 @@ function buildEditorExtensions(path) {
}
// Use the editor's function to check if anything has changed
-function isDirty() {
- if (unchanged == editor.state.doc.length) return false;
+function isDirty(tab = getActiveTab()) {
+ if (!tab) {
+ return false;
+ }
+ if (tab.id === activeEditorTabId && editor) {
+ return unchanged !== editor.state.doc.length;
+ }
+ if (tab.unchanged == tab.editorState.doc.length) return false;
return true;
}
-function loadEditorContents(content, path = null) {
+function loadEditorContents(content, path = null, saved = true) {
editor.setState(EditorState.create({
doc: content,
extensions: buildEditorExtensions(path)
@@ -758,12 +1026,15 @@ function loadEditorContents(content, path = null) {
// compartment contents so the next setEditorLanguageForPath call
// can correctly skip a no-op reconfigure.
editorLanguagePath = path;
- unchanged = editor.state.doc.length;
+ unchanged = saved === true ? editor.state.doc.length : saved === false ? 0 : saved;
+ const activeTab = getActiveTab();
+ if (activeTab) {
+ activeTab.editorState = editor.state;
+ activeTab.unchanged = unchanged;
+ }
//console.log("doc length", unchanged);
}
-setFilename(null);
-
async function showMessage(message) {
return await messageDialog.open(message);
}
@@ -819,7 +1090,8 @@ function updateUIConnected(isConnected) {
}
window.onbeforeunload = () => {
- if (isDirty()) {
+ captureActiveEditorState();
+ if (hasDirtyTabs()) {
return "You have unsaved changed, exit anyways?";
}
};
@@ -833,8 +1105,7 @@ let shownDeviceInfoForCurrentSession = false;
async function loadEditor() {
let documentState = loadParameterizedContent();
if (documentState) {
- loadFileContents(documentState.path, documentState.contents, null);
- unchanged = documentState.pos;
+ loadFileContents(documentState.path, documentState.contents, documentState.pos);
setSaved(!isDirty());
}
@@ -982,10 +1253,25 @@ async function saveFileContents(path) {
// Load the File Contents and Path into the UI
function loadFileContents(path, contents, saved = true) {
+ const existingTab = findTabByPath(path);
+ if (existingTab) {
+ activateEditorTab(existingTab.id);
+ console.log("Current File Changed to: " + workflow.currentFilename);
+ return;
+ } else if (getActiveTab() && getActiveTab().path === null && !isDirty() && editorTabs.length === 1) {
+ } else {
+ createEditorTab(path, contents, saved, true);
+ console.log("Current File Changed to: " + workflow.currentFilename);
+ return;
+ }
setFilename(path);
- loadEditorContents(contents, path);
- if (saved !== null) {
- setSaved(saved);
+ loadEditorContents(contents, path, saved);
+ if (saved === true) {
+ setSaved(true);
+ } else if (saved === false) {
+ setSaved(false);
+ } else {
+ renderEditorTabs();
}
console.log("Current File Changed to: " + workflow.currentFilename);
}
@@ -1013,6 +1299,11 @@ async function onTextChange(update) {
}
setSaved(false);
+ const activeTab = getActiveTab();
+ if (activeTab) {
+ activeTab.unchanged = unchanged;
+ activeTab.editorState = update.state;
+ }
}
function disconnectCallback() {
@@ -1030,6 +1321,7 @@ editor = new EditorView({
}),
parent: document.querySelector('#editor')
});
+createEditorTab(null, "", true, true);
function getCssVar(varName) {
return window.getComputedStyle(document.body).getPropertyValue("--" + varName);
diff --git a/sass/layout/_header.scss b/sass/layout/_header.scss
index fa4284c..e4216f9 100644
--- a/sass/layout/_header.scss
+++ b/sass/layout/_header.scss
@@ -132,7 +132,6 @@
background-color: #e71c8c;
}
-.file-path,
#terminal-title {
font-size: 20px;
line-height: 59px;
diff --git a/sass/layout/_header_mobile.scss b/sass/layout/_header_mobile.scss
index dbf4da1..521345f 100644
--- a/sass/layout/_header_mobile.scss
+++ b/sass/layout/_header_mobile.scss
@@ -95,9 +95,6 @@
float: right;
}
- .file-path {
- float: left;
- }
}
@media (max-width: $screen-xs-max) {
diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss
index 4d88962..c5dabd4 100644
--- a/sass/layout/_layout.scss
+++ b/sass/layout/_layout.scss
@@ -43,11 +43,13 @@
flex: auto;
display: flex;
flex-direction: row;
+ min-height: 0;
#editor-page, #serial-page {
flex: 1 1 100%;
display: none;
flex-direction: column;
+ min-height: 0;
&.active {
display: flex;
@@ -68,8 +70,115 @@
}
#editor-page {
+ #editor-tabs-wrapper {
+ background: var(--tab-bar-background);
+ border-bottom: var(--border-style);
+ display: flex;
+ flex: 0 0 auto;
+ min-height: 38px;
+ }
+
+ #editor-tabs {
+ align-items: stretch;
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ }
+
+ #editor-tab-select {
+ align-self: center;
+ background: var(--tab-select-background);
+ border: 1px solid var(--tab-select-border-color);
+ border-radius: 4px;
+ color: var(--tab-select-text-color);
+ flex: 0 0 auto;
+ font-size: 0.9em;
+ margin: 4px 6px;
+ max-width: 220px;
+ min-width: 0;
+ padding: 4px 24px 4px 8px;
+ width: fit-content;
+
+ &[hidden] {
+ display: none;
+ }
+ }
+
+ .editor-tab {
+ align-items: center;
+ background: var(--tab-background);
+ border: solid transparent;
+ border-width: 3px 1px 0;
+ color: var(--tab-text-color);
+ cursor: pointer;
+ display: inline-flex;
+ flex: 0 1 auto;
+ gap: 8px;
+ min-width: 0;
+ max-width: 260px;
+ padding: 0 8px 0 12px;
+
+ &:hover {
+ background: var(--tab-hover-background);
+ }
+
+ &:focus-visible {
+ outline: 2px solid $purple;
+ outline-offset: -2px;
+ }
+
+ &.active {
+ background: var(--background-color);
+ border-color: $purple $purple transparent;
+ color: var(--editor-text-color);
+ }
+
+ .editor-tab-dirty {
+ color: var(--unsaved-file-color);
+ display: none;
+ flex: 0 0 auto;
+ font-weight: 700;
+ line-height: 1;
+ margin-left: -4px;
+ }
+
+ &.unsaved .editor-tab-dirty {
+ display: inline;
+ }
+
+ .editor-tab-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .editor-tab-close {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: 4px;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ flex: 0 0 24px;
+ height: 24px;
+ justify-content: center;
+ line-height: 1;
+ padding: 0;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.14);
+ }
+ }
+ }
+
#editor {
flex: 1 1 0%;
+ min-height: 0;
}
}
@@ -87,6 +196,7 @@
}
#terminal {
flex: 1 1 0%;
+ min-height: 0;
position: relative;
width: 100%;
overflow: hidden;
@@ -216,6 +326,16 @@
}
@media (max-width: $screen-xs-max) {
+ #editor-page {
+ #editor-tab-select {
+ max-width: 150px;
+ }
+
+ .editor-tab {
+ max-width: 210px;
+ }
+ }
+
.layout #footer-bar {
.purple-button {
font-size: 0;
diff --git a/sass/layout/_themes.scss b/sass/layout/_themes.scss
index c135fb2..ace28a1 100644
--- a/sass/layout/_themes.scss
+++ b/sass/layout/_themes.scss
@@ -14,6 +14,13 @@
--gutter-text-color: #ddd;
--debug-message-color: #fce94f;
--unsaved-file-color: #f60;
+ --tab-bar-background: #262626;
+ --tab-background: #262626;
+ --tab-hover-background: #333;
+ --tab-text-color: #ddd;
+ --tab-select-background: #3b3b3b;
+ --tab-select-border-color: rgba(255, 255, 255, 0.18);
+ --tab-select-text-color: #ddd;
// Editor token colors. Defaults are tuned for the dark editor
// background; the light theme below overrides any that would be
@@ -45,6 +52,13 @@
--gutter-active-line-color: #ccc;
--gutter-text-color: #222;
--debug-message-color: #FF9900;
+ --tab-bar-background: #e2e2e2;
+ --tab-background: #e9e9e9;
+ --tab-hover-background: #dedede;
+ --tab-text-color: #222;
+ --tab-select-background: #f1f1f1;
+ --tab-select-border-color: #{$gray-border};
+ --tab-select-text-color: #222;
// Darken token colors enough to stay readable on the
// near-white editor background. Dark theme values above are
@@ -66,12 +80,6 @@
// Styles applied to both themes
-#main-content {
- &.unsaved .file-path {
- color: var(--unsaved-file-color);
- }
-}
-
#footer-bar {
border-top: var(--border-style);
}
@@ -113,7 +121,8 @@
background-color: var(--background-color);
line-height: 1.5;
font-family: 'Operator Mono', 'Source Code Pro', Menlo, Monaco, Consolas, Courier New, monospace;
- max-height: calc(100vh - 2*4em - 5em);
+ height: 100%;
+ max-height: none;
.cm-content {
caret-color: orange;
@@ -168,6 +177,7 @@
}
.cm-scroller {
+ height: 100%;
overflow: auto;
}