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; }