1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-26 12:29:06 +01:00

[TS dashboard] Draw dependencies between files (#17338)

If you're thinking about converting a file to TypeScript, you might want
to know what files import that file and what files it imports. This
commit makes it so that if you click on a box in the visualization, it
will draw lines between that box and the other boxes which represent its
dependents and dependencies.

Co-authored-by: legobeat <109787230+legobeat@users.noreply.github.com>
This commit is contained in:
Elliot Winkler 2023-03-16 20:03:12 -06:00 committed by GitHub
parent d45c4ed497
commit 3c622cd395
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 543 additions and 127 deletions

View File

@ -1,7 +1,9 @@
import React from 'react'; import React, { useState } from 'react';
import classnames from 'classnames';
import { Tooltip as ReactTippy } from 'react-tippy';
import { readPartitionsFile } from '../../common/partitions-file'; import { readPartitionsFile } from '../../common/partitions-file';
import type { ModulePartitionChild } from '../../common/build-module-partitions';
import Box from './Box';
import Connections from './Connections';
import type { BoxRect, BoxModel } from './types';
type Summary = { type Summary = {
numConvertedModules: number; numConvertedModules: number;
@ -12,16 +14,85 @@ function calculatePercentageComplete(summary: Summary) {
return ((summary.numConvertedModules / summary.numModules) * 100).toFixed(1); return ((summary.numConvertedModules / summary.numModules) * 100).toFixed(1);
} }
export default function App() { const partitions = readPartitionsFile();
const partitions = readPartitionsFile(); const allModules = partitions.flatMap((partition) => {
return partition.children;
});
const modulesById = allModules.reduce<Record<string, ModulePartitionChild>>(
(obj, module) => {
return { ...obj, [module.id]: module };
},
{},
);
const overallTotal = {
numConvertedModules: allModules.filter((module) => module.hasBeenConverted)
.length,
numModules: allModules.length,
};
const allModules = partitions.flatMap((partition) => { export default function App() {
return partition.children; const [boxRectsByModuleId, setBoxRectsById] = useState<Record<
}); string,
const overallTotal = { BoxRect
numConvertedModules: allModules.filter((module) => module.hasBeenConverted) > | null>(null);
.length, const boxesByModuleId =
numModules: allModules.length, boxRectsByModuleId === null
? {}
: Object.values(boxRectsByModuleId).reduce<Record<string, BoxModel>>(
(obj, boxRect) => {
const module = modulesById[boxRect.moduleId];
const dependencyBoxRects = module.dependencyIds.reduce<BoxRect[]>(
(dependencyBoxes, dependencyId) => {
if (boxRectsByModuleId[dependencyId] === undefined) {
return dependencyBoxes;
}
return [...dependencyBoxes, boxRectsByModuleId[dependencyId]];
},
[],
);
const dependentBoxRects = module.dependentIds.reduce<BoxRect[]>(
(dependentBoxes, dependentId) => {
if (boxRectsByModuleId[dependentId] === undefined) {
return dependentBoxes;
}
return [...dependentBoxes, boxRectsByModuleId[dependentId]];
},
[],
);
return {
...obj,
[boxRect.moduleId]: {
rect: boxRect,
dependencyBoxRects,
dependentBoxRects,
},
};
},
{},
);
const [activeBoxRectId, setActiveBoxRectId] = useState<string | null>(null);
const activeBoxRect =
boxRectsByModuleId === null || activeBoxRectId === null
? null
: boxRectsByModuleId[activeBoxRectId];
const registerBox = (id: string, boxRect: BoxRect) => {
setBoxRectsById((existingBoxRectsById) => {
if (existingBoxRectsById === undefined) {
return { [id]: boxRect };
}
return { ...existingBoxRectsById, [id]: boxRect };
});
};
const toggleConnectionsFor = (id: string) => {
if (activeBoxRectId !== undefined && activeBoxRectId === id) {
setActiveBoxRectId(null);
} else {
setActiveBoxRectId(id);
}
}; };
return ( return (
@ -50,17 +121,35 @@ export default function App() {
</p> </p>
<p> <p>
Each box Each box on this page represents a file in the codebase. Gray boxes
<div className="module module--inline module--to-be-converted"> <span className="module module--inline module--to-be-converted">
&nbsp; &nbsp;
</div> </span>
on this page represents a file that either we want to convert or represent files that need to be converted to TypeScript. Green boxes
we've already converted to TypeScript (hover over a box to see the <span className="module module--inline module--has-been-converted">
filename). Boxes that are
<div className="module module--inline module--to-be-converted module--test">
&nbsp; &nbsp;
</div> </span>
greyed out are test or Storybook files. are files that have already been converted. Faded boxes
<span className="module module--inline module--to-be-converted module--test">
&nbsp;
</span>
<span
className="module module--inline module--has-been-converted module--test"
style={{ marginLeft: 0 }}
>
&nbsp;
</span>
are test or Storybook files.
</p>
<p>
You can hover over a box to see the name of the file that it
represents. You can also click on a box to see connections between
other files;{' '}
<strong className="module-connection__dependency">red</strong> lines
lead to dependencies (other files that import the file);{' '}
<strong className="module-connection__dependent">blue</strong> lines
lead to dependents (other files that are imported by the file).
</p> </p>
<p> <p>
@ -103,36 +192,25 @@ export default function App() {
<div key={partition.level} className="partition"> <div key={partition.level} className="partition">
<div className="partition__name">level {partition.level}</div> <div className="partition__name">level {partition.level}</div>
<div className="partition__children"> <div className="partition__children">
{partition.children.map(({ name, hasBeenConverted }) => { {partition.children.map((module) => {
const isTest = /\.test\.(?:js|tsx?)/u.test(name); const areConnectionsVisible = activeBoxRectId === module.id;
const isStorybookModule = /\.stories\.(?:js|tsx?)/u.test(
name,
);
return ( return (
<ReactTippy <Box
key={name} key={module.id}
title={name} module={module}
arrow={true} register={registerBox}
animation="fade" toggleConnectionsFor={toggleConnectionsFor}
duration={250} areConnectionsVisible={areConnectionsVisible}
className="module__tooltipped" />
style={{ display: 'block' }}
>
<div
className={classnames('module', {
'module--has-been-converted': hasBeenConverted,
'module--to-be-converted': !hasBeenConverted,
'module--test': isTest,
'module--storybook': isStorybookModule,
})}
/>
</ReactTippy>
); );
})} })}
</div> </div>
</div> </div>
); );
})} })}
{activeBoxRect === null ? null : (
<Connections activeBox={boxesByModuleId[activeBoxRect.moduleId]} />
)}
</div> </div>
</> </>
); );

