Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions src/LiveDevelopment/BrowserScripts/LiveDevProtocolRemote.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,11 +341,9 @@
* @param {string} msgStr The protocol message as stringified JSON.
*/
message: function (msgStr) {
var msg;
try {
msg = JSON.parse(msgStr);
} catch (e) {
console.error("[Brackets LiveDev] Malformed message received: ", msgStr);
const msg = JSON.parse(msgStr);
if(msg && typeof msg === "object" && msg.method === "PhoenixComm.execLPFn") {
_onLPFnTrigger(msg.fnName, msg.params);
return;
}
// delegates handling/routing to MessageBroker.
Expand All @@ -363,6 +361,37 @@

ProtocolManager.setProtocolHandler(ProtocolHandler);

const registeredPhoenixCommFns = {};

// never rejects
function _onLPFnTrigger(fnName, paramObj) {
const lpFn = registeredPhoenixCommFns[fnName];
if(!lpFn) {
console.error(`PhoenixComm: No such LP function ${fnName}`);
}
try {
const response = lpFn(paramObj);
if(response instanceof Promise) {
response.catch(err => {
console.error(`PhoenixComm: Error executing LP function ${fnName}`, err);
});
}
} catch (e) {
console.error(`PhoenixComm: Error executing LP function ${fnName}`, e);
}
}

const PhoenixComm = {
registerLpFn: function (fnName, fn) {
if(registeredPhoenixCommFns[fnName]){
throw new Error(`Function "${fnName}" already registered with PhoenixComm`);
}
registeredPhoenixCommFns[fnName] = fn;
}
};

global._Brackets_LiveDev_PhoenixComm = PhoenixComm;

window.addEventListener('load', function () {
ProtocolManager.enable();
});
Expand Down
7 changes: 7 additions & 0 deletions src/LiveDevelopment/BrowserScripts/RemoteFunctions.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ function RemoteFunctions(config = {}) {
HIGHLIGHT_CLASSNAME: "__brackets-ld-highlight" // CSS class name used for highlighting elements in live preview
};

// this is for bidirectional communication between phoenix and live preview
const PhoenixComm = window._Brackets_LiveDev_PhoenixComm;
PhoenixComm && PhoenixComm.registerLpFn("PH_Hello", function(param) {
// this is just a test function here to check if live preview. fn call is working correctly.
console.log("Hello World", param);
});

const SHARED_STATE = {
__description: "Use this to keep shared state for Live Preview Edit instead of window.*"
};
Expand Down
183 changes: 171 additions & 12 deletions src/LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ define(function (require, exports, module) {
const EVENT_LIVE_PREVIEW_CLICKED = "livePreviewClicked",
EVENT_LIVE_PREVIEW_RELOAD = "livePreviewReload";

const MAX_PENDING_LP_CALLS_1000 = 1000;

/**
* @private
* Active connections.
Expand All @@ -84,10 +86,84 @@ define(function (require, exports, module) {

/**
* @private
* A map of response IDs to deferreds, for messages that are awaiting responses.
* @type {Object}
* LRU cache of response IDs to deferreds, for messages that are awaiting responses.
* Uses LRU cache to prevent unbounded memory growth.
* @type {Phoenix.libs.LRUCache}
*/
const pendingLpResponses = new Phoenix.libs.LRUCache({
max: MAX_PENDING_LP_CALLS_1000
});

/**
* @private
* Reverse mapping: clientId -> Set of pending call IDs.
* Used to clean up pending calls when a client disconnects.
* @type {Map<number, Set<number>>}
*/
const pendingCallsByClient = new Map();

/**
* Track a pending call for cleanup when client disconnects.
* @param {number} fnCallID
* @param {number} clientId
* @param {$.Deferred} deferred
*/
function _trackPending(fnCallID, clientId, deferred) {
pendingLpResponses.set(fnCallID, { deferred, clientId });

let set = pendingCallsByClient.get(clientId);
if (!set) {
set = new Set();
pendingCallsByClient.set(clientId, set);
}
set.add(fnCallID);
}

/**
* Untrack a pending call (cleanup from both maps).
* @param {number} fnCallID
*/
var _responseDeferreds = {};
function _untrackPending(fnCallID) {
const pendingHandler = pendingLpResponses.get(fnCallID);
if (!pendingHandler) {
return;
}

// Delete from main cache
pendingLpResponses.delete(fnCallID);

// Clean up reverse mapping
if (pendingHandler.clientId) {
const set = pendingCallsByClient.get(pendingHandler.clientId);
if (set) {
set.delete(fnCallID);
if (set.size === 0) {
pendingCallsByClient.delete(pendingHandler.clientId);
}
}
}

return pendingHandler;
}

/**
* Reject all pending calls for a Live Preview client that disconnected.
* @param {number} clientId
*/
function rejectAllPendingForClient(clientId) {
const set = pendingCallsByClient.get(clientId);
if (!set || set.size === 0) {
return;
}

const callIds = Array.from(set);
for (const fnCallID of callIds) {
const pendingHandler = _untrackPending(fnCallID);
if (pendingHandler) {
pendingHandler.deferred.reject(new Error(`Live Preview client disconnected: ${clientId}`));
}
}
}

let _remoteFunctionProvider = null;

Expand Down Expand Up @@ -205,7 +281,7 @@ define(function (require, exports, module) {
}

const processedMessageIDs = new Phoenix.libs.LRUCache({
max: 1000
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
// start getting evited from the cache. the message ids are only an issue within a fraction of a seconds when
// a series of messages are sent in quick succession. Eg. user click on a div and there are 3 tabs and due to
Expand Down Expand Up @@ -236,7 +312,6 @@ define(function (require, exports, module) {
function _receive(clientId, msgStr, messageID) {
const msg = JSON.parse(msgStr),
event = msg.method || "event";
let deferred;
if(messageID && processedMessageIDs.has(messageID)){
return; // this message is already processed.
} else if (messageID) {
Expand All @@ -254,13 +329,12 @@ define(function (require, exports, module) {
}

if (msg.id) {
deferred = _responseDeferreds[msg.id];
if (deferred) {
delete _responseDeferreds[msg.id];
const pendingHandler = _untrackPending(msg.id);
if (pendingHandler) {
if (msg.error) {
deferred.reject(msg);
pendingHandler.deferred.reject(msg);
} else {
deferred.resolve(msg);
pendingHandler.deferred.resolve(msg);
}
}
} else if (msg.clicked && msg.tagId) {
Expand All @@ -278,7 +352,7 @@ define(function (require, exports, module) {
* Dispatches a message to the remote protocol handler via the transport.
*
* @param {Object} msg The message to send.
* @param {number|Array.<number>} idOrArray ID or IDs of the client(s) that should
* @param {number|Array.<number>} clients ID or IDs of the client(s) that should
* receive the message.
* @return {$.Promise} A promise that's fulfilled when the response to the message is received.
*/
Expand All @@ -294,7 +368,22 @@ define(function (require, exports, module) {
// broadcast if there are no specific clients
clients = clients || getConnectionIds();
msg.id = id;
_responseDeferreds[id] = result;

// Normalize clients to array
if (!Array.isArray(clients)) {
clients = [clients];
}

// If no clients available, reject immediately
if (clients.length === 0) {
result.reject(new Error("No live preview clients connected"));
return result.promise();
}

// Track pending call for the first client (representative)
const clientId = clients[0];
_trackPending(id, clientId, result);

_transport.send(clients, JSON.stringify(msg));
return result.promise();
}
Expand Down Expand Up @@ -327,6 +416,10 @@ define(function (require, exports, module) {
if(!_connections[clientId]){
return;
}

// Reject all pending calls for this client
rejectAllPendingForClient(clientId);

delete _connections[clientId];
exports.trigger("ConnectionClose", {
clientId: clientId
Expand Down Expand Up @@ -484,6 +577,68 @@ define(function (require, exports, module) {
);
}

const registeredFunctions = {};
function registerPhoenixFn(fnName, fn) {
if(registeredFunctions[fnName]){
throw new Error(`Function "${fnName}" already registered with LPComm`);
}
registeredFunctions[fnName] = fn;
}

/**
* Triggers a named API function in the Live Preview for one or more clients
* in a **fire-and-forget** manner.
*
* This API intentionally does **not** return a value or Promise.
*
* Live Preview connections are considered unreliable:
* - Multiple Live Preview clients may exist, or none at all.
* - Clients may be geographically distant and slow to respond.
* - If a Live Preview disconnects unexpectedly, Phoenix may take up to ~10 seconds
* to detect the disconnection, during which calls would otherwise block.
*
* Because of these constraints, this function does **not** wait for acknowledgements
* or responses, and callers should **not** rely on timely execution or delivery.
*
* Use this method only for best-effort notifications or side effects in Live Preview.
*
* If a response or guaranteed delivery is required, invoke this function using
* `triggerLPFn()` and have the corresponding Live Preview handler explicitly
* send the result back to Phoenix via `PhoenixComm.execFn`.
*
* @param {string} fnName
* Name of the Live Preview API function to invoke.
*
* @param {*} fnArgs
* Arguments to pass to the Live Preview function(object/string). Must be JSON-serializable.
*
* @param {number|number[]=} clientIdOrArray
* Optional client ID or array of client IDs obtained from getConnectionIds().
* If omitted, the function is executed for all active Live Preview connections.
*/
function triggerLPFn(fnName, fnArgs, clientIdOrArray) {
let clientIds;

if (clientIdOrArray === undefined || clientIdOrArray === null) {
clientIds = getConnectionIds();
} else if (Array.isArray(clientIdOrArray)) {
clientIds = clientIdOrArray;
} else {
clientIds = [clientIdOrArray];
}

clientIds.map(clientId => {
_transport.send([clientId], JSON.stringify({
method: "PhoenixComm.execLPFn",
fnName,
params: fnArgs
}));
});
}


window.ee= triggerLPFn; // todo remove this once all usages are migrated to execLPFn

/**
* Closes the connection to the given client. Proxies to the transport.
* @param {number} clientId
Expand All @@ -495,6 +650,7 @@ define(function (require, exports, module) {
function closeAllConnections() {
getConnectionIds().forEach(function (clientId) {
close(clientId);
rejectAllPendingForClient(clientId);
});
_connections = {};
}
Expand All @@ -514,6 +670,9 @@ define(function (require, exports, module) {
exports.closeAllConnections = closeAllConnections;
exports.setLivePreviewMessageHandler = setLivePreviewMessageHandler;
exports.setCustomRemoteFunctionProvider = setCustomRemoteFunctionProvider;
// lp communication functions
exports.registerPhoenixFn = registerPhoenixFn;
exports.triggerLPFn = triggerLPFn;
exports.LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_SCRIPTS_FILE_NAME;
exports.LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME = LIVE_DEV_REMOTE_WORKER_SCRIPTS_FILE_NAME;
exports.EVENT_LIVE_PREVIEW_CLICKED = EVENT_LIVE_PREVIEW_CLICKED;
Expand Down
13 changes: 12 additions & 1 deletion src/extensionsIntegrated/Phoenix/newly-added-features.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ define(function (require, exports, module) {
DocumentManager = require("document/DocumentManager"),
FileSystem = require("filesystem/FileSystem"),
FileUtils = require("file/FileUtils"),
StringUtils = require("utils/StringUtils"),
StateManager = require("preferences/StateManager"),
Metrics = require("utils/Metrics");

const STATE_LAST_SHOWN_HASH = "newlyAddedFeaturesHash";

function _getUpdateMarkdownURL() {
return Phoenix.baseURL + "assets/default-project/en/Newly_added_features.md";
}
Expand Down Expand Up @@ -66,8 +70,14 @@ define(function (require, exports, module) {
async function _showNewUpdatesIfPresent() {
// codemirror documents are always \n instead of \r\n line endings. so we strip here too
let newMarkdownText = (await _getUpdateMarkdownText()).replace(/\r/g, '');
let newMarkdownTextHash = StringUtils.hashCode(newMarkdownText);
if(StateManager.get(STATE_LAST_SHOWN_HASH) === newMarkdownTextHash){
// already shown this update, so no need to show it again.
return;
}
let currentMarkdownText = (await _readMarkdownTextFile()).replace(/\r/g, '');
if(newMarkdownText !== currentMarkdownText){
StateManager.set(STATE_LAST_SHOWN_HASH, newMarkdownTextHash);
let markdownFile = FileSystem.getFileForPath(_getUpdateMarkdownLocalPath());
// if the user overwrites the markdown file, then the user edited content will be nuked here.
FileUtils.writeText(markdownFile, newMarkdownText, true)
Expand All @@ -80,7 +90,8 @@ define(function (require, exports, module) {

exports.init = function () {
if(!Phoenix.firstBoot && !window.testEnvironment){
_showNewUpdatesIfPresent();
_showNewUpdatesIfPresent()
.catch(console.error); // fine if we dont show it once. happens mostly when offline.
}
};
});
Loading