diff --git a/packages/devtools-vite/src/app/components/chunks/Graph.vue b/packages/devtools-vite/src/app/components/chunks/Graph.vue new file mode 100644 index 00000000..0112d4a6 --- /dev/null +++ b/packages/devtools-vite/src/app/components/chunks/Graph.vue @@ -0,0 +1,161 @@ + + + + + + + #{{ node.data.module.id }} + + + {{ node.data.module.name || '[unnamed]' }} + + + + + + {{ node.data.module.modules.length }} + + + + + diff --git a/packages/devtools-vite/src/app/components/data/AssetRelationships.vue b/packages/devtools-vite/src/app/components/data/AssetRelationships.vue index 32cc60b7..16e1f893 100644 --- a/packages/devtools-vite/src/app/components/data/AssetRelationships.vue +++ b/packages/devtools-vite/src/app/components/data/AssetRelationships.vue @@ -1,22 +1,15 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/devtools-vite/src/app/components/modules/Graph.vue b/packages/devtools-vite/src/app/components/modules/Graph.vue index f32485c0..a2258206 100644 --- a/packages/devtools-vite/src/app/components/modules/Graph.vue +++ b/packages/devtools-vite/src/app/components/modules/Graph.vue @@ -1,547 +1,183 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + diff --git a/packages/devtools-vite/src/app/composables/moduleGraph.ts b/packages/devtools-vite/src/app/composables/moduleGraph.ts new file mode 100644 index 00000000..ddb9a084 --- /dev/null +++ b/packages/devtools-vite/src/app/composables/moduleGraph.ts @@ -0,0 +1,354 @@ +import type { HierarchyLink, HierarchyNode } from 'd3-hierarchy' +import type { ComputedRef, InjectionKey, MaybeRef, Ref, ShallowReactive, ShallowRef } from 'vue' +import { onKeyPressed, useEventListener, useMagicKeys } from '@vueuse/core' +import { hierarchy, tree } from 'd3-hierarchy' +import { linkHorizontal, linkVertical } from 'd3-shape' +import { computed, inject, nextTick, provide, ref, shallowReactive, shallowRef, unref } from 'vue' +import { useZoomElement } from './zoomElement' + +export interface ModuleGraphNode { + module: M + import?: I + expanded?: boolean + hasChildren?: boolean +} + +export interface ModuleGraphSpacing { + width: MaybeRef + height: MaybeRef + linkOffset: MaybeRef + margin: MaybeRef + gap: MaybeRef +} + +export type ModuleGraphLink = HierarchyLink> & { + id: string + import?: I +} + +interface ModuleGraphGenerateBaseOptions { + spacing: ModuleGraphSpacing + isFirstCalculateGraph: Ref + container: Ref + nodesRefMap: ShallowReactive> + width: Ref + height: Ref + scale: Ref + childToParentMap: ShallowReactive> + collapsedNodes: ShallowReactive> +} + +interface ModuleGraphOptions { + spacing: ModuleGraphSpacing + modules: Ref | ComputedRef + generateGraph: (options: ModuleGraphGenerateBaseOptions & { + nodes: ShallowRef>[], HierarchyNode>[]> + links: ShallowRef[], ModuleGraphLink[]> + hierarchy: typeof hierarchy + tree: typeof tree + modulesMap: ComputedRef> + nodesMap: ShallowReactive>>> + linksMap: ShallowReactive>> + + focusOn: (id: string, animated?: boolean) => void + }) => (focusOnFirstRootNode?: boolean) => void +} + +const ViteDevToolsModuleGraphStateSymbol: InjectionKey>[], HierarchyNode>[]> + links: ShallowRef[], ModuleGraphLink[]> + calculateGraph: (focusOnFirstRootNode?: boolean) => void + ZOOM_MIN: number + ZOOM_MAX: number + zoomIn: (factor?: number) => void + zoomOut: (factor?: number) => void +}> = Symbol.for('ViteDevToolsModuleGraphState') + +export const createLinkHorizontal = linkHorizontal() + .x(d => d[0]) + .y(d => d[1]) + +export const createLinkVertical = linkVertical() + .x(d => d[0]) + .y(d => d[1]) + +export function generateModuleGraphLink(link: ModuleGraphLink, spacing?: ModuleGraphSpacing) { + if (!spacing) { + if (link.target.x! <= link.source.x!) { + return createLinkVertical({ + source: [link.source.x!, link.source.y!], + target: [link.target.x!, link.target.y!], + }) + } + return createLinkHorizontal({ + source: [link.source.x!, link.source.y!], + target: [link.target.x!, link.target.y!], + }) + } + else { + if (link.target.x! <= link.source.x!) { + return createLinkVertical({ + source: [link.source.x! + unref(spacing.width) / 2 - unref(spacing.linkOffset), link.source.y!], + target: [link.target.x! - unref(spacing.width) / 2 + unref(spacing.linkOffset), link.target.y!], + }) + } + return createLinkHorizontal({ + source: [link.source.x! + unref(spacing.width) / 2 - unref(spacing.linkOffset), link.source.y!], + target: [link.target.x! - unref(spacing.width) / 2 + unref(spacing.linkOffset), link.target.y!], + }) + } +} +// @unocss-include +export function getModuleGraphLinkColor(_link: ModuleGraphLink) { + return 'stroke-#8885' +} + +export function createModuleGraph(options: ModuleGraphOptions) { + const ZOOM_MIN = 0.4 + const ZOOM_MAX = 2 + const container = ref() as Ref + const width = ref(window.innerWidth) + const height = ref(window.innerHeight) + const nodesRefMap = shallowReactive(new Map()) + const collapsedNodes = shallowReactive(new Set()) + const isFirstCalculateGraph = ref(true) + const childToParentMap = shallowReactive(new Map()) + + const nodes = shallowRef>[]>([]) + const links = shallowRef[]>([]) + const nodesMap = shallowReactive(new Map>>()) + const linksMap = shallowReactive(new Map>()) + + const modulesMap = computed(() => { + const map = new Map() + for (const module of options.modules.value) { + map.set(module.id, module) + } + return map + }) + + const { control } = useMagicKeys() + const { scale, zoomIn, zoomOut } = useZoomElement(container, { + wheel: control, + minScale: ZOOM_MIN, + maxScale: ZOOM_MAX, + }) + + function focusOn(id: string, animated = true) { + const el = nodesRefMap.get(id) + el?.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: animated ? 'smooth' : 'instant', + }) + } + + const _calculateGraph = options.generateGraph({ + isFirstCalculateGraph, + hierarchy, + tree, + nodesRefMap, + container, + modulesMap, + nodes, + links, + scale, + nodesMap, + linksMap, + width, + height, + childToParentMap, + focusOn, + collapsedNodes, + spacing: options.spacing, + }) + + provide(ViteDevToolsModuleGraphStateSymbol, { + spacing: options.spacing, + calculateGraph: _calculateGraph, + isFirstCalculateGraph, + container, + width, + height, + scale, + nodes, + links, + nodesRefMap, + collapsedNodes, + childToParentMap, + ZOOM_MIN, + ZOOM_MAX, + zoomIn, + zoomOut, + }) +} + +export function useModuleGraph() { + const state = inject(ViteDevToolsModuleGraphStateSymbol)! + return state +} + +export function useToggleGraphNodeExpanded(options: { + modules: MaybeRef +}) { + const { nodesRefMap, container, calculateGraph, collapsedNodes } = inject(ViteDevToolsModuleGraphStateSymbol)! + const isGraphNodeToggling = ref(false) + + function restoreScrollPosition(id: string, beforePosition: { x: number, y: number }) { + // Ensure this runs after the nextTick inside calculateGraph completes (width and height are computed) + nextTick(() => { + nextTick(() => { + const newNode = nodesRefMap.get(id) + + if (newNode && beforePosition && container.value) { + const containerRect = container.value.getBoundingClientRect() + const newRect = newNode.getBoundingClientRect() + + const viewportDiffX = newRect.left - containerRect.left - beforePosition.x + const viewportDiffY = newRect.top - containerRect.top - beforePosition.y + + container.value.scrollLeft += viewportDiffX + container.value.scrollTop += viewportDiffY + } + }) + }) + } + + function toggleNode(id: string) { + if (isGraphNodeToggling.value) + return + isGraphNodeToggling.value = true + + const node = nodesRefMap.get(id) + let prevNodeOffset: null | { x: number, y: number } = null + + // Record position relative to the scroll container to avoid drift after reflow + if (node && container.value) { + const containerRect = container.value.getBoundingClientRect() + const rect = node.getBoundingClientRect() + prevNodeOffset = { + x: rect.left - containerRect.left, + y: rect.top - containerRect.top, + } + } + + if (collapsedNodes.has(id)) { + collapsedNodes.delete(id) + } + else { + collapsedNodes.add(id) + } + + calculateGraph() + + // Adjust scroll position after layout changes + if (prevNodeOffset) { + restoreScrollPosition(id, prevNodeOffset) + } + + isGraphNodeToggling.value = false + } + + function expandAll() { + if (isGraphNodeToggling.value) + return + + isGraphNodeToggling.value = true + + collapsedNodes.clear() + calculateGraph() + + setTimeout(() => { + isGraphNodeToggling.value = false + }, 300) + } + + function collapseAll() { + if (isGraphNodeToggling.value) + return + + isGraphNodeToggling.value = true + + unref(options.modules).forEach((module) => { + if (module.imports.length > 0) { + collapsedNodes.add(module.id) + } + }) + calculateGraph() + + setTimeout(() => { + isGraphNodeToggling.value = false + }, 300) + } + + return { + isGraphNodeToggling, + toggleNode, + expandAll, + collapseAll, + } +} + +export function useGraphZoom() { + const state = inject(ViteDevToolsModuleGraphStateSymbol)! + + const { scale, zoomIn, zoomOut, ZOOM_MIN, ZOOM_MAX } = state + + onKeyPressed(['-', '_'], (e) => { + if (e.ctrlKey) + zoomOut() + }) + + onKeyPressed(['=', '+'], (e) => { + if (e.ctrlKey) + zoomIn() + }) + + return { + ZOOM_MIN, + ZOOM_MAX, + scale, + zoomIn, + zoomOut, + } +} + +export function useGraphDraggingScroll() { + const { container } = inject(ViteDevToolsModuleGraphStateSymbol)! + const isGrabbing = ref(false) + const SCROLLBAR_THICKNESS = 20 + let x = 0 + let y = 0 + + function init() { + useEventListener(container, 'mousedown', (e) => { + // prevent dragging when clicking on scrollbar + const rect = container.value!.getBoundingClientRect() + const distRight = rect.right - e.clientX + const distBottom = rect.bottom - e.clientY + + if (distRight <= SCROLLBAR_THICKNESS || distBottom <= SCROLLBAR_THICKNESS) { + return + } + + isGrabbing.value = true + x = container.value!.scrollLeft + e.pageX + y = container.value!.scrollTop + e.pageY + }) + useEventListener(container, 'contextmenu', e => e.preventDefault()) + useEventListener('mouseleave', () => isGrabbing.value = false) + useEventListener('mouseup', () => isGrabbing.value = false) + useEventListener('mousemove', (e) => { + if (!isGrabbing.value) + return + e.preventDefault() + container.value!.scrollLeft = x - e.pageX + container.value!.scrollTop = y - e.pageY + }) + } + + return { + init, + isGrabbing, + } +} diff --git a/packages/devtools-vite/src/app/pages/session/[session]/chunks.vue b/packages/devtools-vite/src/app/pages/session/[session]/chunks.vue index 2aeb43a2..b3208aff 100644 --- a/packages/devtools-vite/src/app/pages/session/[session]/chunks.vue +++ b/packages/devtools-vite/src/app/pages/session/[session]/chunks.vue @@ -1,12 +1,28 @@ - - Chunks - - - + + + View as + + + {{ viewType.label }} + + + + + + + + + + + + diff --git a/packages/devtools-vite/src/app/state/settings.ts b/packages/devtools-vite/src/app/state/settings.ts index 4a2d2d64..ce2323af 100644 --- a/packages/devtools-vite/src/app/state/settings.ts +++ b/packages/devtools-vite/src/app/state/settings.ts @@ -21,6 +21,7 @@ export interface ClientSettings { pluginDetailsModuleTypes: string[] | null pluginDetailsDurationSortType: string pluginDetailSelectedHook: string + chunkViewType: 'list' | 'graph' pluginDetailsShowType: 'changed' | 'unchanged' | 'all' } @@ -45,6 +46,7 @@ export const settings = useLocalStorage( pluginDetailsModuleTypes: null, pluginDetailsDurationSortType: '', pluginDetailSelectedHook: '', + chunkViewType: 'list', pluginDetailsShowType: 'all', }, {