diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index f8834985d8..9c7df58fda 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -38,6 +38,11 @@ function RemoteFunctions(config = {}) { let _cssSelectorHighlightTimer = null; let _lastHoverTarget = null; // tracks the element currently under the mouse (for same-element skip) let _pendingHoverRAF = null; // pending requestAnimationFrame ID for hover updates + // a hover shows once the pointer has stayed on an element this long, so the elements + // passing under it during a sweep or a scroll don't flash + const HOVER_SETTLE_MS = 60; + let _hoverSettleTimer = null; + let _hoverSettled = false; // this will store the element that was clicked previously (before the new click) // we need this so that we can remove click styling from the previous element when a new element is clicked @@ -51,6 +56,8 @@ function RemoteFunctions(config = {}) { let _selectedFromEditor = false; // the element selected by name (layers panel row), not by pointer let _namedSelection = null; + // the element the caret points at while the selection is held elsewhere + let _caretTarget = null; // Expose the currently selected element globally for external access window.__current_ph_lp_selected = null; @@ -260,6 +267,8 @@ function RemoteFunctions(config = {}) { screenOffset: screenOffset, selectElement: selectElement, isSelectedFromEditor: function () { return _selectedFromEditor; }, + highlightRuleAroundSelection: highlightRuleAroundSelection, + getCaretTarget: function () { return _caretTarget; }, isNamedSelection: _isNamedSelection, toMatchableSelector: toMatchableSelector, sendSelectionToEditor: sendSelectionToEditor, @@ -508,9 +517,16 @@ function RemoteFunctions(config = {}) { return measured; } + // Margin and padding fills belong to the selected element and to the one the caret + // points at. A hover and the other matches of a css rule only get the outline. + function _showsBoxModel(element, outlineOnly) { + return !outlineOnly && (element === previouslySelectedElement || element === _caretTarget) && + !SHARED_STATE._boxModelHighlightHidden; + } + // Update an existing overlay's position, dimensions, and colors to match the target element. // No DOM elements are created or destroyed — only style properties are updated. - function _paintOverlay(overlay, element, measured) { + function _paintOverlay(overlay, element, measured, outlineOnly) { if (!measured) { overlay.classList.add('hidden'); return; @@ -574,7 +590,7 @@ function RemoteFunctions(config = {}) { // Padding region. Rects stay in place when hidden, only their fill goes away, // so nothing has to be rebuilt when they come back. - const boxModelHidden = SHARED_STATE._boxModelHighlightHidden; + const boxModelHidden = !_showsBoxModel(element, outlineOnly); const padColor = boxModelHidden ? "transparent" : COLORS.highlightPadding; setRect(refs.padTop, paddingBox.left, paddingBox.top, paddingBox.width, pt, padColor); setRect(refs.padBottom, paddingBox.left, contentBox.top + contentBox.height, paddingBox.width, pb, padColor); @@ -599,12 +615,14 @@ function RemoteFunctions(config = {}) { outlineStyle.border = `1px solid ${outlineColor}`; } - function _updateOverlay(overlay, element) { - _paintOverlay(overlay, element, _measureOverlay(element)); + function _updateOverlay(overlay, element, outlineOnly) { + _paintOverlay(overlay, element, _measureOverlay(element), outlineOnly); } - function Highlight(trigger) { + // outlineOnly: a hover can sit on the selected element too, and must not fill it twice + function Highlight(trigger, outlineOnly) { this.trigger = !!trigger; + this.outlineOnly = !!outlineOnly; this.elements = []; this.selector = ""; this._overlays = []; @@ -622,7 +640,7 @@ function RemoteFunctions(config = {}) { this.elements.push(element); const overlay = _getOverlay(); this._overlays.push(overlay); - _updateOverlay(overlay, element); + _updateOverlay(overlay, element, this.outlineOnly); }, addAll: function (elements) { @@ -643,7 +661,7 @@ function RemoteFunctions(config = {}) { this.elements.push(fresh[i]); const overlay = _getOverlay(); this._overlays.push(overlay); - _paintOverlay(overlay, fresh[i], measured[i]); + _paintOverlay(overlay, fresh[i], measured[i], this.outlineOnly); } }, @@ -687,7 +705,7 @@ function RemoteFunctions(config = {}) { // Update all overlays in place — no DOM creation or destruction const measured = _measureAll(elements); for (let i = 0; i < elements.length; i++) { - _paintOverlay(this._overlays[i], elements[i], measured[i]); + _paintOverlay(this._overlays[i], elements[i], measured[i], this.outlineOnly); } } }; @@ -706,7 +724,8 @@ function RemoteFunctions(config = {}) { /** * Applies the current hover state in a single batched DOM update. * Called once per animation frame via requestAnimationFrame. - * _lastHoverTarget holds the element to highlight (or null to clear). + * _lastHoverTarget holds the element to highlight (or null to clear), and is only + * shown once the pointer has settled on it. */ function _applyHoverState() { _pendingHoverRAF = null; @@ -721,7 +740,7 @@ function RemoteFunctions(config = {}) { hoverBoxHandler.dismiss(); } - const element = _lastHoverTarget; + const element = _hoverSettled ? _lastHoverTarget : null; if (element && (element !== previouslySelectedElement || _selectedFromEditor)) { _hoverHighlight.add(element); @@ -741,6 +760,30 @@ function RemoteFunctions(config = {}) { } } + function _cancelHoverSettle() { + if (_hoverSettleTimer) { + clearTimeout(_hoverSettleTimer); + _hoverSettleTimer = null; + } + _hoverSettled = false; + } + + function _restartHoverSettle() { + _cancelHoverSettle(); + _hoverSettleTimer = setTimeout(function () { + _hoverSettleTimer = null; + _hoverSettled = true; + _scheduleHoverUpdate(); + }, HOVER_SETTLE_MS); + } + + // a tall element stays under the pointer long enough to settle while the page is still moving + function _onScrollWhileHoverSettles() { + if (_hoverSettleTimer) { + _restartHoverSettle(); + } + } + function onElementHover(event) { // don't want highlighting and stuff when auto scrolling or when dragging (svgs) // for dragging normal html elements its already taken care of...so we just add svg drag checking @@ -760,10 +803,12 @@ function RemoteFunctions(config = {}) { // if _hoverHighlight is uninitialized, initialize it if (!_hoverHighlight && shouldShowHighlightOnHover()) { - _hoverHighlight = new Highlight(true); + _hoverHighlight = new Highlight(true, true); } if (_hoverHighlight && shouldShowHighlightOnHover()) { + // the previous hover goes now, this one comes once the pointer settles + _restartHoverSettle(); _scheduleHoverUpdate(); } } @@ -774,6 +819,7 @@ function RemoteFunctions(config = {}) { } if (_hoverHighlight && shouldShowHighlightOnHover()) { _lastHoverTarget = null; + _cancelHoverSettle(); _scheduleHoverUpdate(); } } @@ -864,15 +910,17 @@ function RemoteFunctions(config = {}) { } } + // the overlay paints by who is selected, so that is settled first + previouslySelectedElement = element; + _selectedFromEditor = fromEditor || false; + window.__current_ph_lp_selected = element; + if (!_clickHighlight) { _clickHighlight = new Highlight(); } _clickHighlight.clear(); _clickHighlight.add(element); - previouslySelectedElement = element; - _selectedFromEditor = fromEditor || false; - window.__current_ph_lp_selected = element; if (isSourceless(element)) { _watchSourcelessSelection(element); } @@ -952,11 +1000,13 @@ function RemoteFunctions(config = {}) { window.document.removeEventListener("mousemove", onElementHover); window.document.removeEventListener("mouseout", onElementHoverOut); window.document.documentElement.removeEventListener("mouseleave", onDocumentMouseLeave); + window.document.removeEventListener("scroll", _onScrollWhileHoverSettles, true); // Cancel any pending rAF hover update so stale callbacks don't fire if (_pendingHoverRAF) { cancelAnimationFrame(_pendingHoverRAF); _pendingHoverRAF = null; } + _cancelHoverSettle(); _lastHoverTarget = null; } @@ -975,6 +1025,8 @@ function RemoteFunctions(config = {}) { window.document.addEventListener("mousemove", onElementHover); window.document.addEventListener("mouseout", onElementHoverOut); window.document.documentElement.addEventListener("mouseleave", onDocumentMouseLeave); + // scroll does not bubble, capture also sees the page's own scroll containers + window.document.addEventListener("scroll", _onScrollWhileHoverSettles, { capture: true, passive: true }); } } @@ -1102,6 +1154,7 @@ function RemoteFunctions(config = {}) { _hoverHighlight.clear(); _hoverHighlight = null; } + _caretTarget = null; clearCssSelectorHighlight(); } @@ -1196,18 +1249,63 @@ function RemoteFunctions(config = {}) { }; } + // Filter out the universal selector (*) from the rule - highlighting everything + // is not useful, similar to how we skip the html tag in isElementInspectable. + // The rule can be a comma-separated list of selectors (from multi-cursor), + // so we filter out any standalone * segments and keep valid ones. + function _withoutUniversalSelector(rule) { + return toMatchableSelector(rule).split(",").map(s => s.trim()).filter(s => s !== "*").join(","); + } + + // only a pick is held, not a selection the caret made before anything held it + function _dropCaretMadeSelection() { + if (previouslySelectedElement && _selectedFromEditor) { + dismissUIAndCleanupState(); + } + } + + /** + * Highlight and scroll to what the rule reaches without selecting it: the selection + * is held elsewhere (the layers panel) and a picked element keeps it. + * @param {string} rule - The CSS rule to highlight + * @returns {Element|null} the element the caret points at, null when it is the held one + */ + function highlightRuleAroundSelection(rule) { + _dropCaretMadeSelection(); + rule = _withoutUniversalSelector(rule); + // already drawn: the live document and the layers panel both ask for the same caret + if (rule && _cssSelectorHighlight && _cssSelectorHighlight.selector === rule) { + return _caretTarget; + } + _caretTarget = null; + if (!rule) { + clearCssSelectorHighlight(); + return null; + } + const nodes = window.document.querySelectorAll(rule); + const { element } = findBestElementToSelect(nodes, rule); + if (element) { + scrollElementToViewPort(element); + } + // set before drawing, the overlay paints margin and padding by it + _caretTarget =element && element !== previouslySelectedElement ? element : null; + createCssSelectorHighlight(nodes, rule); + return _caretTarget; + } + /** * Highlight all elements matching a CSS rule and select the best one * @param {string} rule - The CSS rule to highlight + * @param {boolean} [keepSelection] - highlight around a selection held elsewhere instead */ - function highlightRule(rule) { + function highlightRule(rule, keepSelection) { + if (keepSelection) { + highlightRuleAroundSelection(rule); + return; + } hideHighlight(); - // Filter out the universal selector (*) from the rule - highlighting everything - // is not useful, similar to how we skip the html tag in isElementInspectable. - // The rule can be a comma-separated list of selectors (from multi-cursor), - // so we filter out any standalone * segments and keep valid ones. - rule = toMatchableSelector(rule).split(",").map(s => s.trim()).filter(s => s !== "*").join(","); + rule = _withoutUniversalSelector(rule); if (!rule) { dismissUIAndCleanupState(); return; @@ -1271,6 +1369,11 @@ function RemoteFunctions(config = {}) { if (_hoverHighlight) { _hoverHighlight.redraw(); } + // rebuilt, not redrawn: its selector also matches the selected element, which it leaves out + if (_cssSelectorHighlight && _cssSelectorHighlight.selector) { + const rule = _cssSelectorHighlight.selector; + createCssSelectorHighlight(window.document.querySelectorAll(rule), rule); + } } // just a wrapper function when we need to redraw highlights as well as UI boxes @@ -1659,12 +1762,12 @@ function RemoteFunctions(config = {}) { } if (freshElement) { + previouslySelectedElement = freshElement; + window.__current_ph_lp_selected = freshElement; if (_clickHighlight) { _clickHighlight.clear(); _clickHighlight.add(freshElement); } - previouslySelectedElement = freshElement; - window.__current_ph_lp_selected = freshElement; // After element replacement (e.g., tag name change), the old // DOM node is gone. Patch the element reference on any // existing UI boxes so that position() doesn't bail on a @@ -1773,6 +1876,7 @@ function RemoteFunctions(config = {}) { // Reset hover tracking so the same-element skip doesn't suppress // re-highlighting after a full state cleanup (e.g. Escape, dismiss). _lastHoverTarget = null; + _cancelHoverSettle(); if (_pendingHoverRAF) { cancelAnimationFrame(_pendingHoverRAF); _pendingHoverRAF = null; @@ -1875,6 +1979,17 @@ function RemoteFunctions(config = {}) { cleanupPreviousElementState(); } + // The editor has nothing to highlight. A selection held elsewhere stays. + function hideEditorHighlight(keepSelection) { + if (keepSelection) { + _dropCaretMadeSelection(); + _caretTarget = null; + clearCssSelectorHighlight(); + return; + } + dismissUIAndCleanupState(); + } + // init _editHandler = new DOMEditHandler(window.document); @@ -1893,7 +2008,7 @@ function RemoteFunctions(config = {}) { }); if (config.mode === 'edit') { - _hoverHighlight = new Highlight(true); + _hoverHighlight = new Highlight(true, true); _clickHighlight = new Highlight(true); // register the event handlers @@ -2063,7 +2178,7 @@ function RemoteFunctions(config = {}) { customReturns = { // we have to do this else the minifier will strip the customReturns variable ...customReturns, "DOMEditHandler": DOMEditHandler, - "hideHighlight": dismissUIAndCleanupState, + "hideHighlight": hideEditorHighlight, "highlight": highlight, "highlightRule": highlightRule, "redrawHighlights": redrawHighlights, diff --git a/src/LiveDevelopment/BrowserScripts/pageLoaderWorker.js b/src/LiveDevelopment/BrowserScripts/pageLoaderWorker.js index acb10967cf..0f7fdf73db 100644 --- a/src/LiveDevelopment/BrowserScripts/pageLoaderWorker.js +++ b/src/LiveDevelopment/BrowserScripts/pageLoaderWorker.js @@ -102,15 +102,26 @@ function splitMetadataAndBuffer(concatenatedBuffer) { } let messageQueue = []; +const MESSAGE_QUEUE_MAX = 200; +const WS_RECONNECT_MIN_MS = 1000; +const WS_RECONNECT_MAX_MS = 10000; +let _wsReconnectDelayMs = WS_RECONNECT_MIN_MS; +let _heartbeatStarted = false; function _sendMessage(message) { if(_livePreviewWebSocket && _livePreviewWebSocketOpen) { _livePreviewWebSocket.send(mergeMetadataAndArrayBuffer(message)); } else if(_livePreviewBroadcastChannel){ _livePreviewBroadcastChannel.postMessage(message); + } else if(message.type === 'TAB_ONLINE') { + // a heartbeat that cannot go now is worthless later + return; } else { livePreviewDebugModeEnabled && console.warn("No Channels available for live preview worker messaging," + " queueing request, waiting for channel.."); + if(messageQueue.length >= MESSAGE_QUEUE_MAX) { + messageQueue.shift(); + } messageQueue.push(message); } } @@ -124,6 +135,10 @@ function flushPendingMessages() { } function _setupHearbeatMessenger(clientID) { + if(_heartbeatStarted) { + return; + } + _heartbeatStarted = true; function _sendOnlineHeartbeat() { _sendMessage({ type: 'TAB_ONLINE', @@ -151,11 +166,13 @@ function _setupBroadcastChannel(broadcastChannel, clientID) { function _setupWebsocketChannel(wssEndpoint, clientID) { _debugLog("live preview worker websocket url: ", wssEndpoint); - _livePreviewWebSocket = new WebSocket(wssEndpoint); - _livePreviewWebSocket.binaryType = 'arraybuffer'; - _livePreviewWebSocket.addEventListener("open", () =>{ + const socket = new WebSocket(wssEndpoint); + socket.binaryType = 'arraybuffer'; + socket.addEventListener("open", () =>{ _debugLog("live preview worker websocket opened", wssEndpoint); + _livePreviewWebSocket = socket; _livePreviewWebSocketOpen = true; + _wsReconnectDelayMs = WS_RECONNECT_MIN_MS; _sendMessage({ type: 'CHANNEL_TYPE', channelName: 'livePreviewChannel', @@ -165,7 +182,7 @@ function _setupWebsocketChannel(wssEndpoint, clientID) { _setupHearbeatMessenger(clientID); }); - _livePreviewWebSocket.addEventListener('message', function (event) { + socket.addEventListener('message', function (event) { const message = event.data; const {metadata} = splitMetadataAndBuffer(message); _debugLog("Live Preview worker socket channel: Browser received event from Phoenix: ", metadata); @@ -176,13 +193,18 @@ function _setupWebsocketChannel(wssEndpoint, clientID) { } }); - _livePreviewWebSocket.addEventListener('error', function (event) { + socket.addEventListener('error', function (event) { console.error("Live Preview worker socket channel: error event: ", event); }); - _livePreviewWebSocket.addEventListener('close', function () { + // The page is still here when the socket goes, so keep trying to get back to the editor. + socket.addEventListener('close', function () { _livePreviewWebSocketOpen = false; - _debugLog("Live Preview worker websocket closed"); + _debugLog("Live Preview worker websocket closed, reconnecting in ms: ", _wsReconnectDelayMs); + setTimeout(() => { + _setupWebsocketChannel(wssEndpoint, clientID); + }, _wsReconnectDelayMs); + _wsReconnectDelayMs = Math.min(_wsReconnectDelayMs * 2, WS_RECONNECT_MAX_MS); }); } diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js index a61d915bdc..0538382595 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveDocument.js @@ -38,6 +38,7 @@ define(function (require, exports, module) { // A held arrow key moves the caret far faster than the rule under it can be // resolved, so the highlight follows the caret once it settles. const CURSOR_HIGHLIGHT_DEBOUNCE_MS = 80; + const HELD_HIGHLIGHT_PREFIX = "held:"; function _simpleHash(str) { let hash = 5381; @@ -160,9 +161,7 @@ define(function (require, exports, module) { this.setInstrumentationEnabled(true, true); this.editor.off("cursorActivity", this._onCursorActivity); this.editor.on("cursorActivity", this._onCursorActivity); - if (!_isCursorHighlightGated(this)) { - this.updateHighlight(); - } + this.updateHighlight(); } }; @@ -173,28 +172,29 @@ define(function (require, exports, module) { LiveDocument.prototype._detachFromEditor = function () { if (this.editor) { this._cancelPendingHighlight(); - if (!_isCursorHighlightGated(this)) { - this.hideHighlight(); - } + this.hideHighlight(); this.editor.off("cursorActivity", this._onCursorActivity); } }; let _disableHighlightOnCursor = false; let _cursorHighlightGeneration = 0; - let _cursorHighlightGate = null; + let _selectionHolder = null; /** - * Lets something outside the live documents decide whether the caret may move - * the preview highlight, such as a panel holding a selection of its own. - * @param {?function(LiveDocument): boolean} gate Returns false to leave the preview alone; null removes it. + * While the holder returns true the caret still highlights in the preview but never + * selects, so a selection made elsewhere (the layers panel) stays. + * @param {?function(LiveDocument): boolean} holder null removes it. */ - LiveDocument.setCursorHighlightGate = function (gate) { - _cursorHighlightGate = gate || null; + LiveDocument.setSelectionHolder = function (holder) { + _selectionHolder = holder || null; }; - function _isCursorHighlightGated(liveDoc) { - return !!_cursorHighlightGate && _cursorHighlightGate(liveDoc) === false; + // for anything else that follows the caret and must settle on the same clock + LiveDocument.CURSOR_HIGHLIGHT_DEBOUNCE_MS = CURSOR_HIGHLIGHT_DEBOUNCE_MS; + + function _isSelectionHeld(liveDoc) { + return !!_selectionHolder && _selectionHolder(liveDoc) === true; } /** @@ -228,15 +228,14 @@ define(function (require, exports, module) { */ LiveDocument.prototype._onCursorActivity = function (event, editor) { this._cancelPendingHighlight(); - if (!this.editor || _disableHighlightOnCursor || _isCursorHighlightGated(this)) { + if (!this.editor || _disableHighlightOnCursor) { return; } const self = this; const generation = _cursorHighlightGeneration; this._highlightTimer = window.setTimeout(function () { self._highlightTimer = null; - if (self.editor && !_disableHighlightOnCursor && generation === _cursorHighlightGeneration && - !_isCursorHighlightGated(self)) { + if (self.editor && !_disableHighlightOnCursor && generation === _cursorHighlightGeneration) { self.updateHighlight(); } }, CURSOR_HIGHLIGHT_DEBOUNCE_MS); @@ -342,7 +341,7 @@ define(function (require, exports, module) { } // The preview can have been selected directly or by another live // document, so this document's cached selector cannot prove it is clear. - this.protocol.evaluate("_LD.hideHighlight()"); + this.protocol.evaluate("_LD.hideHighlight(" + _isSelectionHeld(this) + ")"); }; /** @@ -351,11 +350,14 @@ define(function (require, exports, module) { * @param {string} name The selector whose matched nodes should be highlighted. */ LiveDocument.prototype.highlightRule = function (name) { - if (this._lastHighlight === name) { + const keepSelection = _isSelectionHeld(this); + // the same rule draws differently around a held selection + const highlight =(keepSelection ? HELD_HIGHLIGHT_PREFIX : "") + name; + if (this._lastHighlight === highlight) { return; } - this._lastHighlight = name; - this.protocol.evaluate("_LD.highlightRule(" + JSON.stringify(name) + ")"); + this._lastHighlight = highlight; + this.protocol.evaluate("_LD.highlightRule(" + JSON.stringify(name) + ", " + keepSelection + ")"); }; /** diff --git a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js index f97446f072..ec98c881c4 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js +++ b/src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js @@ -317,6 +317,11 @@ define(function (require, exports, module) { } } + function _isInFront(liveDoc) { + const fullEditor = EditorManager.getCurrentFullEditor(); + return !!fullEditor && fullEditor.document.file.fullPath === liveDoc.doc.file.fullPath; + } + const processedMessageIDs = new Phoenix.libs.LRUCache({ max: MAX_PENDING_LP_CALLS_1000 // we dont need to set a ttl here as message ids are unique throughout lifetime. And old ids will @@ -397,8 +402,11 @@ define(function (require, exports, module) { console.error("error in tag selection", e); } editMode && liveDoc && liveDoc.disableHighlightOnCursorActivity(false); - // the caret did not move for a script-added element, re-highlighting would drop its selection - liveDoc && !msg.sourceless && liveDoc.updateHighlight(); + // the caret did not move for a script-added element, re-highlighting would drop its selection. + // Nor did it move in the html while another file is in front: that stale caret would take it. + if (liveDoc &&!msg.sourceless && _isInFront(liveDoc)) { + liveDoc.updateHighlight(); + } } else { // enrich received message with clientId msg.clientId = clientId; diff --git a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js index 21c0f8b28c..19dc804509 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/BrowserStaticServer.js @@ -45,6 +45,7 @@ define(function (require, exports, module) { HilightJSText = require("text!thirdparty/highlight.js/highlight.min.js"), GFMCSSText = require("text!thirdparty/gfm.min.css"), markdownHTMLTemplate = require("text!./markdown.html"), + LivePreviewTabs = require("./LivePreviewTabs"), redirectionHTMLTemplate = require("text!./redirectPage.html"); const EVENT_GET_PHOENIX_INSTANCE_ID = 'GET_PHOENIX_INSTANCE_ID'; @@ -59,7 +60,7 @@ define(function (require, exports, module) { EventDispatcher.makeEventDispatcher(exports); - const livePreviewTabs = new Map(); + const livePreviewTabs = LivePreviewTabs.livePreviewTabs; const PHCODE_LIVE_PREVIEW_QUERY_PARAM = "phcodeLivePreview"; // Communication Channels for PHCode.dev Editor and Live Preview @@ -225,11 +226,7 @@ define(function (require, exports, module) { _sendInitialURL(event.data.pageLoaderID); return; case 'TAB_LOADER_ONLINE': - livePreviewTabs.set(event.data.pageLoaderID, { - lastSeen: new Date(), - URL: event.data.URL, - navigationTab: true - }); + LivePreviewTabs.tabOnline(event.data.pageLoaderID, event.data.URL, true); return; default: return; // ignore messages not intended for us. } @@ -274,14 +271,23 @@ define(function (require, exports, module) { .catch(console.error); return; case EVENT_TAB_ONLINE: - livePreviewTabs.set(message.clientID, { - lastSeen: new Date(), - URL: message.URL - }); + LivePreviewTabs.tabOnline(message.clientID, message.URL); return; case EVENT_REPORT_ERROR: logger.reportError(new Error(message)); return; + case 'BROWSER_CONNECT': + LivePreviewTabs.tabConnected(message.clientID, message.url); + exports.trigger(eventName, { + data + }); + return; + case 'BROWSER_CLOSE': + LivePreviewTabs.dropTab(message.clientID); + exports.trigger(eventName, { + data + }); + return; default: exports.trigger(eventName, { data @@ -645,30 +651,19 @@ define(function (require, exports, module) { }); exports.on(EVENT_TAB_ONLINE, function(_ev, event){ - livePreviewTabs.set(event.data.message.clientID, { - lastSeen: new Date(), - URL: event.data.message.URL - }); + LivePreviewTabs.tabOnline(event.data.message.clientID, event.data.message.URL); }); + // A tab silent for too long is closed; one that heartbeats again is connected again. function _startHeartBeatListeners() { - // If we didn't receive heartbeat message from a tab for 10 seconds, we assume tab closed - const TAB_HEARTBEAT_TIMEOUT = 10000; // in millis secs - setInterval(()=>{ - let endTime = new Date(); - for(let tab of livePreviewTabs.keys()){ - const tabInfo = livePreviewTabs.get(tab); - let timeDiff = endTime - tabInfo.lastSeen; // in ms - if(timeDiff > TAB_HEARTBEAT_TIMEOUT){ - livePreviewTabs.delete(tab); - // the parent navigationTab `phcode.dev/live-preview-loader.html` which loads the live preview tab - // is in the list too. We should not raise browser close for a live-preview-loader tab. - if(!tabInfo.navigationTab) { - exports.trigger('BROWSER_CLOSE', { data: { message: {clientID: tab}}}); - } - } + LivePreviewTabs.start({ + close: function (clientID) { + exports.trigger('BROWSER_CLOSE', { data: { message: {clientID}}}); + }, + reconnect: function (clientID, url) { + exports.trigger('BROWSER_CONNECT', { data: { message: {clientID, url}}}); } - }, 1000); + }); } /** @@ -782,6 +777,7 @@ define(function (require, exports, module) { exports.messageToLivePreviewTabs = messageToLivePreviewTabs; exports.getPreviewDetails = getPreviewDetails; exports.livePreviewTabs = livePreviewTabs; + exports.dropTab = LivePreviewTabs.dropTab; exports.redirectAllTabs = redirectAllTabs; exports.getTabPopoutURL = getTabPopoutURL; exports.hasActiveLivePreviews = hasActiveLivePreviews; diff --git a/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewTabs.js b/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewTabs.js new file mode 100644 index 0000000000..b480c8b210 --- /dev/null +++ b/src/extensionsIntegrated/Phoenix-live-preview/LivePreviewTabs.js @@ -0,0 +1,182 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/* + * Liveness of the pages a live preview session talks to: the docked iframe and any + * popped out tabs. Each page's worker sends a TAB_ONLINE heartbeat every few seconds, + * and a page silent for longer than the timeout is reported closed, which is the only + * close signal a crashed or killed tab ever gives. Silence is not always death, though: + * the OS pauses the timers and sockets of a backgrounded or sleeping app. So a page that + * heartbeats again after it was reported closed is reported back as connected, and a + * check that finds the editor itself was paused gives every page a fresh window instead + * of closing them all. + */ +define(function (require, exports, module) { + + const TAB_HEARTBEAT_TIMEOUT = 10000; + const CHECK_INTERVAL = 1000; + const MAX_EXPIRED = 50; + + // clientID -> {lastSeen: Date, URL: string, navigationTab: ?boolean} + const livePreviewTabs = new Map(); + // clientID -> page url, for every page whose BROWSER_CONNECT the editor accepted + const _pageURLs = new Map(); + // clientID -> page url, for the pages the heartbeat check reported closed + const _expired = new Map(); + let _callbacks = null; + let _timer = null; + let _lastCheck = 0; + + /** + * Records a page the editor accepted a connection from, so a later silence can be undone. + * @param {string} clientID + * @param {string} url the page url the connection was made with + */ + function tabConnected(clientID, url) { + _pageURLs.set(clientID, url); + _expired.delete(clientID); + } + + /** + * A heartbeat. A page reported closed by the check is reported back as connected. + * @param {string} clientID + * @param {string} url + * @param {boolean} [navigationTab] the loader tab hosting a popped out preview in the browser + * @param {number} [now] + */ + function tabOnline(clientID, url, navigationTab, now) { + const info = { lastSeen: new Date(now || Date.now()), URL: url }; + if (navigationTab) { + info.navigationTab = true; + } + livePreviewTabs.set(clientID, info); + const pageURL = _expired.get(clientID); + if (pageURL === undefined) { + return; + } + _expired.delete(clientID); + _pageURLs.set(clientID, pageURL); + if (_callbacks) { + _callbacks.reconnect(clientID, pageURL); + } + } + + /** + * Forgets a page for good: one that closed itself, or one the editor removed. A late + * heartbeat from it is then just a heartbeat, never a reconnect. + * @param {string} clientID + */ + function dropTab(clientID) { + livePreviewTabs.delete(clientID); + _pageURLs.delete(clientID); + _expired.delete(clientID); + } + + /** + * Reports every page silent for longer than the timeout as closed. + * @param {number} [now] + */ + // Whether any page was heard from after the given time. + function _heardSince(time) { + for (const info of livePreviewTabs.values()) { + if (info.lastSeen > time) { + return true; + } + } + return false; + } + + function checkHeartbeats(now) { + now = now || Date.now(); + const previousCheck = _lastCheck; + _lastCheck = now; + // A check that comes late with nothing heard in between is the editor itself + // having been paused, so the silence is its own and not the pages'. A late + // check with pages heard from in between is only this timer throttled in a + // background window, and the silent pages are as dead as ever. + if (previousCheck && now - previousCheck > TAB_HEARTBEAT_TIMEOUT && !_heardSince(previousCheck)) { + livePreviewTabs.forEach(function (info) { + info.lastSeen = new Date(now); + }); + return; + } + for (const [clientID, info] of Array.from(livePreviewTabs.entries())) { + if (now - info.lastSeen <= TAB_HEARTBEAT_TIMEOUT) { + continue; + } + livePreviewTabs.delete(clientID); + if (info.navigationTab) { + continue; + } + const pageURL = _pageURLs.get(clientID); + if (pageURL !== undefined) { + _pageURLs.delete(clientID); + _expired.set(clientID, pageURL); + if (_expired.size > MAX_EXPIRED) { + _expired.delete(_expired.keys().next().value); + } + } + if (_callbacks) { + _callbacks.close(clientID); + } + } + } + + /** + * @param {{close: function(string), reconnect: function(string, string)}} callbacks + */ + function setCallbacks(callbacks) { + _callbacks = callbacks; + } + + /** + * Starts the periodic check. Safe to call more than once. + * @param {{close: function(string), reconnect: function(string, string)}} callbacks + */ + function start(callbacks) { + setCallbacks(callbacks); + if (_timer) { + return; + } + _lastCheck = Date.now(); + _timer = setInterval(function () { + checkHeartbeats(); + }, CHECK_INTERVAL); + } + + function _resetForTests() { + livePreviewTabs.clear(); + _pageURLs.clear(); + _expired.clear(); + _callbacks = null; + _lastCheck = 0; + } + + exports.livePreviewTabs = livePreviewTabs; + exports.tabConnected = tabConnected; + exports.tabOnline = tabOnline; + exports.dropTab = dropTab; + exports.checkHeartbeats = checkHeartbeats; + exports.setCallbacks = setCallbacks; + exports.start = start; + exports.TAB_HEARTBEAT_TIMEOUT = TAB_HEARTBEAT_TIMEOUT; + exports.MAX_EXPIRED = MAX_EXPIRED; + exports._resetForTests = _resetForTests; +}); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js index 0d9c4d067b..a941d40893 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/NodeStaticServer.js @@ -48,6 +48,7 @@ define(function (require, exports, module) { GFMCSSText = require("text!thirdparty/gfm.min.css"), markdownHTMLTemplate = require("text!./markdown.html"), NodeConnector = require("NodeConnector"), + LivePreviewTabs = require("./LivePreviewTabs"), redirectionHTMLTemplate = require("text!./redirectPage.html"); const LIVE_SERVER_NODE_CONNECTOR_ID = "ph_live_server"; @@ -73,7 +74,7 @@ define(function (require, exports, module) { EventDispatcher.makeEventDispatcher(exports); - const livePreviewTabs = new Map(); + const livePreviewTabs = LivePreviewTabs.livePreviewTabs; const PHCODE_LIVE_PREVIEW_QUERY_PARAM = "phcodeLivePreview"; let _staticServerInstance; @@ -99,11 +100,7 @@ define(function (require, exports, module) { async function tabLoaderOnline(data) { window.logger.livePreview.log("Live Preview navigator channel: tabLoaderOnline: ", data); - livePreviewTabs.set(data.pageLoaderID, { - lastSeen: new Date(), - URL: data.URL, - navigationTab: true - }); + LivePreviewTabs.tabOnline(data.pageLoaderID, data.URL, true); } // see markdown advanced rendering options at https://marked.js.org/using_advanced @@ -533,24 +530,16 @@ define(function (require, exports, module) { _staticServerInstance = undefined; }; + // A tab silent for too long is closed; one that heartbeats again is connected again. function _startHeartBeatListeners() { - // If we didn't receive heartbeat message from a tab for 10 seconds, we assume tab closed - const TAB_HEARTBEAT_TIMEOUT = 10000; // in millis secs - setInterval(()=>{ - let endTime = new Date(); - for(let tab of livePreviewTabs.keys()){ - const tabInfo = livePreviewTabs.get(tab); - let timeDiff = endTime - tabInfo.lastSeen; // in ms - if(timeDiff > TAB_HEARTBEAT_TIMEOUT){ - livePreviewTabs.delete(tab); - // the parent navigationTab `phcode.dev/live-preview-loader.html` which loads the live preview tab - // is in the list too. We should not raise browser close for a live-preview-loader tab. - if(!tabInfo.navigationTab) { - exports.trigger('BROWSER_CLOSE', { data: { message: {clientID: tab}}}); - } - } + LivePreviewTabs.start({ + close: function (clientID) { + exports.trigger('BROWSER_CLOSE', { data: { message: {clientID}}}); + }, + reconnect: function (clientID, url) { + exports.trigger('BROWSER_CONNECT', { data: { message: {clientID, url}}}); } - }, 1000); + }); } /** @@ -567,14 +556,18 @@ define(function (require, exports, module) { async function onLivePreviewMessage(message) { switch (message.type) { case EVENT_TAB_ONLINE: - livePreviewTabs.set(message.clientID, { - lastSeen: new Date(), - URL: message.URL - }); + LivePreviewTabs.tabOnline(message.clientID, message.URL); return; + case 'BROWSER_CONNECT': + LivePreviewTabs.tabConnected(message.clientID, message.url); + break; + case 'BROWSER_CLOSE': + LivePreviewTabs.dropTab(message.clientID); + break; default: - exports.trigger(message.type, { data: { message}}); + break; } + exports.trigger(message.type, { data: { message}}); } function redirectAllTabs(newURL, force) { @@ -715,9 +708,11 @@ define(function (require, exports, module) { }); return; } else if(!_staticServerInstance || !_staticServerInstance.getBaseUrl()){ + // not an answer about the file: the server restarts each time the preview opens resolve({ URL: getNoPreviewURL(), - isNoPreview: true + isNoPreview: true, + isServerNotReady: true }); return; } else if(utils.isPreviewableFile(fullPath)){ @@ -807,6 +802,7 @@ define(function (require, exports, module) { exports.StaticServer = StaticServer; exports.messageToLivePreviewTabs = messageToLivePreviewTabs; exports.livePreviewTabs = livePreviewTabs; + exports.dropTab = LivePreviewTabs.dropTab; exports.redirectAllTabs = redirectAllTabs; exports.getTabPopoutURL = getTabPopoutURL; exports.hasActiveLivePreviews = hasActiveLivePreviews; diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index 3b4b4bcb1d..db2f44958a 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -642,10 +642,16 @@ define(function (require, exports, module) { } const clientID = _dockedClientID; _dockedClientID = null; - StaticServer.livePreviewTabs.delete(clientID); + StaticServer.dropTab(clientID); StaticServer.trigger('BROWSER_CLOSE', { data: { message: {clientID}}}); } + // every removal of the docked page goes through here, so its connection never outlives it + function _removeDockedIframe() { + _dropDockedConnection(); + $iframe.remove(); + } + function _blankIframe() { // we have to remove the dom node altog as at time chrome fails to clear workers if we just change // src. so we delete the node itself to eb thorough. @@ -671,8 +677,7 @@ define(function (require, exports, module) { } else { let newIframe = $(LIVE_PREVIEW_IFRAME_HTML); newIframe.insertAfter($iframe); - _dropDockedConnection(); - $iframe.remove(); + _removeDockedIframe(); $iframe = newIframe; } } @@ -969,16 +974,7 @@ define(function (require, exports, module) { $pinUrlBtn.click(_togglePinUrl); $livePreviewPopBtn.click(_popoutLivePreview); $reloadBtn.click(()=>{ - if (_isMdviewrActive && urlPinned) { - // When pinned, just re-send the pinned document's content - MarkdownSync.resendContent(); - Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "reloadBtn", "click"); - return; - } - if (_isMdviewrActive) { - MarkdownSync.reloadCurrentFile(); - } - _loadPreview(true, true); + reloadLivePreview(); Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "reloadBtn", "click"); }); @@ -1042,7 +1038,7 @@ define(function (require, exports, module) { if ($mdviewrIframe && $mdviewrIframe[0].parentNode) { // Hide the current HTML iframe and show the md iframe if ($iframe[0] !== $mdviewrIframe[0]) { - $iframe.remove(); + _removeDockedIframe(); } $mdviewrIframe.show(); $iframe = $mdviewrIframe; @@ -1051,7 +1047,7 @@ define(function (require, exports, module) { const mdviewrURL = StaticServer.getMdviewrURL(); let newIframe = $(MDVIEWR_IFRAME_HTML); newIframe.insertAfter($iframe); - $iframe.remove(); + _removeDockedIframe(); $iframe = newIframe; $mdviewrIframe = newIframe; if (_isProjectPreviewTrusted()) { @@ -1070,6 +1066,21 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "render", "mdviewr"); } + /** + * Reloads the previewed page, the same way the panel's reload button does. + */ + function reloadLivePreview() { + if (_isMdviewrActive && urlPinned) { + // When pinned, just re-send the pinned document's content + MarkdownSync.resendContent(); + return; + } + if (_isMdviewrActive) { + MarkdownSync.reloadCurrentFile(); + } + _loadPreview(true, true); + } + /** * Renders the current preview target into the panel iframe. Note: html files outside the project currently * get the "Preview Unavailable" page. A verified prototype that instead renders them as static `file://` @@ -1148,7 +1159,10 @@ define(function (require, exports, module) { // preview breaks sporadically. to alleviate this, we create a new iframe every time. if(!urlPinned) { currentLivePreviewURL = newSrc; - _setPreviewedFile(previewDetails.fullPath); + // a server still starting names no file, so the last one stands until it answers + if(!previewDetails.isServerNotReady) { + _setPreviewedFile(previewDetails.fullPath); + } } if(isReload && previewDetails.isHTMLFile){ LiveDevelopment.openLivePreview(); @@ -1171,7 +1185,7 @@ define(function (require, exports, module) { newIframe.insertAfter($iframe); // Don't remove the md iframe — it's persistent and already hidden if (!$mdviewrIframe || $iframe[0] !== $mdviewrIframe[0]) { - $iframe.remove(); + _removeDockedIframe(); } $iframe = newIframe; if(_isProjectPreviewTrusted()){ @@ -1834,6 +1848,7 @@ define(function (require, exports, module) { exports.getPreviewedFilePath = getPreviewedFilePath; exports.canPopoutLivePreview = canPopoutLivePreview; exports.popoutLivePreview = popoutLivePreview; + exports.reloadLivePreview = reloadLivePreview; }); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index fd86c62978..6db874e999 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -799,14 +799,16 @@ define({ "LIVE_PREVIEW_LAYERS_OPEN_IN_BROWSER": "Open in Browser", "LIVE_PREVIEW_LAYERS_NEEDS_HTML": "Open an HTML file to see its layers.", "LIVE_PREVIEW_LAYERS_PICK_HTML": "Pick an HTML file to see its layers.", - "LIVE_PREVIEW_LAYERS_FILTER_FILES": "Filter files", + "LIVE_PREVIEW_LAYERS_SEARCH_FILES": "Search {0} files", + "LIVE_PREVIEW_LAYERS_SHOW_MORE_FILES": "Show {0} more", + "LIVE_PREVIEW_LAYERS_SHOW_FEWER_FILES": "Show fewer", "LIVE_PREVIEW_LAYERS_NO_FILE_MATCHES": "No matching files", "LIVE_PREVIEW_LAYERS_FILES_TRUNCATED": "Showing the first {0} files", "LIVE_PREVIEW_LAYERS_NEEDS_EDIT_MODE": "The Layers panel needs edit mode.", "LIVE_PREVIEW_LAYERS_ENABLE_EDIT_MODE": "Turn on Edit Mode", + "LIVE_PREVIEW_LAYERS_LOAD_FAILED": "The Layers panel could not load. Restart {APP_NAME} to try again.", "LIVE_PREVIEW_LAYERS_ELEMENTS": "Elements", "LIVE_PREVIEW_LAYERS_SEARCH": "Search elements", - "LIVE_PREVIEW_LAYERS_NO_ELEMENTS": "No elements to show", "LIVE_PREVIEW_LAYERS_NO_MATCHES": "No matching elements", "LIVE_PREVIEW_LAYERS_TRUNCATED": "Showing the first {0} elements", "LIVE_PREVIEW_LAYERS_COLLAPSE_ALL": "Collapse All", @@ -862,6 +864,8 @@ define({ "LIVE_PREVIEW_LAYERS_LOADING": "Loading elements…", "LIVE_PREVIEW_LAYERS_UNAVAILABLE": "The live preview did not answer.", "LIVE_PREVIEW_LAYERS_RETRY": "Try again", + "LIVE_PREVIEW_LAYERS_NOT_RESPONDING": "The live preview is not responding.", + "LIVE_PREVIEW_LAYERS_RELOAD_PREVIEW": "Reload preview", "LIVE_PREVIEW_LAYERS_EMPTY_PAGE": "This page has no elements yet.", "LIVE_PREVIEW_LAYERS_ADD_ELEMENT": "Add an element", "LIVE_PREVIEW_LAYERS_EMPTY_PAGE_PROPERTIES": "The page is empty. Add an element to see its properties.", diff --git a/src/styles/Extn-LayersPanel.less b/src/styles/Extn-LayersPanel.less index e1d4dad204..edc94208e4 100644 --- a/src/styles/Extn-LayersPanel.less +++ b/src/styles/Extn-LayersPanel.less @@ -35,7 +35,6 @@ @layers-label-min-width: 72px; @layers-row-intrinsic-width: 115px; @layers-tools-width: 106px; -@layers-indent: 14px; @layers-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; @layers-prop-name: #9cdcfe; @layers-prop-value: #ce9178; @@ -118,12 +117,32 @@ min-height: 0; overflow: hidden; position: relative; + container-type: inline-size; background-color: @bc-ai-sidebar-bg; color: @project-panel-text-1; font-size: @layers-font-size; letter-spacing: normal; text-shadow: none; + #sidebar & { + .layers-section-content, + .layers-files-list, + .layers-message { + &::-webkit-scrollbar { + width: 9px; + height: 9px; + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background-color: rgba(255, 255, 255, 0.12); + background-clip: padding-box; + border: 2px solid transparent; + } + } + } + .layers-hidden { display: none !important; } @@ -251,11 +270,7 @@ .layers-message.layers-message-files { justify-content: flex-start; - padding: 32px 8px 8px; - - .layers-message-text { - margin-bottom: 14px; - } + padding: 40px 14px 10px; } .layers-files { @@ -263,11 +278,13 @@ flex-direction: column; flex: 0 1 auto; width: 100%; + max-width: 300px; min-height: 0; + margin-top: 18px; text-align: left; .layers-search { - padding: 0 0 6px; + padding: 0 0 8px; } } @@ -278,40 +295,45 @@ overflow-y: auto; } - .layers-file { + .layers-file, + .layers-folder { box-sizing: border-box; display: flex; align-items: center; - gap: 8px; + gap: 9px; width: 100%; - height: @layers-row-height; - padding: 0 8px; + height: 30px; + padding: 0 10px; margin: 0; border: none; - border-radius: 5px; + border-radius: 3px; background: transparent; color: @project-panel-text-1; font-family: inherit; - font-size: @layers-font-size; + font-size: @layers-font-tree; text-align: left; white-space: nowrap; cursor: pointer; outline: none; - &:hover { + &:hover, + &:focus-visible { background: @layers-row-hover-bg; + + .layers-folder-chevron { + color: @project-panel-text-1; + } } &:focus-visible { - background: @layers-row-hover-bg; box-shadow: inset 0 0 0 1px @layers-accent; } } .layers-file-icon { flex: none; - width: 14px; - font-size: 13px; + width: 16px; + font-size: 15px; text-align: center; color: @project-panel-text-2; } @@ -324,6 +346,26 @@ color: #8892bf; } + .layers-folder-chevron { + display: flex; + align-items: center; + justify-content: center; + height: 16px; + transition: color 120ms ease; + + svg { + flex: none; + width: 20px; + height: 20px; + transform: rotate(-90deg); + transition: transform 120ms ease; + } + } + + .layers-folder-open .layers-folder-chevron svg { + transform: none; + } + .layers-file-name { flex: 0 1 auto; min-width: 0; @@ -347,6 +389,45 @@ text-align: center; } + .layers-files-more-row { + box-sizing: border-box; + display: flex; + flex: none; + justify-content: center; + padding: 8px 10px 2px; + } + + .layers-files-more { + box-sizing: border-box; + flex: none; + height: 26px; + padding: 0 12px; + margin: 0; + border: 1px solid @layers-border-strong; + border-radius: 6px; + background: @layers-input-bg; + color: @project-panel-text-1; + font-family: inherit; + font-size: @layers-font-xs; + line-height: 1; + cursor: pointer; + outline: none; + transition: background-color 120ms ease, border-color 120ms ease; + + &:hover { + background: @layers-hover-bg; + border-color: rgba(255, 255, 255, 0.22); + } + + &:active { + background: @layers-input-bg; + } + + &:focus-visible { + border-color: @layers-accent; + } + } + .layers-panel { display: flex; flex-direction: column; @@ -372,7 +453,7 @@ .layers-section-properties .layers-section-content, .layers-section-styles .layers-section-content { - padding: 2px 8px 10px; + padding: 2px 8px 10px 14px; } .layers-section-header { @@ -483,7 +564,10 @@ } .layers-section.layers-collapsed .layers-section-content, - .layers-section.layers-collapsed .layers-search, + .layers-section.layers-collapsed .layers-search { + display: none; + } + .layers-divider { position: relative; flex: 0 0 5px; @@ -894,8 +978,47 @@ } .layers-empty { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; padding: 10px 12px; color: @project-panel-text-2; + white-space: normal; + overflow: hidden; + overflow-wrap: break-word; + } + + @container (max-width: 95px) { + .layers-empty { + display: none; + } + } + + .layers-pending { + display: flex; + align-items: center; + gap: 5px; + height: 20px; + padding: 10px 12px; + box-sizing: content-box; + + span { + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: @project-panel-text-2; + opacity: 0.25; + animation: layers-pending-dot 900ms ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 150ms; + } + + &:nth-child(3) { + animation-delay: 300ms; + } + } } .layers-tree.layers-tree-empty { @@ -2133,7 +2256,26 @@ body.layers-dnd-invalid { } } +@keyframes layers-pending-dot { + 0%, + 60%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + + 30% { + opacity: 1; + transform: translateY(-3px); + } +} + @media (prefers-reduced-motion: reduce) { + .layers-tab-container .layers-pending span { + animation: none; + opacity: 0.6; + } + .layers-tab-container .layers-loading-page { animation-duration: 1ms; } diff --git a/src/styles/brackets_scrollbars.less b/src/styles/brackets_scrollbars.less index 71595bec98..553eff9728 100644 --- a/src/styles/brackets_scrollbars.less +++ b/src/styles/brackets_scrollbars.less @@ -27,7 +27,10 @@ whole list flash lighter under the pointer. */ .open-files-container:hover, #project-files-container:hover, -.ai-chat-messages:hover { +.ai-chat-messages:hover, +.layers-section-content:hover, +.layers-files-list:hover, +.layers-message:hover { background-color: inherit; } diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 703b2f10c0..845c4f1563 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -115,6 +115,7 @@ define(function (require, exports, module) { require("spec/Template-for-integ-test"); require("spec/LiveDevelopmentMultiBrowser-test"); require("spec/LiveDevelopmentCustomServer-test"); + require("spec/LivePreviewTabs-test"); require("spec/md-editor-integ-test"); require("spec/md-editor-edit-integ-test"); require("spec/md-editor-edit-more-integ-test"); diff --git a/test/spec/LivePreviewTabs-test.js b/test/spec/LivePreviewTabs-test.js new file mode 100644 index 0000000000..d53eb407a1 --- /dev/null +++ b/test/spec/LivePreviewTabs-test.js @@ -0,0 +1,178 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect, beforeEach, afterEach */ + +define(function (require, exports, module) { + + const LivePreviewTabs = require("extensionsIntegrated/Phoenix-live-preview/LivePreviewTabs"); + + describe("unit:Live preview tab heartbeats", function () { + const TIMEOUT = LivePreviewTabs.TAB_HEARTBEAT_TIMEOUT; + const PAGE_URL = "http://localhost:1234/index.html"; + let closed, reconnected, now; + + // the check runs once a second while the editor is awake + function tick(ms) { + for (let elapsed = 0; elapsed < ms; elapsed += 1000) { + now += 1000; + LivePreviewTabs.checkHeartbeats(now); + } + } + + // no checks and no heartbeats while the whole app is paused + function sleepApp(ms) { + now += ms; + LivePreviewTabs.checkHeartbeats(now); + } + + function connectPage(id) { + LivePreviewTabs.tabOnline(id, "worker.js", false, now); + LivePreviewTabs.tabConnected(id, PAGE_URL); + } + + beforeEach(function () { + LivePreviewTabs._resetForTests(); + closed = []; + reconnected = []; + LivePreviewTabs.setCallbacks({ + close: function (id) { + closed.push(id); + }, + reconnect: function (id, url) { + reconnected.push({ id: id, url: url }); + } + }); + now = 1000000; + LivePreviewTabs.checkHeartbeats(now); + }); + + afterEach(function () { + LivePreviewTabs._resetForTests(); + }); + + it("should keep a page that heartbeats in time", function () { + connectPage("a"); + for (let i = 0; i < 20; i++) { + tick(1000); + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + } + expect(closed).toEqual([]); + expect(LivePreviewTabs.livePreviewTabs.has("a")).toBe(true); + }); + + it("should report a silent page closed after the timeout", function () { + connectPage("a"); + tick(TIMEOUT); + expect(closed).toEqual([]); + tick(1000); + expect(closed).toEqual(["a"]); + expect(LivePreviewTabs.livePreviewTabs.has("a")).toBe(false); + }); + + it("should report a closed page back as connected when its heartbeats resume", function () { + connectPage("a"); + tick(TIMEOUT + 1000); + expect(closed).toEqual(["a"]); + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + expect(reconnected).toEqual([{ id: "a", url: PAGE_URL }]); + expect(LivePreviewTabs.livePreviewTabs.has("a")).toBe(true); + // the next heartbeat is only a heartbeat + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + expect(reconnected.length).toBe(1); + }); + + it("should report a page closed and back again on every silence", function () { + connectPage("a"); + tick(TIMEOUT + 1000); + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + tick(TIMEOUT + 1000); + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + expect(closed).toEqual(["a", "a"]); + expect(reconnected.length).toBe(2); + }); + + it("should not reconnect a page that only heartbeats but never connected", function () { + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + tick(TIMEOUT + 1000); + expect(closed).toEqual(["a"]); + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + expect(reconnected).toEqual([]); + }); + + it("should never reconnect a page that was dropped", function () { + connectPage("a"); + LivePreviewTabs.dropTab("a"); + expect(LivePreviewTabs.livePreviewTabs.has("a")).toBe(false); + // a late heartbeat from the dying page is just a heartbeat + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + expect(reconnected).toEqual([]); + tick(TIMEOUT + 1000); + expect(closed).toEqual(["a"]); + LivePreviewTabs.tabOnline("a", "worker.js", false, now); + expect(reconnected).toEqual([]); + }); + + it("should not report pages closed when the editor itself was paused", function () { + connectPage("a"); + connectPage("b"); + sleepApp(TIMEOUT * 6); + expect(closed).toEqual([]); + expect(LivePreviewTabs.livePreviewTabs.has("a")).toBe(true); + expect(LivePreviewTabs.livePreviewTabs.has("b")).toBe(true); + // the pages get a full window to resume in + tick(TIMEOUT); + expect(closed).toEqual([]); + tick(1000); + expect(closed).toEqual(["a", "b"]); + }); + + it("should still report a silent page closed when only the check was late", function () { + connectPage("a"); + connectPage("b"); + // the check timer was throttled in a hidden window, but page a kept heartbeating + now += TIMEOUT * 6; + LivePreviewTabs.tabOnline("a", "worker.js", false, now - 1000); + LivePreviewTabs.checkHeartbeats(now); + expect(closed).toEqual(["b"]); + expect(LivePreviewTabs.livePreviewTabs.has("a")).toBe(true); + }); + + it("should not report a loader tab closed", function () { + LivePreviewTabs.tabOnline("loader", "loader.html", true, now); + tick(TIMEOUT + 1000); + expect(closed).toEqual([]); + expect(LivePreviewTabs.livePreviewTabs.has("loader")).toBe(false); + }); + + it("should forget the oldest closed pages beyond the limit", function () { + const total = LivePreviewTabs.MAX_EXPIRED + 1; + for (let i = 0; i < total; i++) { + connectPage("p" + i); + } + tick(TIMEOUT + 1000); + expect(closed.length).toBe(total); + LivePreviewTabs.tabOnline("p0", "worker.js", false, now); + expect(reconnected).toEqual([]); + LivePreviewTabs.tabOnline("p1", "worker.js", false, now); + expect(reconnected).toEqual([{ id: "p1", url: PAGE_URL }]); + }); + }); +}); diff --git a/tracking-repos.json b/tracking-repos.json index ed1a8bc1b9..ceb034a496 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "9e1ee1d4f8e5eb181a63329977cabebb3caec4d9" + "commitID": "d8b98ceb6dafb5f1425d5cdc9873d6fb96d105f8" } }