pkgviz 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -1
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/cache/webpack/client-production/10.pack +0 -0
  5. package/.next/cache/webpack/client-production/7.pack +0 -0
  6. package/.next/cache/webpack/client-production/8.pack +0 -0
  7. package/.next/cache/webpack/client-production/index.pack +0 -0
  8. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  9. package/.next/cache/webpack/server-production/11.pack +0 -0
  10. package/.next/cache/webpack/server-production/12.pack +0 -0
  11. package/.next/cache/webpack/server-production/13.pack +0 -0
  12. package/.next/cache/webpack/server-production/14.pack +0 -0
  13. package/.next/cache/webpack/server-production/15.pack +0 -0
  14. package/.next/cache/webpack/server-production/index.pack +0 -0
  15. package/.next/cache/webpack/server-production/index.pack.old +0 -0
  16. package/.next/server/app/_not-found.html +1 -1
  17. package/.next/server/app/_not-found.rsc +1 -1
  18. package/.next/server/app/favicon.ico/route.js +1 -1
  19. package/.next/server/app/index.html +1 -1
  20. package/.next/server/app/index.rsc +1 -1
  21. package/.next/server/app-paths-manifest.json +1 -1
  22. package/.next/server/chunks/610.js +1 -1
  23. package/.next/server/pages/404.html +1 -1
  24. package/.next/server/pages/500.html +1 -1
  25. package/.next/trace +2 -2
  26. package/package.json +3 -3
  27. package/src/app/actions/graph.actions.ts +25 -0
  28. package/src/app/favicon.ico +0 -0
  29. package/src/app/globals.css +77 -0
  30. package/src/app/layout.tsx +30 -0
  31. package/src/app/page.tsx +5 -0
  32. package/src/app/utils/buildGraph.ts +119 -0
  33. package/src/app/utils/getParsedFileStructure.ts +225 -0
  34. package/src/app/utils/markCyclicPackages.ts +275 -0
  35. package/src/app/utils/parser/cpp/extractCppPackageFromImport.ts +18 -0
  36. package/src/app/utils/parser/cpp/parseCppFile.ts +150 -0
  37. package/src/app/utils/parser/delphi/extractPackageFromImport.ts +21 -0
  38. package/src/app/utils/parser/delphi/parseFile.ts +179 -0
  39. package/src/app/utils/parser/java/extractJavaPackageFromImport.ts +39 -0
  40. package/src/app/utils/parser/java/findEntryPoint.ts +24 -0
  41. package/src/app/utils/parser/java/getIntrinsicPackagesRecursive.ts +33 -0
  42. package/src/app/utils/parser/java/parseJavaFile.ts +114 -0
  43. package/src/app/utils/parser/kotlin/extractPackageFromImport.ts +19 -0
  44. package/src/app/utils/parser/kotlin/parseFile.ts +147 -0
  45. package/src/app/utils/parser/python/extractPythonPackageFromImport.ts +18 -0
  46. package/src/app/utils/parser/python/parseFile.ts +171 -0
  47. package/src/app/utils/parser/typescript/extractTypeScriptPackageFromImport.ts +18 -0
  48. package/src/app/utils/parser/typescript/parseFile.ts +130 -0
  49. package/src/components/Breadcrumb.tsx +34 -0
  50. package/src/components/Cytoscape.tsx +23 -0
  51. package/src/components/Header.tsx +28 -0
  52. package/src/components/Loader.tsx +10 -0
  53. package/src/components/Setting.tsx +17 -0
  54. package/src/components/Settings.tsx +189 -0
  55. package/src/components/Switch.tsx +31 -0
  56. package/src/components/ThemeToggle.tsx +25 -0
  57. package/src/components/ZoomInput.tsx +94 -0
  58. package/src/components/useCytoscape.ts +343 -0
  59. package/src/contexts/SettingsContext.tsx +88 -0
  60. package/src/i18n/en.ts +27 -0
  61. package/src/i18n/i18n.ts +12 -0
  62. package/src/layouts/breadthfirst/layout.ts +30 -0
  63. package/src/layouts/breadthfirst/style.ts +8 -0
  64. package/src/layouts/circle/layout.ts +11 -0
  65. package/src/layouts/circle/style.ts +18 -0
  66. package/src/layouts/concentric/layout.ts +10 -0
  67. package/src/layouts/concentric/style.ts +16 -0
  68. package/src/layouts/constants.ts +17 -0
  69. package/src/layouts/elk/layout.ts +55 -0
  70. package/src/layouts/elk/style.ts +14 -0
  71. package/src/layouts/getLayoutStyle.ts +19 -0
  72. package/src/layouts/getWeightBuckets.ts +58 -0
  73. package/src/layouts/grid/layout.ts +11 -0
  74. package/src/layouts/grid/style.ts +20 -0
  75. package/src/layouts/index.ts +14 -0
  76. package/src/layouts/style.ts +191 -0
  77. package/src/screens/home/Home.tsx +48 -0
  78. package/src/shared/constants/index.ts +7 -0
  79. package/src/shared/types/index.ts +68 -0
  80. package/src/shared/utils/detectLanguage.ts +255 -0
  81. package/src/shared/utils/getJsonAsync.ts +13 -0
  82. package/src/shared/utils/getProjectName.ts +3 -0
  83. package/src/shared/utils/parseEnv.ts +91 -0
  84. package/src/shared/utils/parseProjectPath.ts +8 -0
  85. package/src/store/useLocalStorage.ts +29 -0
  86. package/src/utils/filter/filterByPackagePrefix.ts +23 -0
  87. package/src/utils/filter/filterEmptyPackages.ts +36 -0
  88. package/src/utils/filter/filterSubPackagesFromDepth.ts +170 -0
  89. package/src/utils/filter/filterVendorPackages.ts +17 -0
  90. package/src/utils/filter/toggleCompoundNodes.ts +40 -0
  91. package/src/utils/hasChildren.ts +7 -0
  92. package/tsconfig.json +29 -0
  93. /package/.next/static/{VVBfFRai9p1x3oVXujjYO → F9v-zmBCEef1qcQb8JFxR}/_buildManifest.js +0 -0
  94. /package/.next/static/{VVBfFRai9p1x3oVXujjYO → F9v-zmBCEef1qcQb8JFxR}/_ssgManifest.js +0 -0
