Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import { getBackend } from '../../backends'
import { fetchData } from '../../state/data'
import { settings } from '../../state/settings'

const backend = getBackend()

const showConfigDialog = ref(false)
</script>

<template>
Expand Down Expand Up @@ -60,6 +63,12 @@ const backend = getBackend()
<div i-ph-arrows-clockwise-duotone />
Refetch Data
</button>
<button btn-action @click="showConfigDialog = true">
<div i-ph-file-code-duotone />
Save as config file
</button>
</div>

<PanelSettingsConfigDialog v-model="showConfigDialog" />
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
import { generateConfigFile } from '../../utils/config'

const open = defineModel<boolean>({ required: true })

const CONFIG_FILENAME = 'node-modules-inspector.config.ts'

const content = computed(() => (open.value ? generateConfigFile() : ''))

const { copy, copied } = useClipboard({ source: content, copiedDuring: 1500 })

function close() {
open.value = false
}

function download() {
const blob = new Blob([content.value], { type: 'text/typescript;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = CONFIG_FILENAME
link.click()
URL.revokeObjectURL(url)
}
</script>

<template>
<Teleport to="body">
<Transition name="config-dialog-backdrop">
<div
v-if="open"
fixed inset-0 z-drawer-backdrop bg-black:30
@click="close"
/>
</Transition>
<Transition name="config-dialog-panel">
<div
v-if="open"
fixed inset-0 z-drawer-content
flex="~ items-center justify-center" p4
pointer-events-none
>
<div
w-full max-w-3xl max-h-90vh
bg-base border="~ base" rounded-lg shadow-2xl
flex="~ col" of-hidden pointer-events-auto
@click.stop
>
<div flex="~ items-center gap-2" border="b base" px4 py3>
<div i-ph-file-code-duotone text-lg op75 />
<div flex="~ col" flex-auto>
<div font-500>
Save as config file
</div>
</div>
<button
w-8 h-8 rounded-full op50 flex="~ items-center justify-center"
hover="op100 bg-active"
title="Close"
@click="close"
>
<div i-ph-x />
</button>
</div>

<div flex-auto of-y-auto p4>
<p text-sm op-fade mb3>
Save the following as
<code color-active bg-primary:10 rounded px1 py0.5>{{ CONFIG_FILENAME }}</code>
in your project root to reuse the current filters and settings as defaults.
</p>
<pre
text-xs p3 rounded-lg border="~ base" bg-code
of-x-auto whitespace-pre font-mono
>{{ content }}</pre>
</div>

<div flex="~ items-center justify-end gap-2" border="t base" px4 py3>
<button
btn-action text-sm
:class="copied ? 'text-primary' : ''"
@click="copy()"
>
<div :class="copied ? 'i-ph-check' : 'i-ph-copy-duotone'" />
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
btn-action text-sm
@click="download()"
>
<div i-ph-download-simple-duotone />
Download
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

<style scoped>
.config-dialog-backdrop-enter-active,
.config-dialog-backdrop-leave-active {
transition: opacity 0.2s ease;
}
.config-dialog-backdrop-enter-from,
.config-dialog-backdrop-leave-to {
opacity: 0;
}

.config-dialog-panel-enter-active,
.config-dialog-panel-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.config-dialog-panel-enter-from,
.config-dialog-panel-leave-to {
opacity: 0;
transform: scale(0.98);
}
</style>
46 changes: 24 additions & 22 deletions packages/node-modules-inspector/src/app/state/settings.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import type { SettingsOptions } from '../../shared/types'
import { useLocalStorage } from '@vueuse/core'

export const SETTINGS_DEFAULT: SettingsOptions = {
graphRender: 'normal',
moduleTypeSimple: false,
moduleTypeRender: 'badge',
deepDependenciesTree: true,
dependenciesGroupBy: 'none',
packageDetailsTab: 'dependents',
colorizePackageSize: true,
showInstallSizeBadge: true,
showPublishTimeBadge: false,
showProvenanceBadge: 'present',
showFileComposition: false,
showDependencySourceBadge: 'dev',
showPublintMessages: false,
showMaintainerActions: false,
showThirdPartyServices: false,
treatFauxAsESM: false,
chartColoringMode: 'spectrum',
collapseSidepanel: false,
chartAnimation: true,
preferNpmx: true,
}

export const settings = useLocalStorage<SettingsOptions>(
'node-modules-inspector-settings',
{
graphRender: 'normal',
moduleTypeSimple: false,
moduleTypeRender: 'badge',
deepDependenciesTree: true,
dependenciesGroupBy: 'none',
packageDetailsTab: 'dependents',
colorizePackageSize: true,
showInstallSizeBadge: true,
showPublishTimeBadge: false,
showProvenanceBadge: 'present',
showFileComposition: false,
showDependencySourceBadge: 'dev',
showPublintMessages: false,
showMaintainerActions: false,
showThirdPartyServices: false,
treatFauxAsESM: false,
chartColoringMode: 'spectrum',
collapseSidepanel: false,
chartAnimation: true,
preferNpmx: true,
},
{ ...SETTINGS_DEFAULT },
{
deep: true,
mergeDefaults: true,
Expand Down
160 changes: 160 additions & 0 deletions packages/node-modules-inspector/src/app/utils/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { FilterOptions } from '../../shared/filters'
import type { NodeModulesInspectorConfig, SettingsOptions } from '../../shared/types'
import { objectMap } from '@antfu/utils'
import { toRaw } from 'vue'
import { FILTERS_SCHEMA } from '../../shared/filters'
import { rawPayload } from '../state/data'
import { filters } from '../state/filters'
import { settings, SETTINGS_DEFAULT } from '../state/settings'

/**
* The hard schema defaults, independent of any loaded config file. Diffing
* against these makes the generated config reproduce the current UI state
* exactly, regardless of an existing `node-modules-inspector.config.ts`.
*/
const FILTERS_DEFAULT = objectMap(FILTERS_SCHEMA, (k, v) => [k, v.default]) as FilterOptions

/**
* Filter keys that represent transient navigation state rather than
* persistent configuration, and therefore should not be serialized.
*/
const TRANSIENT_FILTER_KEYS = new Set<keyof FilterOptions>([
'search',
'focus',
'why',
'compareA',
'compareB',
])

function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b)
return true
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length)
return false
return a.every((v, i) => isDeepEqual(v, b[i]))
}
if (a && b && typeof a === 'object' && typeof b === 'object') {
const ak = Object.keys(a)
const bk = Object.keys(b)
if (ak.length !== bk.length)
return false
return ak.every(k => isDeepEqual((a as any)[k], (b as any)[k]))
}
return false
}