View File

@ -0,0 +1,69 @@
import React, { useEffect, useRef } from 'react';
import classnames from 'classnames';
import { Tooltip as ReactTippy } from 'react-tippy';
import type { ModulePartitionChild } from '../../common/build-module-partitions';
import type { BoxRect } from './types';
export default function Box({
module,
register,
toggleConnectionsFor,
areConnectionsVisible,
}: {
module: ModulePartitionChild;
register: (id: string, boxRect: BoxRect) => void;
toggleConnectionsFor: (id: string) => void;
areConnectionsVisible: boolean;
}) {
const isTest = /\.test\.(?:js|tsx?)/u.test(module.id);
const isStorybookModule = /\.stories\.(?:js|tsx?)/u.test(module.id);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current?.offsetParent) {
const rect = ref.current.getBoundingClientRect();
const offsetParentRect = ref.current.offsetParent.getBoundingClientRect();
const top = rect.top - offsetParentRect.top;
const left = rect.left - offsetParentRect.left;
const centerX = left + rect.width / 2;
const centerY = top + rect.height / 2;
register(module.id, {
moduleId: module.id,
top,
left,
width: rect.width,
height: rect.height,
centerX,
centerY,
});
}
}, [ref]);
const onClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
toggleConnectionsFor(module.id);
};
return (
<ReactTippy
title={module.id}
arrow={true}
animation="fade"
duration={250}
className="module__tooltipped"
style={{ display: 'block' }}
>
<div
ref={ref}
onClick={onClick}
className={classnames('module', {
'module--has-been-converted': module.hasBeenConverted,
'module--to-be-converted': !module.hasBeenConverted,
'module--test': isTest,
'module--storybook': isStorybookModule,
'module--active': areConnectionsVisible,
})}
/>
</ReactTippy>
);
}

