Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint no-shadow: ["error", { "allow": ["dispatchEvent"] }] */
import ReactDOM from "resource://devtools/client/shared/vendor/react-dom.mjs";
import { createFactories } from "resource://devtools/client/shared/react-utils.mjs";
import MainTabbedAreaClass from "resource://devtools/client/jsonview/components/MainTabbedArea.mjs";
import TreeViewClass from "resource://devtools/client/shared/components/tree/TreeView.mjs";
import { ObjectProvider } from "resource://devtools/client/shared/components/tree/ObjectProvider.mjs";
import { JSON_NUMBER } from "resource://devtools/client/shared/components/reps/reps/constants.mjs";
import { parseJsonLossless } from "resource://devtools/client/shared/components/reps/reps/rep-utils.mjs";
import { createSizeProfile } from "resource://devtools/client/jsonview/json-size-profiler.mjs";
const { MainTabbedArea } = createFactories(MainTabbedAreaClass);
// Send readyState change notification event to the window. It's useful for tests.
JSONView.readyState = "loading";
window.dispatchEvent(new CustomEvent("AppReadyStateChange"));
const AUTO_EXPAND_MAX_SIZE = 100 * 1024;
const AUTO_EXPAND_MAX_LEVEL = 7;
const EXPAND_ALL_MAX_NODES = 100000;
const TABS = {
JSON: 0,
RAW_DATA: 1,
HEADERS: 2,
};
let prettyURL;
let theApp;
// Application state object.
const input = {
jsonText: JSONView.json,
jsonPretty: null,
headers: JSONView.headers,
activeTab: 0,
prettified: false,
expandedNodes: new Set(),
};
/**
* Recursively walk the tree and expand all nodes including buckets.
* Similar to TreeViewClass.getExpandedNodes but includes buckets.
*/
function expandAllNodes(data, { maxNodes = Infinity } = {}) {
const expandedNodes = new Set();
function walkTree(object, path = "") {
const children = ObjectProvider.getChildren(object, {
bucketLargeArrays: true,
});
// Check if adding these children would exceed the limit
if (expandedNodes.size + children.length > maxNodes) {
// Avoid having children half expanded
return;
}
for (const child of children) {
const key = ObjectProvider.getKey(child);
const childPath = TreeViewClass.subPath(path, key);
// Expand this node
expandedNodes.add(childPath);
// Recursively walk children
if (ObjectProvider.hasChildren(child)) {
walkTree(child, childPath);
}
}
}
// Start walking from the root if it's not a primitive
if (
data &&
typeof data === "object" &&
!(data instanceof Error) &&
data.type !== JSON_NUMBER
) {
walkTree(data);
}
return expandedNodes;
}
/**
* Recursively walk the tree and expand buckets that contain matches.
*/
function expandBucketsWithMatches(data, searchFilter) {
const expandedNodes = new Set(input.expandedNodes);
function walkTree(object, path = "") {
const children = ObjectProvider.getChildren(object, {
bucketLargeArrays: true,
});
for (const child of children) {
const key = ObjectProvider.getKey(child);
const childPath = TreeViewClass.subPath(path, key);
// Check if this is a bucket
if (ObjectProvider.getType(child) === "bucket") {
// Check if any children in the bucket match the filter
const { object: array, startIndex, endIndex } = child;
let hasMatch = false;
for (let i = startIndex; i <= endIndex; i++) {
const childJson = JSON.stringify(array[i]);
if (childJson.toLowerCase().includes(searchFilter)) {
hasMatch = true;
break;
}
}
if (hasMatch) {
expandedNodes.add(childPath);
}
} else if (ObjectProvider.hasChildren(child)) {
// Recursively walk non-bucket nodes
walkTree(child, childPath);
}
}
}
// Start walking from the root if it's not a primitive
if (
data &&
typeof data === "object" &&
!(data instanceof Error) &&
data.type !== JSON_NUMBER
) {
walkTree(data);
}
return expandedNodes;
}
/**
* Application actions/commands. This list implements all commands
* available for the JSON viewer.
*/
input.actions = {
onCopyJson() {
const text = input.prettified ? input.jsonPretty : input.jsonText;
copyString(text.textContent);
},
onSaveJson() {
if (input.prettified && !prettyURL) {
prettyURL = URL.createObjectURL(
new window.Blob([input.jsonPretty.textContent])
);
}
dispatchEvent("save", input.prettified ? prettyURL : null);
},
onCopyHeaders() {
let value = "";
const isWinNT = document.documentElement.getAttribute("platform") === "win";
const eol = isWinNT ? "\r\n" : "\n";
const responseHeaders = input.headers.response;
for (let i = 0; i < responseHeaders.length; i++) {
const header = responseHeaders[i];
value += header.name + ": " + header.value + eol;
}
value += eol;
const requestHeaders = input.headers.request;
for (let i = 0; i < requestHeaders.length; i++) {
const header = requestHeaders[i];
value += header.name + ": " + header.value + eol;
}
copyString(value);
},
onSearch(value) {
const expandedNodes = value
? expandBucketsWithMatches(input.json, value.toLowerCase())
: input.expandedNodes;
theApp.setState({ searchFilter: value, expandedNodes });
},
onPrettify() {
if (input.json instanceof Error) {
// Cannot prettify invalid JSON
return;
}
if (input.prettified) {
theApp.setState({ jsonText: input.jsonText });
} else {
if (!input.jsonPretty) {
input.jsonPretty = new Text(
JSON.stringify(
input.json,
(key, value) => {
if (value?.type === JSON_NUMBER) {
return JSON.rawJSON(value.source);
}
// By default, -0 will be stringified as `0`, so we need to handle it
if (Object.is(value, -0)) {
return JSON.rawJSON("-0");
}
return value;
},
" "
)
);
}
theApp.setState({ jsonText: input.jsonPretty });
}
input.prettified = !input.prettified;
},
onCollapse() {
input.expandedNodes.clear();
theApp.forceUpdate();
},
onExpand() {
input.expandedNodes = expandAllNodes(input.json, {
maxNodes: EXPAND_ALL_MAX_NODES,
});
theApp.setState({ expandedNodes: input.expandedNodes });
},
async onProfileSize() {
// Get the raw JSON string
const jsonString = input.jsonText.textContent;
// Get profiler URL from preferences and open window immediately
// to avoid popup blocker (profile creation may take several seconds)
const origin = JSONView.profilerUrl;
const profilerURL = origin + "/from-post-message/";
const profilerWindow = window.open(profilerURL, "_blank");
if (!profilerWindow) {
console.error("Failed to open profiler window");
return;
}
// Extract filename from URL
let filename;
try {
const pathname = window.location.pathname;
const lastSlash = pathname.lastIndexOf("/");
if (lastSlash !== -1 && lastSlash < pathname.length - 1) {
filename = decodeURIComponent(pathname.substring(lastSlash + 1));
}
} catch (e) {
// Invalid URL encoding, leave filename undefined
}
const profile = createSizeProfile(jsonString, filename);
// Wait for profiler to be ready and send the profile
let isReady = false;
const messageHandler = function (event) {
if (event.origin !== origin) {
return;
}
if (event.data && event.data.name === "ready:response") {
window.removeEventListener("message", messageHandler);
isReady = true;
}
};
window.addEventListener("message", messageHandler);
// Poll until the profiler window is ready. We need to poll because the
// postMessage will not be received if we send it before the profiler
// tab has finished loading.
while (!isReady) {
await new Promise(resolve => setTimeout(resolve, 100));
profilerWindow.postMessage({ name: "ready:request" }, origin);
}
profilerWindow.postMessage(
{
name: "inject-profile",
profile,
},
origin
);
},
};
/**
* Helper for copying a string to the clipboard.
*
* @param {string} string The text to be copied.
*/
function copyString(string) {
document.addEventListener(
"copy",
event => {
event.clipboardData.setData("text/plain", string);
event.preventDefault();
},
{ once: true }
);
document.execCommand("copy", false, null);
}
/**
* Helper for dispatching an event. It's handled in chrome scope.
*
* @param {string} type Event detail type
* @param {object} value Event detail value
*/
function dispatchEvent(type, value) {
const data = {
detail: {
type,
value,
},
};
const contentMessageEvent = new CustomEvent("contentMessage", data);
window.dispatchEvent(contentMessageEvent);
}
/**
* Render the main application component. It's the main tab bar displayed
* at the top of the window. This component also represents ReacJS root.
*/
const content = document.getElementById("content");
const promise = (async function parseJSON() {
if (document.readyState == "loading") {
// If the JSON has not been loaded yet, render the Raw Data tab first.
input.json = {};
input.activeTab = TABS.RAW_DATA;
return new Promise(resolve => {
document.addEventListener("DOMContentLoaded", resolve, { once: true });
})
.then(parseJSON)
.then(async () => {
// Now update the state and switch to the JSON tab.
await appIsReady;
theApp.setState({
activeTab: TABS.JSON,
json: input.json,
expandedNodes: input.expandedNodes,
});
});
}
// If the JSON has been loaded, parse it immediately before loading the app.
const jsonString = input.jsonText.textContent;
try {
input.json = parseJsonLossless(jsonString);
// Expose a clean public API for accessing JSON data from the console
// This is not tied to internal implementation details
window.$json = {
// The parsed JSON data
get data() {
return input.json;
},
// The original JSON text
get text() {
return jsonString;
},
// HTTP headers
get headers() {
return JSONView.headers;
},
};
// Log a welcome message to the console
const intro = "font-size: 130%;";
const bold = "font-family: monospace; font-weight: bold;";
const reset = "";
console.log(
"%cData available from the console:%c\n\n" +
"%c$json.data%c - The parsed JSON object\n" +
"%c$json.text%c - The original JSON text\n" +
"%c$json.headers%c - HTTP request and response headers\n\n" +
"The JSON Viewer is documented here:\n" +
intro,
reset,
bold,
reset,
bold,
reset,
bold,
reset
);
} catch (err) {
input.json = err;
// Display the raw data tab for invalid json
input.activeTab = TABS.RAW_DATA;
}
// Expand the document by default if its size isn't bigger than 100KB.
if (
!(input.json instanceof Error) &&
jsonString.length <= AUTO_EXPAND_MAX_SIZE
) {
input.expandedNodes = TreeViewClass.getExpandedNodes(input.json, {
maxLevel: AUTO_EXPAND_MAX_LEVEL,
});
}
return undefined;
})();
const appIsReady = new Promise(resolve => {
ReactDOM.render(MainTabbedArea(input), content, function () {
theApp = this;
resolve();
// Send readyState change notification event to the window. Can be useful for
// tests as well as extensions.
JSONView.readyState = "interactive";
window.dispatchEvent(new CustomEvent("AppReadyStateChange"));
promise.then(() => {
// Another readyState change notification event.
JSONView.readyState = "complete";
window.dispatchEvent(new CustomEvent("AppReadyStateChange"));
});
});
});