@lunanoir/dep-lens 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/args.js +113 -0
- package/dist/bridge.js +117 -0
- package/dist/cli.js +108 -0
- package/dist/i18n.js +231 -0
- package/dist/types.js +6 -0
- package/dist/ui/App.js +148 -0
- package/dist/ui/DetailPane.js +15 -0
- package/dist/ui/ErrorScreen.js +14 -0
- package/dist/ui/ExportMenu.js +14 -0
- package/dist/ui/FilterBar.js +8 -0
- package/dist/ui/Header.js +37 -0
- package/dist/ui/HelpOverlay.js +11 -0
- package/dist/ui/PackageTable.js +118 -0
- package/dist/ui/Root.js +39 -0
- package/dist/ui/ScanningScreen.js +26 -0
- package/dist/ui/SummaryBar.js +31 -0
- package/dist/ui/hooks.js +85 -0
- package/dist/ui/i18n-context.js +6 -0
- package/dist/ui/theme.js +59 -0
- package/dist/utils.js +169 -0
- package/package.json +52 -0
package/dist/ui/App.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
5
|
+
import { format } from '../i18n.js';
|
|
6
|
+
import { buildSectionDivider, filterPackages, SORT_COLUMNS, sortPackages } from '../utils.js';
|
|
7
|
+
import { DetailPane } from './DetailPane.js';
|
|
8
|
+
import { ExportMenu, EXPORT_OPTION_COUNT } from './ExportMenu.js';
|
|
9
|
+
import { FilterBar } from './FilterBar.js';
|
|
10
|
+
import { Header } from './Header.js';
|
|
11
|
+
import { HelpOverlay } from './HelpOverlay.js';
|
|
12
|
+
import { useMessages } from './i18n-context.js';
|
|
13
|
+
import { PackageTable } from './PackageTable.js';
|
|
14
|
+
import { SummaryBar } from './SummaryBar.js';
|
|
15
|
+
import { PALETTE } from './theme.js';
|
|
16
|
+
const QUICK_FILTERS = {
|
|
17
|
+
'1': 'Permissive',
|
|
18
|
+
'2': 'WeakCopyleft',
|
|
19
|
+
'3': 'StrongCopyleft',
|
|
20
|
+
'4': 'Unknown',
|
|
21
|
+
};
|
|
22
|
+
const STATUS_CLEAR_MS = 4000;
|
|
23
|
+
export function App({ report, version, getHtml }) {
|
|
24
|
+
const messages = useMessages();
|
|
25
|
+
const { exit } = useApp();
|
|
26
|
+
const { stdout } = useStdout();
|
|
27
|
+
const divider = buildSectionDivider(stdout.columns > 0 ? stdout.columns - 4 : 76);
|
|
28
|
+
const [mode, setMode] = useState('list');
|
|
29
|
+
const [query, setQuery] = useState('');
|
|
30
|
+
const [categoryFilter, setCategoryFilter] = useState(null);
|
|
31
|
+
const [sortIndex, setSortIndex] = useState(0);
|
|
32
|
+
const [descending, setDescending] = useState(false);
|
|
33
|
+
const [cursor, setCursor] = useState(0);
|
|
34
|
+
const [exportCursor, setExportCursor] = useState(0);
|
|
35
|
+
const [status, setStatus] = useState('');
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (status.length === 0)
|
|
38
|
+
return;
|
|
39
|
+
const id = setTimeout(() => setStatus(''), STATUS_CLEAR_MS);
|
|
40
|
+
return () => clearTimeout(id);
|
|
41
|
+
}, [status]);
|
|
42
|
+
const sortColumn = SORT_COLUMNS[sortIndex] ?? 'name';
|
|
43
|
+
const visible = useMemo(() => sortPackages(filterPackages(report.packages, query, categoryFilter), sortColumn, descending), [report.packages, query, categoryFilter, sortColumn, descending]);
|
|
44
|
+
const maxCursor = Math.max(0, visible.length - 1);
|
|
45
|
+
const clampedCursor = Math.min(cursor, maxCursor);
|
|
46
|
+
const selectedPackage = visible[clampedCursor];
|
|
47
|
+
function exportSelection(index) {
|
|
48
|
+
const filename = index === 0 ? 'dep-lens-report.json' : 'dep-lens-report.html';
|
|
49
|
+
setStatus(format(messages.exportMenu.writing, { file: filename }));
|
|
50
|
+
const promise = index === 0
|
|
51
|
+
? Promise.resolve(JSON.stringify(report, null, 2))
|
|
52
|
+
: getHtml();
|
|
53
|
+
promise
|
|
54
|
+
.then(async (content) => {
|
|
55
|
+
await writeFile(filename, content);
|
|
56
|
+
setStatus(format(messages.exportMenu.wrote, { file: filename }));
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
setStatus(format(messages.exportMenu.failed, { error: String(error) }));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
useInput((input, key) => {
|
|
63
|
+
if (mode === 'filter') {
|
|
64
|
+
if (key.return)
|
|
65
|
+
setMode('list');
|
|
66
|
+
else if (key.escape) {
|
|
67
|
+
setQuery('');
|
|
68
|
+
setMode('list');
|
|
69
|
+
}
|
|
70
|
+
else if (key.backspace || key.delete) {
|
|
71
|
+
setQuery(c => c.slice(0, -1));
|
|
72
|
+
setCursor(0);
|
|
73
|
+
}
|
|
74
|
+
else if (input.length > 0 && !key.ctrl && !key.meta) {
|
|
75
|
+
setQuery(c => c + input);
|
|
76
|
+
setCursor(0);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (mode === 'export') {
|
|
81
|
+
if (key.escape)
|
|
82
|
+
setMode('list');
|
|
83
|
+
else if (key.upArrow)
|
|
84
|
+
setExportCursor(c => Math.max(0, c - 1));
|
|
85
|
+
else if (key.downArrow)
|
|
86
|
+
setExportCursor(c => Math.min(EXPORT_OPTION_COUNT - 1, c + 1));
|
|
87
|
+
else if (key.return) {
|
|
88
|
+
exportSelection(exportCursor);
|
|
89
|
+
setMode('list');
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (mode === 'detail' || mode === 'help') {
|
|
94
|
+
if (key.escape || key.return || input === 'q' || (mode === 'help' && input === 'h')) {
|
|
95
|
+
setMode('list');
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const quickFilter = QUICK_FILTERS[input];
|
|
100
|
+
if (quickFilter !== undefined) {
|
|
101
|
+
setCategoryFilter(c => c === quickFilter ? null : quickFilter);
|
|
102
|
+
setCursor(0);
|
|
103
|
+
}
|
|
104
|
+
else if (input === '0') {
|
|
105
|
+
setQuery('');
|
|
106
|
+
setCategoryFilter(null);
|
|
107
|
+
setCursor(0);
|
|
108
|
+
}
|
|
109
|
+
else if (input === 'q')
|
|
110
|
+
exit();
|
|
111
|
+
else if (input === 'f' || input === '/') {
|
|
112
|
+
setStatus('');
|
|
113
|
+
setMode('filter');
|
|
114
|
+
}
|
|
115
|
+
else if (input === 's')
|
|
116
|
+
setSortIndex(c => (c + 1) % SORT_COLUMNS.length);
|
|
117
|
+
else if (input === 'r')
|
|
118
|
+
setDescending(c => !c);
|
|
119
|
+
else if (input === 'e') {
|
|
120
|
+
setStatus('');
|
|
121
|
+
setExportCursor(0);
|
|
122
|
+
setMode('export');
|
|
123
|
+
}
|
|
124
|
+
else if (input === 'h' || input === '?')
|
|
125
|
+
setMode('help');
|
|
126
|
+
else if (input === 'g')
|
|
127
|
+
setCursor(0);
|
|
128
|
+
else if (input === 'G')
|
|
129
|
+
setCursor(maxCursor);
|
|
130
|
+
else if (key.return && selectedPackage !== undefined)
|
|
131
|
+
setMode('detail');
|
|
132
|
+
else if (key.upArrow)
|
|
133
|
+
setCursor(c => Math.max(0, c - 1));
|
|
134
|
+
else if (key.downArrow)
|
|
135
|
+
setCursor(c => Math.min(maxCursor, c + 1));
|
|
136
|
+
else if (key.pageUp)
|
|
137
|
+
setCursor(c => Math.max(0, c - 10));
|
|
138
|
+
else if (key.pageDown)
|
|
139
|
+
setCursor(c => Math.min(maxCursor, c + 10));
|
|
140
|
+
});
|
|
141
|
+
const activeFilters = [];
|
|
142
|
+
if (query.length > 0)
|
|
143
|
+
activeFilters.push(format(messages.filterText, { query }));
|
|
144
|
+
if (categoryFilter !== null)
|
|
145
|
+
activeFilters.push(format(messages.filterCategory, { category: messages.categories[categoryFilter] }));
|
|
146
|
+
const modeLabel = mode !== 'list' ? ` [${mode.toUpperCase()}] ` : '';
|
|
147
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Header, { project: report.project, version: version, scannedAt: report.scannedAt, summary: report.summary }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: PALETTE.dim, children: divider }) }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(SummaryBar, { summary: report.summary }) }), _jsx(PackageTable, { packages: visible, cursor: clampedCursor, sortColumn: sortColumn, descending: descending, query: query }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [mode === 'filter' && _jsx(FilterBar, { query: query }), mode === 'export' && _jsx(ExportMenu, { cursor: exportCursor }), mode === 'detail' && selectedPackage !== undefined && _jsx(DetailPane, { pkg: selectedPackage }), mode === 'help' && _jsx(HelpOverlay, {}), _jsx(Text, { color: PALETTE.dim, children: divider }), _jsxs(Text, { color: PALETTE.dim, children: [_jsx(Text, { bold: true, color: PALETTE.brand, children: modeLabel }), activeFilters.length > 0 && mode !== 'filter' && _jsxs(Text, { color: PALETTE.ok, children: ["(", activeFilters.join(', '), ") "] }), status.length > 0 ? _jsxs(Text, { color: PALETTE.good, children: [status, " "] }) : messages.footer] })] })] }));
|
|
148
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { format } from '../i18n.js';
|
|
4
|
+
import { useMessages } from './i18n-context.js';
|
|
5
|
+
import { categoryColor, commercialColor, PALETTE, riskColor } from './theme.js';
|
|
6
|
+
function Row({ label, children }) {
|
|
7
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: PALETTE.dim, children: label.padEnd(12) }), children] }));
|
|
8
|
+
}
|
|
9
|
+
export function DetailPane({ pkg }) {
|
|
10
|
+
const messages = useMessages();
|
|
11
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: PALETTE.brand, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: "black", backgroundColor: PALETTE.brand, children: [" ", pkg.name, "@", pkg.version, " "] }), _jsx(Text, { color: PALETTE.dim, children: messages.detail.hint })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Row, { label: messages.detail.ecosystem, children: [_jsx(Text, { color: PALETTE.accent, children: pkg.ecosystem }), _jsxs(Text, { color: PALETTE.dim, children: [" (", messages.types[pkg.dependencyType], ")"] })] }), _jsxs(Row, { label: messages.detail.license, children: [_jsx(Text, { bold: true, children: pkg.license }), _jsxs(Text, { color: PALETTE.dim, children: [" - ", messages.sources[pkg.licenseSource]] })] }), _jsx(Row, { label: messages.detail.category, children: _jsx(Text, { color: categoryColor(pkg.category), bold: true, children: messages.categories[pkg.category] }) }), _jsx(Row, { label: messages.detail.risk, children: _jsx(Text, { color: riskColor(pkg.riskLevel), children: format(messages.detail.riskValue, {
|
|
12
|
+
score: pkg.riskScore,
|
|
13
|
+
level: messages.riskLevels[pkg.riskLevel],
|
|
14
|
+
}) }) }), _jsx(Row, { label: messages.detail.commercial, children: _jsx(Text, { color: commercialColor(pkg.commercialUse), bold: true, children: messages.commercial[pkg.commercialUse].toUpperCase() }) })] }), _jsx(Box, { marginTop: 1, padding: 1, borderStyle: "single", borderColor: PALETTE.dim, children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, italic: true, color: PALETTE.dim, children: [messages.detail.advice, ":"] }), _jsx(Text, { children: messages.advice[pkg.category] })] }) })] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import { useMessages } from './i18n-context.js';
|
|
4
|
+
import { PALETTE } from './theme.js';
|
|
5
|
+
export function ErrorScreen({ message }) {
|
|
6
|
+
const messages = useMessages();
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
useInput((input, key) => {
|
|
9
|
+
if (input === 'q' || key.escape || key.return) {
|
|
10
|
+
exit();
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "classic", borderColor: PALETTE.bad, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: PALETTE.bad, children: messages.error.title }), _jsx(Text, { children: message }), _jsx(Text, { color: PALETTE.dim, children: messages.error.hint })] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useMessages } from './i18n-context.js';
|
|
4
|
+
import { PALETTE } from './theme.js';
|
|
5
|
+
export const EXPORT_OPTION_COUNT = 3;
|
|
6
|
+
export function ExportMenu({ cursor }) {
|
|
7
|
+
const messages = useMessages();
|
|
8
|
+
const options = [
|
|
9
|
+
messages.exportMenu.json,
|
|
10
|
+
messages.exportMenu.html,
|
|
11
|
+
messages.exportMenu.cancel,
|
|
12
|
+
];
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "classic", borderColor: PALETTE.brand, paddingX: 1, children: [_jsx(Text, { bold: true, color: PALETTE.brand, children: messages.exportMenu.title }), options.map((option, index) => (_jsxs(Text, { color: index === cursor ? PALETTE.accent : undefined, inverse: index === cursor, children: [index === cursor ? '> ' : ' ', option] }, option)))] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useMessages } from './i18n-context.js';
|
|
4
|
+
import { PALETTE } from './theme.js';
|
|
5
|
+
export function FilterBar({ query }) {
|
|
6
|
+
const messages = useMessages();
|
|
7
|
+
return (_jsx(Box, { borderStyle: "classic", borderColor: PALETTE.brand, paddingX: 1, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: PALETTE.brand, children: messages.filterBar.label }), query, _jsx(Text, { inverse: true, children: " " }), _jsx(Text, { color: PALETTE.dim, children: messages.filterBar.hint })] }) }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { calculateHealthScore } from '../utils.js';
|
|
4
|
+
import { useMessages } from './i18n-context.js';
|
|
5
|
+
import { healthColor, HEALTH_GOOD_THRESHOLD, HEALTH_OK_THRESHOLD, PALETTE, } from './theme.js';
|
|
6
|
+
const FACE_TOP = '╭───────╮';
|
|
7
|
+
const FACE_BOTTOM = '╰───────╯';
|
|
8
|
+
// Visible columns the score bar is inset by: indent + face box + gap.
|
|
9
|
+
const FACE_INDENT = ' ';
|
|
10
|
+
const FACE_OFFSET = FACE_INDENT.length + FACE_TOP.length + FACE_INDENT.length;
|
|
11
|
+
const BAR_MIN_WIDTH = 10;
|
|
12
|
+
function faceLines(score) {
|
|
13
|
+
if (score >= HEALTH_GOOD_THRESHOLD)
|
|
14
|
+
return ['◉ ◉', ' ◠ '];
|
|
15
|
+
if (score >= HEALTH_OK_THRESHOLD)
|
|
16
|
+
return ['◉ ◉', ' ─ '];
|
|
17
|
+
return ['✕ ✕', ' ◡ '];
|
|
18
|
+
}
|
|
19
|
+
function healthLabel(score, messages) {
|
|
20
|
+
if (score >= HEALTH_GOOD_THRESHOLD)
|
|
21
|
+
return messages.header.health.good;
|
|
22
|
+
if (score >= HEALTH_OK_THRESHOLD)
|
|
23
|
+
return messages.header.health.ok;
|
|
24
|
+
return messages.header.health.poor;
|
|
25
|
+
}
|
|
26
|
+
export function Header({ project, version, scannedAt, summary }) {
|
|
27
|
+
const messages = useMessages();
|
|
28
|
+
const { stdout } = useStdout();
|
|
29
|
+
const terminalWidth = stdout.columns > 0 ? stdout.columns : 80;
|
|
30
|
+
const score = calculateHealthScore(summary);
|
|
31
|
+
const color = healthColor(score);
|
|
32
|
+
const [eyes, mouth] = faceLines(score);
|
|
33
|
+
const barWidth = Math.max(BAR_MIN_WIDTH, terminalWidth - FACE_OFFSET - 4);
|
|
34
|
+
const filled = Math.round((score / 100) * barWidth);
|
|
35
|
+
const empty = Math.max(0, barWidth - filled);
|
|
36
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", marginRight: 2, children: [_jsx(Text, { color: color, children: FACE_TOP }), _jsxs(Text, { color: color, children: ["\u2502 ", eyes, " \u2502"] }), _jsxs(Text, { color: color, children: ["\u2502 ", mouth, " \u2502"] }), _jsx(Text, { color: color, children: FACE_BOTTOM })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: color, children: score }), _jsx(Text, { color: PALETTE.dim, children: " / 100 " }), _jsx(Text, { bold: true, color: color, children: healthLabel(score, messages) })] }), _jsxs(Text, { children: [_jsx(Text, { color: color, children: '█'.repeat(filled) }), _jsx(Text, { color: PALETTE.dim, children: '░'.repeat(empty) })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: PALETTE.brand, children: "DEP-LENS" }), _jsxs(Text, { color: PALETTE.dim, children: [" v", version, " "] }), _jsx(Text, { color: PALETTE.accent, children: project })] }), _jsxs(Text, { color: PALETTE.dim, children: [messages.header.scanned, " ", scannedAt, " \u00B7 ", summary.total, " ", messages.header.packages] })] })] }) }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useMessages } from './i18n-context.js';
|
|
4
|
+
import { PALETTE } from './theme.js';
|
|
5
|
+
export function HelpOverlay() {
|
|
6
|
+
const messages = useMessages();
|
|
7
|
+
const half = Math.ceil(messages.help.bindings.length / 2);
|
|
8
|
+
const left = messages.help.bindings.slice(0, half);
|
|
9
|
+
const right = messages.help.bindings.slice(half);
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: PALETTE.accent, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { bold: true, color: PALETTE.accent, children: [" ", messages.help.title.toUpperCase(), " "] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Box, { flexDirection: "column", width: "50%", children: left.map(([key, description]) => (_jsxs(Text, { children: [_jsx(Text, { bold: true, color: PALETTE.brand, children: key.padEnd(12) }), _jsx(Text, { children: description })] }, key))) }), _jsx(Box, { flexDirection: "column", width: "50%", children: right.map(([key, description]) => (_jsxs(Text, { children: [_jsx(Text, { bold: true, color: PALETTE.brand, children: key.padEnd(12) }), _jsx(Text, { children: description })] }, key))) })] })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { format } from '../i18n.js';
|
|
4
|
+
import { pad } from '../utils.js';
|
|
5
|
+
import { useRevealOnce } from './hooks.js';
|
|
6
|
+
import { useMessages } from './i18n-context.js';
|
|
7
|
+
import { categoryColor, PALETTE } from './theme.js';
|
|
8
|
+
/**
|
|
9
|
+
* Column widths adapt to the terminal: version/category/risk/commercial are
|
|
10
|
+
* fixed, the remainder is split between name (60%) and license (40%).
|
|
11
|
+
*/
|
|
12
|
+
export function computeColumns(terminalWidth, messages) {
|
|
13
|
+
const fixed = [
|
|
14
|
+
{ key: 'version', label: messages.table.version, width: 9 },
|
|
15
|
+
{ key: 'category', label: messages.table.category, width: 16 },
|
|
16
|
+
{ key: 'dependencyType', label: messages.table.type, width: 10 },
|
|
17
|
+
{ key: 'riskScore', label: messages.table.risk, width: 7 },
|
|
18
|
+
{ key: 'commercialUse', label: messages.table.commercial, width: 12 },
|
|
19
|
+
];
|
|
20
|
+
const fixedTotal = fixed.reduce((sum, column) => sum + column.width, 0);
|
|
21
|
+
const separators = 6; // 7 columns
|
|
22
|
+
const chrome = 4; // table border plus horizontal padding
|
|
23
|
+
const flexible = Math.max(terminalWidth - chrome - separators - fixedTotal, 16);
|
|
24
|
+
const nameWidth = Math.max(Math.floor(flexible * 0.6), 10);
|
|
25
|
+
const licenseWidth = Math.max(flexible - nameWidth, 6);
|
|
26
|
+
const columns = [
|
|
27
|
+
{ key: 'name', label: messages.table.package, width: nameWidth },
|
|
28
|
+
{ key: 'license', label: messages.table.license, width: licenseWidth },
|
|
29
|
+
...fixed,
|
|
30
|
+
];
|
|
31
|
+
return columns.sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key));
|
|
32
|
+
}
|
|
33
|
+
const ORDER = [
|
|
34
|
+
'name',
|
|
35
|
+
'version',
|
|
36
|
+
'license',
|
|
37
|
+
'category',
|
|
38
|
+
'dependencyType',
|
|
39
|
+
'riskScore',
|
|
40
|
+
'commercialUse',
|
|
41
|
+
];
|
|
42
|
+
function cellValue(pkg, key, messages) {
|
|
43
|
+
switch (key) {
|
|
44
|
+
case 'name':
|
|
45
|
+
return pkg.name;
|
|
46
|
+
case 'version':
|
|
47
|
+
return pkg.version;
|
|
48
|
+
case 'license':
|
|
49
|
+
return pkg.license;
|
|
50
|
+
case 'category':
|
|
51
|
+
return messages.categories[pkg.category];
|
|
52
|
+
case 'dependencyType':
|
|
53
|
+
return messages.types[pkg.dependencyType];
|
|
54
|
+
case 'riskScore':
|
|
55
|
+
return String(pkg.riskScore);
|
|
56
|
+
case 'commercialUse':
|
|
57
|
+
return messages.commercial[pkg.commercialUse];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function PackageTable({ packages, cursor, sortColumn, descending, viewport = 12, query = '', }) {
|
|
61
|
+
const messages = useMessages();
|
|
62
|
+
const { stdout } = useStdout();
|
|
63
|
+
const columns = computeColumns(stdout.columns > 0 ? stdout.columns : 80, messages);
|
|
64
|
+
const revealed = useRevealOnce(viewport);
|
|
65
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(viewport / 2), packages.length - viewport));
|
|
66
|
+
const rows = packages.slice(start, start + Math.min(viewport, revealed));
|
|
67
|
+
const header = columns
|
|
68
|
+
.map((column) => {
|
|
69
|
+
const marker = column.key === sortColumn ? (descending ? ' v' : ' ^') : '';
|
|
70
|
+
return pad(`${column.label}${marker}`, column.width);
|
|
71
|
+
})
|
|
72
|
+
.join(' ');
|
|
73
|
+
const scrollbarHeight = Math.min(viewport, packages.length);
|
|
74
|
+
const scrollbarThumbHeight = Math.max(1, Math.floor((scrollbarHeight / Math.max(1, packages.length)) * scrollbarHeight));
|
|
75
|
+
const scrollbarThumbTop = packages.length > viewport
|
|
76
|
+
? Math.floor((start / Math.max(1, packages.length - viewport)) * (scrollbarHeight - scrollbarThumbHeight))
|
|
77
|
+
: 0;
|
|
78
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.dim, paddingX: 1, children: [_jsx(Text, { bold: true, color: PALETTE.brand, underline: true, children: header }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, children: rows.length === 0 ? (_jsx(Text, { color: PALETTE.dim, children: messages.table.empty })) : (rows.map((pkg, index) => {
|
|
79
|
+
const absoluteIndex = start + index;
|
|
80
|
+
const selected = absoluteIndex === cursor;
|
|
81
|
+
const line = columns
|
|
82
|
+
.map((column) => {
|
|
83
|
+
const val = cellValue(pkg, column.key, messages);
|
|
84
|
+
return pad(val, column.width);
|
|
85
|
+
})
|
|
86
|
+
.join(' ');
|
|
87
|
+
if (selected) {
|
|
88
|
+
return (_jsx(Text, { color: categoryColor(pkg.category), inverse: true, children: line }, `${pkg.ecosystem}:${pkg.name}@${pkg.version}`));
|
|
89
|
+
}
|
|
90
|
+
// Search highlighting for unselected rows
|
|
91
|
+
if (query.length > 0) {
|
|
92
|
+
const needle = query.toLowerCase();
|
|
93
|
+
const parts = [];
|
|
94
|
+
let lastIndex = 0;
|
|
95
|
+
const lowerLine = line.toLowerCase();
|
|
96
|
+
let matchIndex = lowerLine.indexOf(needle);
|
|
97
|
+
while (matchIndex !== -1) {
|
|
98
|
+
parts.push(line.slice(lastIndex, matchIndex));
|
|
99
|
+
parts.push(_jsx(Text, { underline: true, bold: true, color: PALETTE.brand, children: line.slice(matchIndex, matchIndex + needle.length) }, matchIndex));
|
|
100
|
+
lastIndex = matchIndex + needle.length;
|
|
101
|
+
matchIndex = lowerLine.indexOf(needle, lastIndex);
|
|
102
|
+
}
|
|
103
|
+
parts.push(line.slice(lastIndex));
|
|
104
|
+
return (_jsx(Text, { color: categoryColor(pkg.category), children: parts }, `${pkg.ecosystem}:${pkg.name}@${pkg.version}`));
|
|
105
|
+
}
|
|
106
|
+
return (_jsx(Text, { color: categoryColor(pkg.category), children: line }, `${pkg.ecosystem}:${pkg.name}@${pkg.version}`));
|
|
107
|
+
})) }), packages.length > viewport && (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: scrollbarHeight }).map((_, i) => (_jsx(Text, { color: i >= scrollbarThumbTop && i < scrollbarThumbTop + scrollbarThumbHeight
|
|
108
|
+
? PALETTE.brand
|
|
109
|
+
: PALETTE.dim, children: i >= scrollbarThumbTop && i < scrollbarThumbTop + scrollbarThumbHeight
|
|
110
|
+
? '┃'
|
|
111
|
+
: '│' }, i))) }))] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: PALETTE.dim, children: packages.length === 0
|
|
112
|
+
? format(messages.table.rows, { start: 0, end: 0, total: 0 })
|
|
113
|
+
: format(messages.table.rows, {
|
|
114
|
+
start: start + 1,
|
|
115
|
+
end: start + rows.length,
|
|
116
|
+
total: packages.length,
|
|
117
|
+
}) }), _jsx(Text, { color: PALETTE.dim, children: messages.table.rows.includes(' / ') ? `sıralama: ${sortColumn}` : `sort: ${sortColumn}` })] })] }));
|
|
118
|
+
}
|
package/dist/ui/Root.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { getMessages } from '../i18n.js';
|
|
4
|
+
import { App } from './App.js';
|
|
5
|
+
import { ErrorScreen } from './ErrorScreen.js';
|
|
6
|
+
import { I18nContext } from './i18n-context.js';
|
|
7
|
+
import { ScanningScreen } from './ScanningScreen.js';
|
|
8
|
+
/**
|
|
9
|
+
* Top-level TUI component: runs the scan with an animated progress screen,
|
|
10
|
+
* then hands over to the interactive report, all under the chosen locale.
|
|
11
|
+
*/
|
|
12
|
+
export function Root({ locale, version, scan, getHtml, onReport, onError, }) {
|
|
13
|
+
const [phase, setPhase] = useState({ name: 'scanning' });
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
let cancelled = false;
|
|
16
|
+
scan()
|
|
17
|
+
.then((report) => {
|
|
18
|
+
if (!cancelled) {
|
|
19
|
+
onReport(report);
|
|
20
|
+
setPhase({ name: 'ready', report });
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
.catch((error) => {
|
|
24
|
+
if (!cancelled) {
|
|
25
|
+
onError();
|
|
26
|
+
setPhase({
|
|
27
|
+
name: 'error',
|
|
28
|
+
message: error instanceof Error ? error.message : String(error),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
};
|
|
35
|
+
// The scan runs exactly once for the lifetime of the TUI.
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, []);
|
|
38
|
+
return (_jsxs(I18nContext.Provider, { value: getMessages(locale), children: [phase.name === 'scanning' ? _jsx(ScanningScreen, { version: version }) : null, phase.name === 'error' ? _jsx(ErrorScreen, { message: phase.message }) : null, phase.name === 'ready' ? _jsx(App, { report: phase.report, version: version, getHtml: getHtml }) : null] }));
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { format } from '../i18n.js';
|
|
5
|
+
import { useDots, useSpinner } from './hooks.js';
|
|
6
|
+
import { useMessages } from './i18n-context.js';
|
|
7
|
+
import { PALETTE } from './theme.js';
|
|
8
|
+
const FACE_TOP = '╭───────╮';
|
|
9
|
+
const FACE_BOTTOM = '╰───────╯';
|
|
10
|
+
export function ScanningScreen({ version }) {
|
|
11
|
+
const messages = useMessages();
|
|
12
|
+
const spinner = useSpinner();
|
|
13
|
+
const dots = useDots();
|
|
14
|
+
const [seconds, setSeconds] = useState(0);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const id = setInterval(() => {
|
|
17
|
+
setSeconds((value) => value + 1);
|
|
18
|
+
}, 1000);
|
|
19
|
+
return () => {
|
|
20
|
+
clearInterval(id);
|
|
21
|
+
};
|
|
22
|
+
}, []);
|
|
23
|
+
const barWidth = 30;
|
|
24
|
+
const filled = (seconds % barWidth) + 1;
|
|
25
|
+
return (_jsx(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsxs(Box, { children: [_jsxs(Box, { flexDirection: "column", marginRight: 2, children: [_jsx(Text, { color: PALETTE.brand, children: FACE_TOP }), _jsxs(Text, { color: PALETTE.brand, children: ["\u2502 ", spinner, " ", spinner, " \u2502"] }), _jsx(Text, { color: PALETTE.brand, children: "\u2502 \u25E0 \u2502" }), _jsx(Text, { color: PALETTE.brand, children: FACE_BOTTOM })] }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: PALETTE.brand, children: "DEP-LENS" }), _jsxs(Text, { color: PALETTE.dim, children: [" v", version] })] }), _jsxs(Text, { children: [_jsx(Text, { color: PALETTE.brand, children: '█'.repeat(filled) }), _jsx(Text, { color: PALETTE.dim, children: '░'.repeat(barWidth - filled) })] }), _jsxs(Text, { bold: true, children: [messages.scanning.title, dots] }), _jsx(Text, { color: PALETTE.dim, children: format(messages.scanning.elapsed, { seconds }) })] })] }) }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from 'ink';
|
|
3
|
+
import { buildRatioSegments, percent } from '../utils.js';
|
|
4
|
+
import { useAnimatedNumber } from './hooks.js';
|
|
5
|
+
import { useMessages } from './i18n-context.js';
|
|
6
|
+
import { PALETTE } from './theme.js';
|
|
7
|
+
function AnimatedEntry({ label, count, total, color, }) {
|
|
8
|
+
const animated = useAnimatedNumber(count);
|
|
9
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: color, bold: true, children: label }), ' ', animated, " (", percent(animated, total), "%)"] }));
|
|
10
|
+
}
|
|
11
|
+
export function SummaryBar({ summary }) {
|
|
12
|
+
const messages = useMessages();
|
|
13
|
+
const { stdout } = useStdout();
|
|
14
|
+
const terminalWidth = stdout.columns > 0 ? stdout.columns : 80;
|
|
15
|
+
const { total } = summary;
|
|
16
|
+
// The bar fill grows with the same easing as the counters.
|
|
17
|
+
const progress = useAnimatedNumber(1000, 900) / 1000;
|
|
18
|
+
const barWidth = Math.max(20, terminalWidth - 4);
|
|
19
|
+
const segments = buildRatioSegments(summary, barWidth, progress);
|
|
20
|
+
const entries = [
|
|
21
|
+
{ label: messages.summaryShort.Permissive, count: summary.permissive, color: PALETTE.good },
|
|
22
|
+
{ label: messages.summaryShort.WeakCopyleft, count: summary.weakCopyleft, color: PALETTE.ok },
|
|
23
|
+
{
|
|
24
|
+
label: messages.summaryShort.StrongCopyleft,
|
|
25
|
+
count: summary.strongCopyleft,
|
|
26
|
+
color: PALETTE.bad,
|
|
27
|
+
},
|
|
28
|
+
{ label: messages.summaryShort.Unknown, count: summary.unknown, color: PALETTE.unknown },
|
|
29
|
+
];
|
|
30
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { gap: 2, children: entries.map((entry) => (_jsx(AnimatedEntry, { label: entry.label, count: entry.count, total: total, color: entry.color }, entry.label))) }), _jsx(Text, { children: segments.map((segment) => (_jsx(Text, { color: segment.color, children: segment.char.repeat(segment.width) }, segment.category))) })] }));
|
|
31
|
+
}
|
package/dist/ui/hooks.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
export const SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
3
|
+
/** Classic ASCII spinner; advances one frame per interval. */
|
|
4
|
+
export function useSpinner(intervalMs = 90) {
|
|
5
|
+
const [tick, setTick] = useState(0);
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const id = setInterval(() => {
|
|
8
|
+
setTick((value) => value + 1);
|
|
9
|
+
}, intervalMs);
|
|
10
|
+
return () => {
|
|
11
|
+
clearInterval(id);
|
|
12
|
+
};
|
|
13
|
+
}, [intervalMs]);
|
|
14
|
+
return SPINNER_FRAMES[tick % SPINNER_FRAMES.length] ?? '|';
|
|
15
|
+
}
|
|
16
|
+
/** Animated trailing dots: "", ".", "..", "..." looping. */
|
|
17
|
+
export function useDots(intervalMs = 300) {
|
|
18
|
+
const [tick, setTick] = useState(0);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const id = setInterval(() => {
|
|
21
|
+
setTick((value) => value + 1);
|
|
22
|
+
}, intervalMs);
|
|
23
|
+
return () => {
|
|
24
|
+
clearInterval(id);
|
|
25
|
+
};
|
|
26
|
+
}, [intervalMs]);
|
|
27
|
+
return '.'.repeat(tick % 4);
|
|
28
|
+
}
|
|
29
|
+
export function easeOutCubic(t) {
|
|
30
|
+
return 1 - (1 - t) ** 3;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Count from 0 to `target` with an ease-out curve. Re-animates when the
|
|
34
|
+
* target changes (e.g. after filtering).
|
|
35
|
+
*/
|
|
36
|
+
export function useAnimatedNumber(target, durationMs = 700) {
|
|
37
|
+
const [value, setValue] = useState(0);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (target === 0) {
|
|
40
|
+
setValue(0);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
const id = setInterval(() => {
|
|
45
|
+
const t = Math.min(1, (Date.now() - startedAt) / durationMs);
|
|
46
|
+
setValue(Math.round(target * easeOutCubic(t)));
|
|
47
|
+
if (t >= 1) {
|
|
48
|
+
clearInterval(id);
|
|
49
|
+
}
|
|
50
|
+
}, 40);
|
|
51
|
+
return () => {
|
|
52
|
+
clearInterval(id);
|
|
53
|
+
};
|
|
54
|
+
}, [target, durationMs]);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Reveal `total` items progressively, one per interval, exactly once per
|
|
59
|
+
* component lifetime (table rows slide in on first render only).
|
|
60
|
+
*/
|
|
61
|
+
export function useRevealOnce(total, stepMs = 25) {
|
|
62
|
+
const done = useRef(false);
|
|
63
|
+
const [count, setCount] = useState(done.current ? total : 1);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (done.current) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const id = setInterval(() => {
|
|
69
|
+
setCount((current) => {
|
|
70
|
+
if (current + 1 >= total) {
|
|
71
|
+
done.current = true;
|
|
72
|
+
clearInterval(id);
|
|
73
|
+
return total;
|
|
74
|
+
}
|
|
75
|
+
return current + 1;
|
|
76
|
+
});
|
|
77
|
+
}, stepMs);
|
|
78
|
+
return () => {
|
|
79
|
+
clearInterval(id);
|
|
80
|
+
};
|
|
81
|
+
// Intentionally run once: this is an entrance animation, not a sync.
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, []);
|
|
84
|
+
return done.current ? total : Math.max(1, count);
|
|
85
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color policy: strong copyleft and other risky entries are red, items
|
|
3
|
+
* needing caution are amber, safe entries are green, unidentifiable
|
|
4
|
+
* entries are slate. Truecolor hex values (rendered via chalk) for a
|
|
5
|
+
* richer palette than the default ANSI 16; no emoji anywhere in the UI.
|
|
6
|
+
*/
|
|
7
|
+
export const PALETTE = {
|
|
8
|
+
good: '#4ade80', // emerald
|
|
9
|
+
ok: '#fbbf24', // amber
|
|
10
|
+
bad: '#fb7185', // rose
|
|
11
|
+
unknown: '#94a3b8', // slate
|
|
12
|
+
brand: '#38bdf8', // sky
|
|
13
|
+
accent: '#a78bfa', // violet
|
|
14
|
+
dim: '#64748b', // slate (dim text)
|
|
15
|
+
};
|
|
16
|
+
/** Health score thresholds (0-100) shared by the header face and bar. */
|
|
17
|
+
export const HEALTH_GOOD_THRESHOLD = 80;
|
|
18
|
+
export const HEALTH_OK_THRESHOLD = 50;
|
|
19
|
+
export function healthColor(score) {
|
|
20
|
+
if (score >= HEALTH_GOOD_THRESHOLD)
|
|
21
|
+
return PALETTE.good;
|
|
22
|
+
if (score >= HEALTH_OK_THRESHOLD)
|
|
23
|
+
return PALETTE.ok;
|
|
24
|
+
return PALETTE.bad;
|
|
25
|
+
}
|
|
26
|
+
export function categoryColor(category) {
|
|
27
|
+
switch (category) {
|
|
28
|
+
case 'Permissive':
|
|
29
|
+
return PALETTE.good;
|
|
30
|
+
case 'WeakCopyleft':
|
|
31
|
+
return PALETTE.ok;
|
|
32
|
+
case 'StrongCopyleft':
|
|
33
|
+
return PALETTE.bad;
|
|
34
|
+
case 'Unknown':
|
|
35
|
+
return PALETTE.unknown;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function riskColor(level) {
|
|
39
|
+
switch (level) {
|
|
40
|
+
case 'low':
|
|
41
|
+
return PALETTE.good;
|
|
42
|
+
case 'medium':
|
|
43
|
+
return PALETTE.ok;
|
|
44
|
+
case 'high':
|
|
45
|
+
return PALETTE.bad;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function commercialColor(use) {
|
|
49
|
+
switch (use) {
|
|
50
|
+
case 'yes':
|
|
51
|
+
return PALETTE.good;
|
|
52
|
+
case 'caution':
|
|
53
|
+
return PALETTE.ok;
|
|
54
|
+
case 'restricted':
|
|
55
|
+
return PALETTE.bad;
|
|
56
|
+
case 'review':
|
|
57
|
+
return PALETTE.unknown;
|
|
58
|
+
}
|
|
59
|
+
}
|