View File

@ -0,0 +1,143 @@
import React from 'react';
import type { BoxModel } from './types';
function buildShapePoints(coordinates: [number, number][]): string {
return coordinates.map(([x, y]) => `${x},${y}`).join(' ');
}
function buildPathD(coordinates: [number, number][]): string {
return coordinates
.map(([x, y], index) => {
if (index === 0) {
return `M ${x},${y}`;
}
return `L ${x},${y}`;
})
.join(' ');
}
function Arrowhead({
type,
x,
y,
}: {
type: 'dependency' | 'dependent';
x: number;
y: number;
}) {
return (
<polygon
className={`module-connection__${type}-arrowhead`}
points={buildShapePoints([
[x - 6, y - 6],
[x + 6, y - 6],
[x, y],
])}
/>
);
}
function Line({
type,
originX,
originY,
originYOffset = 0,
destinationX,
destinationY,
destinationYOffset = 0,
}: {
type: 'dependency' | 'dependent';
originX: number;
originY: number;
originYOffset?: number;
destinationX: number;
destinationY: number;
destinationYOffset?: number;
}) {
const coordinates: [number, number][] =
type === 'dependency'
? [
[originX, originY],
[originX, originY + originYOffset],
[destinationX, originY + originYOffset],
[destinationX, destinationY],
]
: [
[originX, originY],
[originX, destinationY - destinationYOffset],
[destinationX, destinationY - destinationYOffset],
[destinationX, destinationY],
];
return (
<path
className={`module-connection__${type}`}
d={buildPathD(coordinates)}
/>
);
}
function LineStart({
type,
x,
y,
}: {
type: 'dependency' | 'dependent';
x: number;
y: number;
}) {
return (
<circle className={`module-connection__${type}-point`} cx={x} cy={y} />
);
}
export default function Connections({ activeBox }: { activeBox: BoxModel }) {
return (
<svg className="module-connections">
{activeBox.dependencyBoxRects.length === 0 ? null : (
<Arrowhead
type="dependency"
x={activeBox.rect.centerX}
y={activeBox.rect.centerY}
/>
)}
{activeBox.dependencyBoxRects.map((dependencyBoxRect) => {
return (
<React.Fragment key={dependencyBoxRect.moduleId}>
<Line
type="dependency"
originX={dependencyBoxRect.centerX}
originY={dependencyBoxRect.centerY}
originYOffset={dependencyBoxRect.height / 2 + 7}
destinationX={activeBox.rect.centerX}
destinationY={activeBox.rect.centerY}
/>
<LineStart
type="dependency"
x={dependencyBoxRect.centerX}
y={dependencyBoxRect.centerY}
/>
</React.Fragment>
);
})}
{activeBox.dependentBoxRects.map((dependentBoxRect) => {
return (
<React.Fragment key={dependentBoxRect.moduleId}>
<Line
type="dependent"
originX={activeBox.rect.centerX}
originY={activeBox.rect.centerY}
destinationX={dependentBoxRect.centerX}
destinationY={dependentBoxRect.centerY}
destinationYOffset={dependentBoxRect.height / 2 + 7}
/>
<Arrowhead
type="dependent"
x={dependentBoxRect.centerX}
y={dependentBoxRect.centerY}
/>
</React.Fragment>
);
})}
</svg>
);
}

View File

@ -0,0 +1,15 @@
export type BoxRect = {
moduleId: string;
top: number;
left: number;
width: number;
height: number;
centerX: number;
centerY: number;
};
export type BoxModel = {
rect: BoxRect;
dependencyBoxRects: BoxRect[];
dependentBoxRects: BoxRect[];
};

View File