@@ -0,0 +1,171 @@
1
+ 'use server';
2
+ import type { ParsedFile, MethodCall, MethodDefinition, ImportDefinition } from '@/shared/types';
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { extractPythonPackageFromImport } from '@/app/utils/parser/python/extractPythonPackageFromImport';
7
+
8
+ /**
9
+ * Extracts module path from Python __init__.py structure.
10
+ */
11
+ function extractModulePath(filePath: string, projectRoot: string): string {
12
+ const relativePath = path.relative(projectRoot, filePath);
13
+ const parts = relativePath.split(path.sep);
14
+
15
+ // Remove the filename
16
+ parts.pop();
17
+
18
+ // Join with dots for Python module notation
19
+ return parts.join('.');
20
+ }
21
+
22
+ /**
23
+ * Extracts import statements from Python code.
24
+ */
25
+ function extractImports(content: string): ImportDefinition[] {
26
+ const imports: ImportDefinition[] = [];
27
+
28
+ // Match: import module
29
+ // Match: import module as alias
30
+ // Match: from module import something
31
+ const importRegex =
32
+ /(?:^|\n)\s*(?:from\s+([\w.]+)\s+)?import\s+([\w\s,*]+?)(?:\s+as\s+\w+)?(?:\s|$|#)/gm;
33
+
34
+ let match;
35
+ while ((match = importRegex.exec(content)) !== null) {
36
+ const fromModule = match[1];
37
+ const importedItems = match[2];
38
+
39
+ if (fromModule) {
40
+ // from X import Y
41
+ const pkg = extractPythonPackageFromImport(fromModule);
42
+ const isIntrinsic = fromModule.startsWith('.');
43
+
44
+ imports.push({
45
+ name: fromModule,
46
+ pkg,
47
+ isIntrinsic,
48
+ });
49
+ } else {
50
+ // import X, Y, Z
51
+ const mods = importedItems.split(',').map(m => m.trim());
52
+ for (const mod of mods) {
53
+ const pkg = extractPythonPackageFromImport(mod);
54
+ imports.push({
55
+ name: mod,
56
+ pkg,
57
+ isIntrinsic: false,
58
+ });
59
+ }
60
+ }
61
+ }
62
+
63
+ return imports;
64
+ }
65
+
66
+ /**
67
+ * Extracts the class name from Python content.
68
+ */
69
+ function extractClassName(content: string, fileName: string): string {
70
+ // Try to find class declaration
71
+ const classMatch = content.match(/^class\s+([A-Za-z0-9_]+)/m);
72
+ if (classMatch) {
73
+ return classMatch[1];
74
+ }
75
+
76
+ // Fallback to filename without extension
77
+ return path.basename(fileName, path.extname(fileName));
78
+ }
79
+
80
+ /**
81
+ * Extracts method/function definitions from Python content.
82
+ */
83
+ function extractMethodDefinitions(content: string): MethodDefinition[] {
84
+ const methods: MethodDefinition[] = [];
85
+
86
+ // Match function definitions: def method_name(params): or async def method_name(params):
87
+ const methodRegex =
88
+ /(?:^|\n)\s*(async\s+)?def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*$$([^)]*)$$\s*(?:->([^:]+))?:/gm;
89
+
90
+ let match;
91
+ while ((match = methodRegex.exec(content)) !== null) {
92
+ const name = match[2];
93
+ const paramsStr = match[3];
94
+ const returnType = match[4]?.trim() || 'None';
95
+
96
+ // Parse parameters
97
+ const params = paramsStr
98
+ .split(',')
99
+ .map(p => p.trim())
100
+ .filter(p => p && p !== 'self' && p !== 'cls');
101
+
102
+ // Determine visibility (Python convention: _ prefix = protected, __ prefix = private)
103
+ let visibility: 'public' | 'protected' | 'private' | 'default' = 'public';
104
+ if (name.startsWith('__') && !name.endsWith('__')) {
105
+ visibility = 'private';
106
+ } else if (name.startsWith('_')) {
107
+ visibility = 'protected';
108
+ }
109
+
110
+ methods.push({
111
+ name,
112
+ returnType,
113
+ parameters: params,
114
+ visibility,
115
+ });
116
+ }
117
+
118
+ return methods;
119
+ }
120
+
121
+ /**
122
+ * Extract method calls from Python content.
123
+ */
124
+ function extractMethodCalls(content: string): MethodCall[] {
125
+ const calls: MethodCall[] = [];
126
+
127
+ // Match: object.method( or self.method(
128
+ const callRegex = /([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
129
+
130
+ let match;
131
+ while ((match = callRegex.exec(content)) !== null) {
132
+ const callee = match[1];
133
+ const method = match[2];
134
+
135
+ // Skip common built-in methods to reduce noise
136
+ if (
137
+ ['append', 'extend', 'pop', 'remove', 'join', 'split', 'strip'].includes(method) &&
138
+ ['str', 'list', 'dict', 'set'].includes(callee)
139
+ ) {
140
+ continue;
141
+ }
142
+
143
+ calls.push({ callee, method });
144
+ }
145
+
146
+ return calls;
147
+ }
148
+
149
+ /**
150
+ * Parses a Python file and returns metadata useful for diagram generation.
151
+ */
152
+ export async function parsePythonFile(fullPath: string, projectRoot: string): Promise<ParsedFile> {
153
+ const content = fs.readFileSync(fullPath, 'utf-8');
154
+ const fileName = path.basename(fullPath);
155
+
156
+ const className = extractClassName(content, fileName);
157
+ const modulePath = extractModulePath(fullPath, projectRoot);
158
+ const imports = extractImports(content);
159
+ const methods = extractMethodDefinitions(content);
160
+ const calls = extractMethodCalls(content);
161
+ const relativePath = path.relative(projectRoot, fullPath);
162
+
163
+ return {
164
+ className,
165
+ package: modulePath,
166
+ imports,
167
+ methods,
168
+ calls,
169
+ path: relativePath,
170
+ };
171
+ }
@@ -0,0 +1,18 @@
1
+ import { toPosix } from '@/shared/utils/toPosix';
2
+
3
+ /**
4
+ * Extracts the package path from an import string.
5
+ * @example 'lodash/debounce' => 'lodash'
6
+ * @example '@nestjs/common' => '@nestjs/common'
7
+ * @example './components/Button' => './components'
8
+ */
9
+ export function extractTypeScriptPackageFromImport(imp: string): string {
10
+ const segments = toPosix(imp).split('/');
11
+ const pathSegments = segments.slice(0, -1).join('/'); // Remove file name
12
+ const replacedAlias = pathSegments.startsWith('@/')
13
+ ? pathSegments.replace(/^@/, 'src')
14
+ : pathSegments;
15
+
16
+ // Remove file/class-like suffixes (e.g., 'Button.tsx')
17
+ return replacedAlias.split('/').join('.');
18
+ }
@@ -0,0 +1,130 @@
1
+ import type { ParsedFile, MethodCall, MethodDefinition, ImportDefinition } from '@/shared/types';
2
+
3
+ import fs from 'node:fs/promises';
4
+ import { basename, relative, resolve } from 'node:path';
5
+ import ts from 'typescript';
6
+ import { extractTypeScriptPackageFromImport } from '@/app/utils/parser/typescript/extractTypeScriptPackageFromImport';
7
+ import { toPosix } from '@/shared/utils/toPosix';
8
+ import { parseProjectPath } from '@/shared/utils/parseProjectPath';
9
+
10
+ /**
11
+ * Extracts import statements from TypeScript code.
12
+ */
13
+ function extractImports(content: string, filename: string): ImportDefinition[] {
14
+ const sourceFile = ts.createSourceFile('temp.ts', content, ts.ScriptTarget.Latest, true);
15
+ const imports: ImportDefinition[] = [];
16
+
17
+ sourceFile.forEachChild(node => {
18
+ if (!ts.isImportDeclaration(node)) return;
19
+
20
+ const fullPath = toPosix(filename).split('/').slice(0, -1).join('/');
21
+ const moduleSpecifier = (node.moduleSpecifier as ts.StringLiteral).text;
22
+
23
+ function resolveImportPath(curDir: string, specifier: string) {
24
+ const root = parseProjectPath();
25
+
26
+ if (specifier.startsWith('./') || specifier.startsWith('../'))
27
+ return {
28
+ isIntrinsic: true,
29
+ resolvedPath: resolve(curDir, specifier).slice(root.length + 1),
30
+ };
31
+
32
+ if (specifier.startsWith('@/'))
33
+ return {
34
+ isIntrinsic: true,
35
+ resolvedPath: specifier.replace(/^@/, 'src'),
36
+ };
37
+
38
+ return { isIntrinsic: false, resolvedPath: specifier };
39
+ }
40
+
41
+ const { resolvedPath, isIntrinsic } = resolveImportPath(fullPath, moduleSpecifier);
42
+
43
+ const pkg = extractTypeScriptPackageFromImport(resolvedPath);
44
+ imports.push({ name: pkg, pkg, isIntrinsic });
45
+ });
46
+
47
+ return imports;
48
+ }
49
+
50
+ /**
51
+ * Extracts the class name from the content and filename fallback.
52
+ */
53
+ function extractClassName(content: string, fileName: string): string {
54
+ const classMatch = content.match(/class\s+(\w+)/);
55
+ if (classMatch) {
56
+ return classMatch[1];
57
+ }
58
+ /*** @todo Don't return this fallback, search for functional wrapper instead */
59
+ return basename(fileName, '.ts');
60
+ }
61
+
62
+ /**
63
+ * Extracts method definitions from TypeScript content.
64
+ */
65
+ function extractMethodDefinitions(content: string): MethodDefinition[] {
66
+ const sourceFile = ts.createSourceFile('temp.ts', content, ts.ScriptTarget.Latest, true);
67
+ const methods: MethodDefinition[] = [];
68
+
69
+ function visit(node: ts.Node) {
70
+ if (ts.isMethodDeclaration(node) && node.name) {
71
+ const name = node.name.getText();
72
+ const returnType = node.type?.getText() ?? 'void';
73
+ const parameters = node.parameters.map(p => p.getText());
74
+ const modifiers = ts.getCombinedModifierFlags(node);
75
+ let visibility: MethodDefinition['visibility'] = 'default';
76
+ if (modifiers & ts.ModifierFlags.Private) visibility = 'private';
77
+ else if (modifiers & ts.ModifierFlags.Protected) visibility = 'protected';
78
+ else if (modifiers & ts.ModifierFlags.Public) visibility = 'public';
79
+
80
+ methods.push({ name, returnType, parameters, visibility });
81
+ }
82
+
83
+ ts.forEachChild(node, visit);
84
+ }
85
+
86
+ visit(sourceFile);
87
+
88
+ return methods;
89
+ }
90
+
91
+ /**
92
+ * Extracts method calls from TypeScript content.
93
+ */
94
+ function extractMethodCalls(content: string): MethodCall[] {
95
+ const sourceFile = ts.createSourceFile('temp.ts', content, ts.ScriptTarget.Latest, true);
96
+ const calls: MethodCall[] = [];
97
+
98
+ function visit(node: ts.Node) {
99
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
100
+ const callee = node.expression.expression.getText();
101
+ const method = node.expression.name.getText();
102
+ calls.push({ callee, method });
103
+ }
104
+ ts.forEachChild(node, visit);
105
+ }
106
+
107
+ visit(sourceFile);
108
+
109
+ return calls;
110
+ }
111
+
112
+ /**
113
+ * Parses a TypeScript file and returns metadata useful for diagram generation.
114
+ */
115
+ export async function parseFile(fullPath: string, projectRoot: string): Promise<ParsedFile> {
116
+ const posixFullPath = toPosix(fullPath);
117
+ const content = await fs.readFile(posixFullPath, 'utf-8');
118
+ const relativePath = toPosix(relative(projectRoot, posixFullPath));
119
+ const segments = relativePath.split('/');
120
+ const segmentedPath = segments.slice(0, -1);
121
+
122
+ return {
123
+ className: extractClassName(content, fullPath),
124
+ imports: extractImports(content, fullPath),
125
+ methods: extractMethodDefinitions(content),
126
+ calls: extractMethodCalls(content),
127
+ package: segmentedPath.join('.'),
128
+ path: relativePath,
129
+ };
130
+ }
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { ChevronRight, Home } from 'lucide-react';
4
+ import Link from 'next/link';
5
+
6
+ export default function Breadcrumb({ path, onNavigate }: BreadcrumbProps) {
7
+ const parts = path.split('/') || [''];
8
+
9
+ return (
10
+ <nav className="flex items-center space-x-1 text-sm">
11
+ <Link href="#" onClick={() => onNavigate('')} className="flex items-center text-foreground">
12
+ <Home className="h-4 w-4 mr-1" />
13
+ </Link>
14
+
15
+ {parts.map((part, index) => {
16
+ const currentPath = parts.length ? parts.slice(0, index + 1).join('.') : '';
17
+
18
+ return (
19
+ <div key={index} className="flex items-center">
20
+ <ChevronRight className="h-4 w-4 text-foreground opacity-20" />
21
+ <Link href="#" onClick={() => onNavigate(currentPath)} className="ml-1 text-foreground">
22
+ {part}
23
+ </Link>
24
+ </div>
25
+ );
26
+ })}
27
+ </nav>
28
+ );
29
+ }
30
+
31
+ interface BreadcrumbProps {
32
+ readonly path: string;
33
+ readonly onNavigate: (path: string) => void;
34
+ }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import type { ElementsDefinition } from 'cytoscape';
3
+
4
+ import React from 'react';
5
+ import { useCytoscape } from '@/components/useCytoscape';
6
+ import ZoomInput from '@/components/ZoomInput';
7
+
8
+ export function Cytoscape({ currentPackage, packageGraph, setCurrentPackage }: CytoscapeProps) {
9
+ const { cyRef, cyInstance } = useCytoscape(packageGraph, currentPackage, setCurrentPackage);
10
+
11
+ return (
12
+ <div className="flex flex-col w-full px-8 flex-1 gap-2">
13
+ <div ref={cyRef} className="h-[calc(100%-65px)]" />
14
+ <ZoomInput cyInstance={cyInstance} />
15
+ </div>
16
+ );
17
+ }
18
+
19
+ interface CytoscapeProps {
20
+ readonly currentPackage: string;
21
+ readonly packageGraph: ElementsDefinition | null;
22
+ readonly setCurrentPackage: (path: string) => void;
23
+ }
@@ -0,0 +1,28 @@
1
+ import React, { type PropsWithChildren } from 'react';
2
+
3
+ import ThemeToggle from '@/components/ThemeToggle';
4
+ import { t } from '@/i18n/i18n';
5
+ import { getProjectName } from '@/shared/utils/getProjectName';
6
+
7
+ export default function Header({ children, title }: HeaderProps) {
8
+ const projectName = getProjectName();
9
+
10
+ return (
11
+ <header className="flex flex-row items-center justify-between text-blue-50 bg-blue-500 dark:bg-blue-950 border-b-1 border-b-neutral-200 mb-1 dark:border-b-blue-800 p-2 pb-1 gap-4">
12
+ {/* Left */}
13
+ <div className="flex flex-row items-center justify-start">
14
+ <h1 className="ml-4">{projectName}</h1>
15
+ <span className="ml-2 opacity-20">&#47;</span>
16
+ {title && <strong className="mx-2">{t(title)}</strong>}
17
+ {children}
18
+ </div>
19
+
20
+ {/* Right */}
21
+ <ThemeToggle />
22
+ </header>
23
+ );
24
+ }
25
+
26
+ interface HeaderProps extends PropsWithChildren {
27
+ readonly title?: string;
28
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { LucideLoader } from 'lucide-react';
3
+
4
+ export default function Loader() {
5
+ return (
6
+ <div data-testid="loader" className="h-full flex items-center justify-center">
7
+ <LucideLoader />
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+
3
+ const Setting: React.FC<SettingProps> = ({ children }) => {
4
+ return (
5
+ <div className="ml-[.8rem] px-[.8rem] py-2 bg-white dark:bg-neutral-900 border-b border-b-neutral-200 dark:border-b-neutral-800">
6
+ {children}
7
+ </div>
8
+ );
9
+ };
10
+
11
+ Setting.displayName = 'Setting';
12
+
13
+ export default Setting;
14
+
15
+ interface SettingProps {
16
+ readonly children: React.ReactNode;
17
+ }
@@ -0,0 +1,189 @@
1
+ 'use client';
2
+ import dynamic from 'next/dynamic';
3
+ import { ChevronDownIcon, DownloadIcon } from 'lucide-react';
4
+ import { Select, Slider } from 'radix-ui';
5
+ import type React from 'react';
6
+ import Setting from '@/components/Setting';
7
+ import Switch from '@/components/Switch';
8
+ import { useSettings } from '@/contexts/SettingsContext';
9
+ import { t } from '@/i18n/i18n';
10
+ import { downloadAuditJsonAction, downloadAuditXmlAction } from '@/app/actions/audit.actions';
11
+
12
+ const Settings: React.FC = () => {
13
+ const {
14
+ cytoscapeLayout,
15
+ cytoscapeLayoutSpacing,
16
+ maxSubPackageDepth,
17
+ showCompoundNodes,
18
+ showVendorPackages,
19
+ subPackageDepth,
20
+ setCytoscapeLayout,
21
+ setCytoscapeLayoutSpacing,
22
+ setSubPackageDepth,
23
+ toggleShowCompoundNodes,
24
+ toggleShowVendorPackages,
25
+ } = useSettings();
26
+
27
+ const handleDownloadJson = async () => {
28
+ const { data, filename } = await downloadAuditJsonAction();
29
+ const blob = new Blob([data], { type: 'application/json' });
30
+ const url = URL.createObjectURL(blob);
31
+ const a = document.createElement('a');
32
+ a.href = url;
33
+ a.download = filename;
34
+ a.click();
35
+ URL.revokeObjectURL(url);
36
+ };
37
+
38
+ const handleDownloadXml = async () => {
39
+ const { data, filename } = await downloadAuditXmlAction();
40
+ const blob = new Blob([data], { type: 'application/xml' });
41
+ const url = URL.createObjectURL(blob);
42
+ const a = document.createElement('a');
43
+ a.href = url;
44
+ a.download = filename;
45
+ a.click();
46
+ URL.revokeObjectURL(url);
47
+ };
48
+
49
+ return (
50
+ <div className="md:pt-14 border-r bg-neutral-100 border-r-neutral-200 dark:border-r-neutral-800 dark:bg-neutral-950">
51
+ {/* Audit Download */}
52
+ <h3>{t('settings.download')}</h3>
53
+ <div>
54
+ {/* JSON Audit Download */}
55
+ <Setting>
56
+ <button
57
+ onClick={handleDownloadJson}
58
+ className="flex flex-row items-center content-start text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
59
+ >
60
+ <DownloadIcon size={8} className="mr-1" />
61
+ <span>JSON</span>
62
+ </button>
63
+ </Setting>
64
+ {/* XML Audit Download */}
65
+ <Setting>
66
+ <button
67
+ onClick={handleDownloadXml}
68
+ className="flex flex-row items-center content-start text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
69
+ >
70
+ <DownloadIcon size={8} className="mr-1" />
71
+ <span>XML</span>
72
+ </button>
73
+ </Setting>
74
+ </div>
75
+
76
+ <h3>{t('settings.filter')}</h3>
77
+ {/* Whether to show vendor packages */}
78
+ <Setting>
79
+ <Switch
80
+ id="switch-show-vendor-packages"
81
+ label={t('settings.showVendorPackages')}
82
+ onToggle={() => {
83
+ toggleShowVendorPackages();
84
+ }}
85
+ value={showVendorPackages}
86
+ />
87
+ </Setting>
88
+
89
+ {/* Whether to show compound nodes */}
90
+ <Setting>
91
+ <Switch
92
+ id="switch-show-compound-nodes"
93
+ label={t('settings.showCompoundNodes')}
94
+ onToggle={() => {
95
+ toggleShowCompoundNodes();
96
+ }}
97
+ value={showCompoundNodes}
98
+ />
99
+ </Setting>
100
+
101
+ {/* How many sub package levels to show */}
102
+ <>
103
+ <h3>{`${t('settings.subPackageDepth')}: ${subPackageDepth}`}</h3>
104
+ <Setting>
105
+ <Slider.Root
106
+ id="subPackageDepth"
107
+ min={1}
108
+ max={maxSubPackageDepth}
109
+ step={1}
110
+ value={[subPackageDepth]}
111
+ onValueChange={([v]) => setSubPackageDepth(Number(v.toFixed(1)))}
112
+ aria-label="Subpackage depth"
113
+ className="relative flex h-5 w-56 touch-none select-none items-center"
114
+ >
115
+ <Slider.Track className="relative h-1.5 grow rounded-full bg-neutral-200 dark:bg-neutral-800">
116
+ <Slider.Range className="absolute h-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
117
+ </Slider.Track>
118
+ <Slider.Thumb className="block h-4 w-4 rounded-full border border-neutral-300 bg-white shadow focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-100" />
119
+ </Slider.Root>
120
+ </Setting>
121
+ </>
122
+
123
+ <h3>{t('settings.layout')}</h3>
124
+ <Setting>
125
+ <Select.Root value={cytoscapeLayout} onValueChange={setCytoscapeLayout}>
126
+ <Select.Trigger
127
+ aria-label={t('cytoscapeLayout')}
128
+ className="inline-flex items-center justify-between w-44 h-9 rounded-md px-3 border"
129
+ >
130
+ <Select.Value className="text-foreground" />
131
+ <Select.Icon>
132
+ <ChevronDownIcon />
133
+ </Select.Icon>
134
+ </Select.Trigger>
135
+ <Select.Portal>
136
+ <Select.Content
137
+ position="popper"
138
+ side="bottom"
139
+ align="start"
140
+ sideOffset={6}
141
+ className="z-50 min-w-(--radix-select-trigger-width) rounded-md border bg-white dark:bg-neutral-900 shadow-md"
142
+ >
143
+ <Select.Viewport className="p-1">
144
+ <Select.Group>
145
+ {['breadthfirst', 'circle', 'concentric', 'elk', 'grid'].map(layout => (
146
+ <Select.Item
147
+ key={layout}
148
+ value={layout}
149
+ textValue={t(layout)}
150
+ className="px-2 py-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800"
151
+ >
152
+ <Select.ItemText>{t(layout)}</Select.ItemText>
153
+ </Select.Item>
154
+ ))}
155
+ </Select.Group>
156
+ </Select.Viewport>
157
+ </Select.Content>
158
+ </Select.Portal>
159
+ </Select.Root>
160
+ </Setting>
161
+
162
+ <h3>
163
+ {t('settings.layoutSpacing')}: {cytoscapeLayoutSpacing}
164
+ </h3>
165
+ {/* Which Cytoscape layout spacing */}
166
+ <Setting>
167
+ <Slider.Root
168
+ id="spacing"
169
+ min={0.1}
170
+ max={1}
171
+ step={0.1}
172
+ value={[cytoscapeLayoutSpacing]}
173
+ onValueChange={([v]) => setCytoscapeLayoutSpacing(Number(v.toFixed(1)))}
174
+ aria-label="Layout spacing"
175
+ className="relative flex h-5 w-56 touch-none select-none items-center"
176
+ >
177
+ <Slider.Track className="relative h-1.5 grow rounded-full bg-neutral-200 dark:bg-neutral-800">
178
+ <Slider.Range className="absolute h-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
179
+ </Slider.Track>
180
+ <Slider.Thumb className="block h-4 w-4 rounded-full border border-neutral-300 bg-white shadow focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-100" />
181
+ </Slider.Root>
182
+ </Setting>
183
+ </div>
184
+ );
185
+ };
186
+
187
+ export default dynamic(() => Promise.resolve(Settings), {
188
+ ssr: false,
189
+ });
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { Switch } from 'radix-ui';
4
+
5
+ export default function RadixSwitch({ id, label, value, onToggle }: ISwitch) {
6
+ return (
7
+ <form>
8
+ <div className="flex items-center justify-between h-[20px]">
9
+ <label className="pr-[15px] leading-none text-foreground whitespace-nowrap" htmlFor={id}>
10
+ {label}
11
+ </label>
12
+ <Switch.Root
13
+ className="relative h-[18px] w-[42px] cursor-default rounded-full bg-neutral-200 dark:bg-neutral-800 outline-none data-[state=checked]:bg-neutral-200 dark:data-[state=checked]:bg-gray-700"
14
+ defaultChecked={value}
15
+ id={id}
16
+ checked={value}
17
+ onCheckedChange={onToggle}
18
+ >
19
+ <Switch.Thumb className="block size-[16px] translate-x-0.5 rounded-full bg-neutral-500 dark:bg-neutral-500 transition-transform duration-100 will-change-transform data-[state=checked]:bg-blue-500 dark:data-[state=checked]:bg-white data-[state=checked]:translate-x-[24px]" />
20
+ </Switch.Root>
21
+ </div>
22
+ </form>
23
+ );
24
+ }
25
+
26
+ interface ISwitch {
27
+ readonly id: string;
28
+ readonly label: string;
29
+ readonly value: boolean;
30
+ readonly onToggle: () => void;
31
+ }
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import { useTheme } from 'next-themes';
4
+
5
+ export default function ThemeToggle() {
6
+ const { theme, setTheme, resolvedTheme } = useTheme();
7
+ const [mounted, setMounted] = useState(false);
8
+
9
+ useEffect(() => setMounted(true), []);
10
+ if (!mounted) return null;
11
+
12
+ const active = theme === 'system' ? resolvedTheme : theme;
13
+ const isDark = active === 'dark';
14
+
15
+ return (
16
+ <button
17
+ onClick={() => setTheme(isDark ? 'light' : 'dark')}
18
+ aria-label="Toggle dark mode"
19
+ className="rounded-lg px-3 py-2 border border-blue-400 dark:border-blue-600 text-xs cursor-pointer"
20
+ title={`Switch to ${isDark ? 'light' : 'dark'} mode`}
21
+ >
22
+ {isDark ? '🌙 Dark' : '☀️ Light'}
23
+ </button>
24
+ );
25
+ }