434 lines
12 KiB
JavaScript
434 lines
12 KiB
JavaScript
/* 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" +
|
|
"https://firefox-source-docs.mozilla.org/devtools-user/json_viewer/",
|
|
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"));
|
|
});
|
|
});
|
|
});
|