diff --git a/src/renderer/components/app-core.tsx b/src/renderer/components/app-core.tsx index 628c1505..270a8e90 100644 --- a/src/renderer/components/app-core.tsx +++ b/src/renderer/components/app-core.tsx @@ -1,11 +1,10 @@ import { observer } from 'mobx-react'; -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import classNames from 'classnames'; import { getFirstLogFile } from '../../utils/get-first-logfile'; import { SleuthState } from '../state/sleuth'; import { - LevelFilter, MergedLogFiles, ProcessedLogFiles, LogType, @@ -28,85 +27,53 @@ export interface CoreAppProps { unzippedFiles: UnzippedFiles; } -export interface CoreAppState { - processedLogFiles: ProcessedLogFiles; - loadingMessage: string; - loadedLogFiles: boolean; - loadedMergeFiles: boolean; - filter: LevelFilter; - search?: string; -} - -@observer -export class CoreApplication extends React.Component< - CoreAppProps, - Partial -> { - constructor(props: CoreAppProps) { - super(props); - - this.state = { - processedLogFiles: { - browser: [], - webapp: [], - state: [], - installer: [], - netlog: [], - trace: [], - mobile: [], - chromium: [], - }, - loadingMessage: '', - loadedLogFiles: false, - loadedMergeFiles: false, - }; - } - - /** - * Once the component has mounted, we'll start processing files. - */ - public componentDidMount() { - this.processFiles(); - } - - public render() { - return this.state.loadedLogFiles - ? this.renderContent() - : this.renderLoading(); - } +export const CoreApplication = observer((props: CoreAppProps) => { + const [processedLogFiles, setProcessedLogFiles] = useState( + { + browser: [], + webapp: [], + state: [], + installer: [], + netlog: [], + trace: [], + mobile: [], + chromium: [], + }, + ); + const [loadingMessage, setLoadingMessage] = useState(''); + const [loadedLogFiles, setLoadedLogFiles] = useState(false); /** * Take an array of processed files (for logs) or unzipped files (for state files) * and add them to the state of this component. */ - private addFilesToState(filesToAdd: Partial) { - const { processedLogFiles } = this.state; - - if (!processedLogFiles) { - return; - } - - const newProcessedLogFiles: ProcessedLogFiles = { ...processedLogFiles }; - - for (const [type, filesOfType] of Object.entries(filesToAdd)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const currentState = processedLogFiles[ - type as keyof ProcessedLogFiles - ] as Array; - newProcessedLogFiles[type as keyof ProcessedLogFiles] = - currentState.concat(filesOfType); - } - - this.setState({ - processedLogFiles: newProcessedLogFiles, - }); - } + const addFilesToState = useCallback( + (filesToAdd: Partial) => { + setProcessedLogFiles((currentProcessedLogFiles) => { + const newProcessedLogFiles: ProcessedLogFiles = { + ...currentProcessedLogFiles, + }; + + for (const [type, filesOfType] of Object.entries(filesToAdd)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentState = currentProcessedLogFiles[ + type as keyof ProcessedLogFiles + ] as Array; + newProcessedLogFiles[type as keyof ProcessedLogFiles] = + currentState.concat(filesOfType); + } + + return newProcessedLogFiles; + }); + }, + [], + ); /** * Process files - most of the work happens over in ../processor.ts. */ - private async processFiles() { - const { unzippedFiles } = this.props; + const processFiles = async () => { + const { unzippedFiles, state } = props; const sortedUnzippedFiles = getTypesForFiles(unzippedFiles); const noFiles = Object.keys(sortedUnzippedFiles) @@ -127,106 +94,114 @@ export class CoreApplication extends React.Component< // Collect const { STATE, NETLOG, TRACE } = LogType; - const { state, netlog, trace } = sortedUnzippedFiles; + const { state: stateFiles, netlog, trace } = sortedUnzippedFiles; const rawLogFiles = { - [STATE]: state, + [STATE]: stateFiles, [NETLOG]: netlog, [TRACE]: trace, }; - this.addFilesToState(rawLogFiles); + addFilesToState(rawLogFiles); console.time('process-files'); // process log files for (const type of LOG_TYPES_TO_PROCESS) { const preFiles = sortedUnzippedFiles[type]; - const files = await processLogFiles(preFiles, (loadingMessage) => { - this.setState({ loadingMessage }); + const files = await processLogFiles(preFiles, (message) => { + setLoadingMessage(message); }); const delta: Partial = {}; delta[type] = files as ProcessedLogFile[]; - this.addFilesToState(delta); + addFilesToState(delta); } // also process state files for (const stateFile of rawLogFiles[STATE]) { const content = await window.Sleuth.readStateFile(stateFile); if (content) { - this.props.state.stateFiles[stateFile.fileName] = content; + state.stateFiles[stateFile.fileName] = content; } } console.timeEnd('process-files'); - const { processedLogFiles } = this.state; - const { selectedLogFile } = this.props.state; + // Get the latest processedLogFiles after all updates + setProcessedLogFiles((currentFiles) => { + // Update the global state with our processed files + state.processedLogFiles = currentFiles; - this.props.state.processedLogFiles = processedLogFiles; - - if (!selectedLogFile && processedLogFiles) { - this.props.state.selectedLogFile = getFirstLogFile(processedLogFiles); - } - this.setState({ loadedLogFiles: true }); - - // We're done processing the files, so let's get started on the merge files. - const { setMergedFile } = this.props.state; + // Set a selected log file if none exists + if (!state.selectedLogFile) { + state.selectedLogFile = getFirstLogFile(currentFiles); + } - if (processedLogFiles) { - await mergeLogFiles(processedLogFiles.browser, LogType.BROWSER).then( - setMergedFile, - ); - await mergeLogFiles(processedLogFiles.webapp, LogType.WEBAPP).then( - setMergedFile, - ); + // Start merging files + const { setMergedFile } = state; + + // Use the currentFiles that we just captured in the functional update + mergeLogFiles(currentFiles.browser, LogType.BROWSER).then(setMergedFile); + mergeLogFiles(currentFiles.webapp, LogType.WEBAPP).then(setMergedFile); + + // When both browser and webapp are merged, merge them together + Promise.all([ + mergeLogFiles(currentFiles.browser, LogType.BROWSER), + mergeLogFiles(currentFiles.webapp, LogType.WEBAPP), + ]).then(() => { + const merged = state.mergedLogFiles as MergedLogFiles; + if (merged?.browser && merged?.webapp) { + const toMerge = [merged.browser, merged.webapp]; + mergeLogFiles(toMerge, LogType.ALL).then(setMergedFile); + } + }); - const merged = this.props.state.mergedLogFiles as MergedLogFiles; - const toMerge = [merged.browser, merged.webapp]; + return currentFiles; + }); - mergeLogFiles(toMerge, LogType.ALL).then((r) => setMergedFile(r)); - } + // Mark as loaded + setLoadedLogFiles(true); - rehydrateBookmarks(this.props.state); + // Finish up with bookmarks and performance logging + rehydrateBookmarks(state); flushLogPerformance(); - } + }; + + useEffect(() => { + processFiles(); + }, []); /** * Returns a rounded percentage number for our init process. * * @returns {number} Percentage loaded */ - private getPercentageLoaded(): number { - const { unzippedFiles } = this.props; - const processedLogFiles: Partial = - this.state.processedLogFiles || {}; + const getPercentageLoaded = useCallback((): number => { + const { unzippedFiles } = props; const alreadyLoaded = Object.keys(processedLogFiles) .map((k: keyof ProcessedLogFiles) => processedLogFiles[k]) .reduce((p, c) => p + (c ? c.length : 0), 0); const toLoad = unzippedFiles.length; return Math.round((alreadyLoaded / toLoad) * 100); - } + }, [props.unzippedFiles, processedLogFiles]); /** * Renders both the sidebar as well as the Spotlight-like omnibar. * * @returns {JSX.Element} */ - private renderSidebarSpotlight(): JSX.Element { + const renderSidebarSpotlight = useCallback((): JSX.Element => { return ( <> - - + + ); - } + }, [props.state]); /** * Render the loading indicator. - * - * @returns {JSX.Element} */ - private renderLoading() { - const { loadingMessage } = this.state; - const percentageLoaded = this.getPercentageLoaded(); + const renderLoading = useCallback((): JSX.Element => { + const percentageLoaded = getPercentageLoaded(); return (
@@ -235,25 +210,25 @@ export class CoreApplication extends React.Component<
); - } + }, [getPercentageLoaded, loadingMessage]); /** * Render the actual content (when loaded). - * - * @returns {JSX.Element} */ - private renderContent(): JSX.Element { - const { isSidebarOpen } = this.props.state; + const renderContent = useCallback((): JSX.Element => { + const { isSidebarOpen } = props.state; const logContentClassName = classNames({ isSidebarOpen }); return (
- {this.renderSidebarSpotlight()} + {renderSidebarSpotlight()}
- +
); - } -} + }, [props.state, renderSidebarSpotlight]); + + return loadedLogFiles ? renderContent() : renderLoading(); +}); diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 5b24eab2..2f8df364 100644 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import classNames from 'classnames'; import { ConfigProvider, theme } from 'antd'; @@ -14,149 +14,134 @@ import { getWindowTitle } from '../../utils/get-window-title'; import { IpcEvents } from '../../ipc-events'; import { observer } from 'mobx-react'; -export interface AppState { - unzippedFiles: UnzippedFiles; - openEmpty?: boolean; -} +export const App = observer(() => { + const [unzippedFiles, setUnzippedFiles] = useState([]); + const [openEmpty, setOpenEmpty] = useState(); + const sleuthStateRef = useRef(null); -@observer -export class App extends React.Component> { - public readonly sleuthState: SleuthState; - - constructor(props: object) { - super(props); - - this.state = { - unzippedFiles: [], - }; - - localStorage.debug = 'sleuth:*'; - - this.openFile = this.openFile.bind(this); - this.resetApp = this.resetApp.bind(this); - this.sleuthState = new SleuthState(this.openFile, this.resetApp); - } - - /** - * Alright, time to show the window! - */ - public componentDidMount() { - window.Sleuth.sendWindowReady(); - - this.setupFileDrop(); - this.setupOpenSentry(); - this.setupWindowTitle(); - } + // Define these functions first so they can be passed to SleuthState constructor + const openFile = useCallback(async (url: string) => { + resetApp(); + const files = await window.Sleuth.openFile(url); + sleuthStateRef.current?.setSource(url); + setUnzippedFiles(files); + }, []); - public resetApp() { - this.setState({ unzippedFiles: [], openEmpty: false }); + const resetApp = useCallback(() => { + setUnzippedFiles([]); + setOpenEmpty(false); - if (this.sleuthState.opened > 0) { - this.sleuthState.reset(false); + if (sleuthStateRef.current && sleuthStateRef.current.opened > 0) { + sleuthStateRef.current.reset(false); } - this.sleuthState.opened = this.sleuthState.opened + 1; - this.sleuthState.getSuggestions(); - } - - /** - * Let's render this! - * - * @returns {JSX.Element} - */ - public render(): JSX.Element { - const { unzippedFiles, openEmpty } = this.state; - const className = classNames( - 'App', - { - // eslint-disable-next-line no-restricted-globals - Darwin: window.Sleuth.platform === 'darwin', - }, - 'antd', - ); - const titleBar = - window.Sleuth.platform === 'darwin' ? ( - - ) : ( - '' - ); - const content = - unzippedFiles && (unzippedFiles.length || openEmpty) ? ( - - ) : ( - - ); - - return ( - -
- - {titleBar} - {content} -
-
- ); - } + if (sleuthStateRef.current) { + sleuthStateRef.current.opened = sleuthStateRef.current.opened + 1; + sleuthStateRef.current.getSuggestions(); + } + }, []); - /** - * Automatically update the Window title - */ - private setupWindowTitle() { - autorun(() => { - document.title = getWindowTitle(this.sleuthState.source); - }); + // Initialize SleuthState on first render + if (!sleuthStateRef.current) { + localStorage.debug = 'sleuth:*'; + sleuthStateRef.current = new SleuthState(openFile, resetApp); } - /** - * Whenever a file is dropped into the window, we'll try to open it - */ - private setupFileDrop() { + const setupFileDrop = useCallback(() => { document.ondragover = document.ondrop = (event) => event.preventDefault(); document.body.ondrop = (event) => { if (event.dataTransfer && event.dataTransfer.files.length > 0) { let url = window.Sleuth.getPathForFile(event.dataTransfer.files[0]); url = url.replace('file:///', '/'); - this.resetApp(); - this.openFile(url); + resetApp(); + openFile(url); } event.preventDefault(); }; - window.Sleuth.setupFileDrop((_event, url: string) => this.openFile(url)); - } + return window.Sleuth.setupFileDrop((_event, url: string) => openFile(url)); + }, [openFile, resetApp]); - private setupOpenSentry() { - window.Sleuth.setupOpenSentry((event) => { + const setupOpenSentry = useCallback(() => { + return window.Sleuth.setupOpenSentry((event) => { // Get the file path to the installation file. Only app-* classes know. - const installationFile = this.state.unzippedFiles?.find((file) => { + const installationFile = unzippedFiles?.find((file) => { return file.fileName === 'installation'; }); event.sender.send(IpcEvents.OPEN_SENTRY, installationFile?.fullPath); }); - } + }, [unzippedFiles]); - private async openFile(url: string) { - this.resetApp(); - const files = await window.Sleuth.openFile(url); - this.sleuthState.setSource(url); - this.setState({ unzippedFiles: files }); - } -} + const setupWindowTitle = useCallback(() => { + return autorun(() => { + document.title = getWindowTitle(sleuthStateRef.current?.source); + }); + }, []); + + useEffect(() => { + window.Sleuth.sendWindowReady(); + + const fileDropCleanup = setupFileDrop(); + const openSentryCleanup = setupOpenSentry(); + const windowTitleDisposer = setupWindowTitle(); + + return () => { + fileDropCleanup && fileDropCleanup(); + openSentryCleanup && openSentryCleanup(); + windowTitleDisposer && windowTitleDisposer(); + }; + }, [setupFileDrop, setupOpenSentry, setupWindowTitle]); + + const className = classNames( + 'App', + { + // eslint-disable-next-line no-restricted-globals + Darwin: window.Sleuth.platform === 'darwin', + }, + 'antd', + ); + + const titleBar = + window.Sleuth.platform === 'darwin' ? ( + + ) : ( + '' + ); + + const content = + unzippedFiles && (unzippedFiles.length || openEmpty) ? ( + + ) : ( + + ); + + if (!sleuthStateRef.current) return null; + + return ( + +
+ + {titleBar} + {content} +
+
+ ); +}); diff --git a/src/renderer/components/devtools-view.tsx b/src/renderer/components/devtools-view.tsx index aacfee7f..ce52a06d 100644 --- a/src/renderer/components/devtools-view.tsx +++ b/src/renderer/components/devtools-view.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { observer } from 'mobx-react'; import { @@ -14,8 +14,7 @@ import { import { SleuthState } from '../state/sleuth'; import { autorun, IReactionDisposer } from 'mobx'; import { UnzippedFile } from '../../interfaces'; -import { TraceThreadDescription, TraceProcessor } from '../processor/trace'; -import autoBind from 'react-autobind'; +import { TraceProcessor } from '../processor/trace'; import debug from 'debug'; import { AreaChartOutlined } from '@ant-design/icons'; @@ -24,47 +23,105 @@ export interface DevtoolsViewProps { file: UnzippedFile; } -export interface DevtoolsViewState { - profilePid?: number; - profileType?: TraceThreadDescription['type']; -} - const d = debug('sleuth:devtoolsview'); -@observer -export class DevtoolsView extends React.Component< - DevtoolsViewProps, - DevtoolsViewState -> { - private disposeDarkModeAutorun: IReactionDisposer | undefined; - private processor: TraceProcessor; - - constructor(props: DevtoolsViewProps) { - super(props); - this.processor = new TraceProcessor(this.props.file); - autoBind(this); - this.state = {}; - this.prepare(); - } +export const DevtoolsView = observer((props: DevtoolsViewProps) => { + const [profilePid, setProfilePid] = useState(); + const disposeDarkModeAutorunRef = useRef(); + const processorRef = useRef(new TraceProcessor(props.file)); + + /** + * We have a little bit of css in catapult.html that'll enable a + * basic dark mode. + * + * @param {boolean} enabled + */ + const setDarkMode = useCallback((enabled: boolean) => { + try { + const iframe = document.getElementsByTagName('iframe'); - async prepare() { - const { state } = this.props; + if (iframe && iframe.length > 0) { + const devtoolsWindow = iframe[0].contentWindow; + + //custom protocol :// * + devtoolsWindow?.postMessage( + { + instruction: 'dark-mode', + payload: enabled, + }, + 'oop://oop/devtools-frontend.html', + ); + } + } catch (error) { + d(`Failed to set dark mode`, error); + } + }, []); + + /** + * Loads the currently selected file in catapult + */ + const loadFile = useCallback( + async (processId?: number) => { + const isDarkMode = props.state.prefersDarkColors; + setDarkMode(isDarkMode); + + if (!processId) { + return; + } + + d(`iFrame loaded`); + const iframe = document.querySelector('iframe'); + + if (iframe) { + const events = await processorRef.current.getRendererProfile(processId); + + // See catapult.html for the postMessage handler + const devtoolsWindow = iframe.contentWindow; + devtoolsWindow?.postMessage( + { + instruction: 'load', + payload: { events }, + }, + 'oop://oop/devtools-frontend.html', + ); + } + + disposeDarkModeAutorunRef.current = autorun(() => { + const isDarkMode = props.state.prefersDarkColors; + setDarkMode(isDarkMode); + }); + }, + [props.state.prefersDarkColors, setDarkMode], + ); + + const prepare = useCallback(async () => { + const { state } = props; if (!state.traceThreads) { - state.traceThreads = await this.processor.getProcesses(); + state.traceThreads = await processorRef.current.getProcesses(); } - } + }, [props]); - private renderThreads() { - const { traceThreads } = this.props.state; + useEffect(() => { + prepare(); + + return () => { + if (disposeDarkModeAutorunRef.current) { + disposeDarkModeAutorunRef.current(); + } + }; + }, [prepare]); + + const renderThreads = () => { + const { traceThreads } = props.state; const hasThreads = !!traceThreads?.length; const isLoading = !traceThreads; const startTime = parseInt( - this.props.file.fileName.split('.')[0]?.split('_')[4] || '0', + props.file.fileName.split('.')[0]?.split('_')[4] || '0', 10, ); const endTime = parseInt( - this.props.file.fileName.split('.')[0]?.split('_')[0] || '0', + props.file.fileName.split('.')[0]?.split('_')[0] || '0', 10, ); const duration = endTime - startTime; @@ -122,12 +179,9 @@ export class DevtoolsView extends React.Component< pid: value.processId, open: (