/**
* Collect the current filters that differ from their defaults, omitting
* transient navigation state.
*/
export function getNonDefaultFilters(): Partial<FilterOptions> {
const state = toRaw(filters.state) as FilterOptions
const result: Partial<FilterOptions> = {}
for (const key of Object.keys(state) as (keyof FilterOptions)[]) {
if (TRANSIENT_FILTER_KEYS.has(key))
continue
if (!isDeepEqual(state[key], FILTERS_DEFAULT[key]))
result[key] = structuredClone(toRaw(state[key])) as any
}
return result
}

/**
* Collect the current settings that differ from their defaults.
*/
export function getNonDefaultSettings(): Partial<SettingsOptions> {
const current = settings.value
const result: Partial<SettingsOptions> = {}
for (const key of Object.keys(SETTINGS_DEFAULT) as (keyof SettingsOptions)[]) {
if (!isDeepEqual(current[key], SETTINGS_DEFAULT[key]))
result[key] = structuredClone(toRaw(current[key])) as any
}
return result
}

/**
* Keep only the string entries of a config array (function-based matchers
* cannot be serialized and are already stripped when the config is sent to
* the frontend).
*/
function toStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value))
return undefined
return value.filter((v): v is string => typeof v === 'string')
}

const IDENTIFIER_RE = /^[a-z_$][\w$]*$/i

function serialize(value: unknown, indent: number): string {
const pad = ' '.repeat(indent)
const padInner = ' '.repeat(indent + 1)

if (Array.isArray(value)) {
if (value.length === 0)
return '[]'
const items = value.map(v => `${padInner}${serialize(v, indent + 1)},`).join('\n')
return `[\n${items}\n${pad}]`
}

if (value && typeof value === 'object') {
const keys = Object.keys(value)
if (keys.length === 0)
return '{}'
const items = keys.map((k) => {
const key = IDENTIFIER_RE.test(k) ? k : JSON.stringify(k)
return `${padInner}${key}: ${serialize((value as any)[k], indent + 1)},`
}).join('\n')
return `{\n${items}\n${pad}}`
}

if (typeof value === 'string')
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`

return String(value)
}

/**
* Generate the source of a `node-modules-inspector.config.ts` file that
* captures the current filters and settings as defaults, merged on top of the
* existing config so its other fields (name, publint, excludes, ...) are
* preserved.
*/
export function generateConfigFile(): string {
const existing = (rawPayload.value?.config ?? {}) as NodeModulesInspectorConfig
const config: NodeModulesInspectorConfig = {}

// Preserve non-filter/settings fields from the existing config, dropping any
// that equal the built-in defaults (unconfig injects `fetchNpmMeta`/`publint`
// into every loaded config, so they would otherwise always be emitted).
if (existing.name != null)
config.name = existing.name
if (existing.fetchNpmMeta != null && existing.fetchNpmMeta !== true)
config.fetchNpmMeta = existing.fetchNpmMeta
if (existing.publint != null && existing.publint !== false)
config.publint = existing.publint
const excludePackages = toStringArray(existing.excludePackages)
if (excludePackages?.length)
config.excludePackages = excludePackages
const excludeDependenciesOf = toStringArray(existing.excludeDependenciesOf)
if (excludeDependenciesOf?.length)
config.excludeDependenciesOf = excludeDependenciesOf

// The current filter/settings state already folds in the existing config's
// defaults, so diffing against the hard defaults yields the fully-merged
// result (existing config values + the user's changes, including resets).
const defaultFilters = getNonDefaultFilters()
if (Object.keys(defaultFilters).length)
config.defaultFilters = defaultFilters

const defaultSettings = getNonDefaultSettings()
if (Object.keys(defaultSettings).length)
config.defaultSettings = defaultSettings

return [
`import { defineConfig } from 'node-modules-inspector'`,
'',
`export default defineConfig(${serialize(config, 0)})`,
'',
].join('\n')
}
Loading