@ -2,6 +2,10 @@
--blue-gray-350: hsl(209deg 13.7% 62.4%); --blue-gray-350: hsl(209deg 13.7% 62.4%);
--blue-gray-100: hsl(209.8deg 16.5% 89%); --blue-gray-100: hsl(209.8deg 16.5% 89%);
--green: hsl(113deg 100% 38%); --green: hsl(113deg 100% 38%);
--red: hsl(13deg 98% 61%);
--blue: hsl(246deg 97% 55%);
--light-cyan: hsl(178deg 100% 85%);
--cyan: hsl(178deg 100% 42%);
} }
.page-header { .page-header {
@ -69,6 +73,10 @@
border: 1px solid rgb(0 0 0 / 50%); border: 1px solid rgb(0 0 0 / 50%);
} }
.partitions {
position: relative;
}
.partition { .partition {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -94,8 +102,9 @@
.module { .module {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
border: 1px solid rgba(0 0 0 / 25%); border: 1px solid hsla(0deg 0% 0% / 25%);
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer;
&--inline { &--inline {
display: inline-block; display: inline-block;
@ -120,6 +129,55 @@
&--storybook { &--storybook {
opacity: 0.3; opacity: 0.3;
} }
&--active {
border-color: var(--cyan);
background-color: var(--light-cyan);
border-width: 2px;
}
}
.module-connections {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.module-connection {
&__dependency-arrowhead {
fill: var(--red);
}
&__dependency {
fill: none;
stroke: var(--red);
color: var(--red);
stroke-width: 2px;
}
&__dependency-point {
fill: var(--red);
r: 3px;
}
&__dependent-arrowhead {
fill: var(--blue);
}
&__dependent {
fill: none;
stroke: var(--blue);
color: var(--blue);
stroke-width: 2px;
}
&__dependent-point {
fill: var(--blue);
r: 3px;
}
} }
/* Package overrides */ /* Package overrides */

View File

@ -9,24 +9,42 @@ import {
} from './constants'; } from './constants';
/** /**
* Represents a module that has been imported somewhere in the codebase. * Represents a module that has been imported somewhere in the codebase, whether
* it is local to the project or an NPM package.
* *
* @property id - The name of a file or NPM module. * @property id - If an NPM package, then the name of the package; otherwise the
* path to a file within the project.
* @property dependents - The modules which are imported by this module. * @property dependents - The modules which are imported by this module.
* @property level - How many modules it takes to import this module (from the * @property level - How many modules it takes to import this module (from the
* root of the dependency tree). * root of the dependency tree).
* @property isExternal - Whether the module refers to a NPM module. * @property isExternal - Whether the module refers to a NPM package.
* @property hasBeenConverted - Whether the module was one of the files we * @property hasBeenConverted - Whether the module was one of the files we
* wanted to convert to TypeScript and has been converted. * wanted to convert to TypeScript and has been converted.
*/ */
type Module = { type Module = {
id: string; id: string;
dependents: Module[]; dependents: Module[];
dependencies: Module[];
level: number; level: number;
isExternal: boolean; isExternal: boolean;
hasBeenConverted: boolean; hasBeenConverted: boolean;
}; };
/**
* Represents a module that belongs to a certain level within the dependency
* graph, displayed as a box in the UI.
*
* @property id - The id of the module.
* @property hasBeenConverted - Whether or not the module has been converted to
* TypeScript.
*/
export type ModulePartitionChild = {
id: string;
hasBeenConverted: boolean;
dependentIds: string[];
dependencyIds: string[];
};
/** /**
* Represents a set of modules that sit at a certain level within the final * Represents a set of modules that sit at a certain level within the final
* dependency tree. * dependency tree.
@ -34,16 +52,10 @@ type Module = {
* @property level - How many modules it takes to import this module (from the * @property level - How many modules it takes to import this module (from the
* root of the dependency tree). * root of the dependency tree).
* @property children - The modules that share this same level. * @property children - The modules that share this same level.
* @property children[].name - The name of the item being imported.
* @property children[].hasBeenConverted - Whether or not the module (assuming
* that it is a file in our codebase) has been converted to TypeScript.
*/ */
export type ModulePartition = { export type ModulePartition = {
level: number; level: number;
children: { children: ModulePartitionChild[];
name: string;
hasBeenConverted: boolean;
}[];
}; };
/** /**
@ -196,10 +208,11 @@ function buildModulesWithLevels(
// includes `foo.js`, so we say `foo.js` is a circular dependency of `baz.js`, // includes `foo.js`, so we say `foo.js` is a circular dependency of `baz.js`,
// and we don't need to follow it. // and we don't need to follow it.
const modulesToFill: Module[] = entryModuleIds.map((moduleId) => { let modulesToFill: Module[] = entryModuleIds.map((moduleId) => {
return { return {
id: moduleId, id: moduleId,
dependents: [], dependents: [],
dependencies: [],
level: 0, level: 0,
isExternal: false, isExternal: false,
hasBeenConverted: /\.tsx?$/u.test(moduleId), hasBeenConverted: /\.tsx?$/u.test(moduleId),
@ -208,76 +221,101 @@ function buildModulesWithLevels(
const modulesById: Record<string, Module> = {}; const modulesById: Record<string, Module> = {};
while (modulesToFill.length > 0) { while (modulesToFill.length > 0) {
const currentModule = modulesToFill.shift() as Module; const currentModule = modulesToFill[0];
const childModulesToFill: Module[] = [];
(dependenciesByModuleId[currentModule.id] ?? []).forEach(
(givenChildModuleId) => {
const npmPackageMatch = givenChildModuleId.match(
/^node_modules\/(?:(@[^/]+)\/)?([^/]+)\/.+$/u,
);
let childModuleId; if (currentModule.level > 100) {
if (npmPackageMatch) { throw new Error(
childModuleId = "Can't build module partitions, as the dependency graph is being traversed ad infinitum.",
npmPackageMatch[1] === undefined );
? `${npmPackageMatch[2]}`
: `${npmPackageMatch[1]}/${npmPackageMatch[2]}`;
} else {
childModuleId = givenChildModuleId;
}
// Skip circular dependencies
if (
findDirectAndIndirectDependentIdsOf(currentModule).has(childModuleId)
) {
return;
}
// Skip files that weren't on the original list of JavaScript files to
// convert, as we don't want them to show up on the status dashboard
if (
!npmPackageMatch &&
!allowedFilePaths.includes(childModuleId.replace(/\.tsx?$/u, '.js'))
) {
return;
}
const existingChildModule = modulesById[childModuleId];
if (existingChildModule === undefined) {
const childModule: Module = {
id: childModuleId,
dependents: [currentModule],
level: currentModule.level + 1,
isExternal: Boolean(npmPackageMatch),
hasBeenConverted: /\.tsx?$/u.test(childModuleId),
};
childModulesToFill.push(childModule);
} else {
if (currentModule.level + 1 > existingChildModule.level) {
existingChildModule.level = currentModule.level + 1;
// Update descendant modules' levels
childModulesToFill.push(existingChildModule);
} else {
// There is no point in descending into dependencies of this module
// if the new level of the module would be <= the existing level,
// because all of the dependencies from this point on are guaranteed
// to have a higher level and are therefore already at the right
// level.
}
if (!existingChildModule.dependents.includes(currentModule)) {
existingChildModule.dependents.push(currentModule);
}
}
},
);
if (childModulesToFill.length > 0) {
modulesToFill.push(...childModulesToFill);
} }
const dependencyModulesToFill: Module[] = [];
for (const nonCanonicalDependencyModuleId of dependenciesByModuleId[
currentModule.id
]) {
// If the file path of the module is located in `node_modules`, use the
// name of the package as the ID
let dependencyModuleId;
const npmPackageMatch = nonCanonicalDependencyModuleId.match(
/^node_modules\/(?:(@[^/]+)\/)?([^/]+)\/.+$/u,
);
if (npmPackageMatch) {
dependencyModuleId =
npmPackageMatch[1] === undefined
? `${npmPackageMatch[2]}`
: `${npmPackageMatch[1]}/${npmPackageMatch[2]}`;
} else {
dependencyModuleId = nonCanonicalDependencyModuleId;
}
// Skip circular dependencies
if (
findDirectAndIndirectDependentIdsOf(currentModule).has(
dependencyModuleId,
)
) {
continue;
}
// Skip files that weren't on the original list of JavaScript files to
// convert, as we don't want them to show up on the status dashboard
if (
!npmPackageMatch &&
!allowedFilePaths.includes(
dependencyModuleId.replace(/\.tsx?$/u, '.js'),
)
) {
continue;
}
let existingDependencyModule = modulesById[dependencyModuleId];
if (existingDependencyModule === undefined) {
existingDependencyModule = {
id: dependencyModuleId,
dependents: [currentModule],
dependencies: [],
level: currentModule.level + 1,
isExternal: Boolean(npmPackageMatch),
hasBeenConverted: /\.tsx?$/u.test(dependencyModuleId),
};
dependencyModulesToFill.push(existingDependencyModule);
} else if (currentModule.level + 1 > existingDependencyModule.level) {
existingDependencyModule.level = currentModule.level + 1;
// Update descendant modules' levels
dependencyModulesToFill.push(existingDependencyModule);
} else {
// There is no point in descending into dependencies of this module
// if the new level of the module would be <= the existing level,
// because all of the dependencies from this point on are guaranteed
// to have a higher level and are therefore already at the right
// level.
}
if (
!existingDependencyModule.dependents.some(
(m) => m.id === currentModule.id,
)
) {
existingDependencyModule.dependents.push(currentModule);
}
if (
!currentModule.dependencies.some(
(m) => m.id === existingDependencyModule.id,
)
) {
currentModule.dependencies.push(existingDependencyModule);
}
}
if (dependencyModulesToFill.length > 0) {
modulesToFill.push(...dependencyModulesToFill);
}
if (!(currentModule.id in modulesById)) { if (!(currentModule.id in modulesById)) {
modulesById[currentModule.id] = currentModule; modulesById[currentModule.id] = currentModule;
} }
modulesToFill = modulesToFill.slice(1);
} }
return modulesById; return modulesById;
@ -288,20 +326,21 @@ function buildModulesWithLevels(
* which import that file directly or through some other file. * which import that file directly or through some other file.
* *
* @param module - A module in the graph. * @param module - A module in the graph.
* @returns The ids of the modules which are incoming connections to * @returns The ids of the modules which are direct and indirect dependents of
* the module. * the module.
*/ */
function findDirectAndIndirectDependentIdsOf(module: Module): Set<string> { function findDirectAndIndirectDependentIdsOf(module: Module): Set<string> {
const modulesToProcess = [module]; let modulesToProcess = [module];
const allDependentIds = new Set<string>(); const allDependentIds = new Set<string>();
while (modulesToProcess.length > 0) { while (modulesToProcess.length > 0) {
const currentModule = modulesToProcess.shift() as Module; const currentModule = modulesToProcess[0];
currentModule.dependents.forEach((dependent) => { for (const dependent of currentModule.dependents) {
if (!allDependentIds.has(dependent.id)) { if (!allDependentIds.has(dependent.id)) {
allDependentIds.add(dependent.id); allDependentIds.add(dependent.id);
modulesToProcess.push(dependent); modulesToProcess.push(dependent);
} }
}); }
modulesToProcess = modulesToProcess.slice(1);
} }
return allDependentIds; return allDependentIds;
} }
@ -328,8 +367,22 @@ function partitionModulesByLevel(
} }
Object.values(modulesById).forEach((module) => { Object.values(modulesById).forEach((module) => {
modulePartitions[module.level].children.push({ modulePartitions[module.level].children.push({
name: module.id, id: module.id,
hasBeenConverted: module.hasBeenConverted, hasBeenConverted: module.hasBeenConverted,
dependentIds: module.dependents.map((dependent) => dependent.id).sort(),
dependencyIds: module.dependencies
.map((dependency) => dependency.id)
.sort(),
});
});
Object.values(modulePartitions).forEach((partition) => {
partition.children.sort((a, b) => {
if (a.id < b.id) {
return -1;
} else if (a.id > b.id) {
return 1;
}
return 0;
}); });
}); });
return modulePartitions.reverse(); return modulePartitions.reverse();