devvami 1.4.0 → 1.4.2

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.
@@ -0,0 +1,224 @@
1
+ import chalk from 'chalk'
2
+ import { NVD_ATTRIBUTION } from '../../services/nvd.js'
3
+
4
+ // ──────────────────────────────────────────────────────────────────────────────
5
+ // ANSI escape sequences (re-declared locally — avoids cross-module coupling)
6
+ // ──────────────────────────────────────────────────────────────────────────────
7
+
8
+ const ANSI_CLEAR = '\x1b[2J'
9
+ const ANSI_HOME = '\x1b[H'
10
+
11
+ // ──────────────────────────────────────────────────────────────────────────────
12
+ // Internal helpers
13
+ // ──────────────────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Clamp a value between min and max (inclusive).
17
+ * @param {number} val
18
+ * @param {number} min
19
+ * @param {number} max
20
+ * @returns {number}
21
+ */
22
+ function clamp(val, min, max) {
23
+ return Math.min(Math.max(val, min), max)
24
+ }
25
+
26
+ /**
27
+ * Center a string within a fixed width, padding with spaces on both sides.
28
+ * @param {string} text - Plain text (no ANSI codes)
29
+ * @param {number} width
30
+ * @returns {string}
31
+ */
32
+ function centerText(text, width) {
33
+ if (text.length >= width) return text.slice(0, width)
34
+ const totalPad = width - text.length
35
+ const left = Math.floor(totalPad / 2)
36
+ const right = totalPad - left
37
+ return ' '.repeat(left) + text + ' '.repeat(right)
38
+ }
39
+
40
+ // ──────────────────────────────────────────────────────────────────────────────
41
+ // T006: buildModalScreen
42
+ // ──────────────────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Build the full modal overlay screen string from the current interactive state.
46
+ * Renders the modal content lines with a border, scroll indicators, and footer hints.
47
+ * Prepends ANSI clear + home to replace the previous frame.
48
+ * @param {import('./navigable-table.js').InteractiveTableState} state
49
+ * @returns {string}
50
+ */
51
+ export function buildModalScreen(state) {
52
+ const { modalContent, modalScrollOffset, termRows, termCols, firstRefUrl } = state
53
+
54
+ const lines = []
55
+
56
+ // Modal inner width: leave 4 chars for left/right borders + padding
57
+ const innerWidth = Math.max(20, termCols - 4)
58
+
59
+ // ── Title bar ──────────────────────────────────────────────────────────────
60
+ const titleText = 'CVE Detail'
61
+ lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
62
+ lines.push(chalk.bold.cyan('║ ') + chalk.bold(centerText(titleText, innerWidth)) + chalk.bold.cyan(' ║'))
63
+ lines.push(chalk.bold.cyan('╠' + '═'.repeat(innerWidth + 2) + '╣'))
64
+
65
+ const BORDER_LINES = 3 // title bar: top + title + divider
66
+ const FOOTER_LINES = 4 // empty + attribution + hints + bottom border
67
+ const contentViewport = Math.max(1, termRows - BORDER_LINES - FOOTER_LINES)
68
+
69
+ const content = modalContent ?? []
70
+ const maxOffset = Math.max(0, content.length - contentViewport)
71
+ const safeOffset = clamp(modalScrollOffset, 0, maxOffset)
72
+
73
+ // ── Content lines ──────────────────────────────────────────────────────────
74
+ const visibleLines = content.slice(safeOffset, safeOffset + contentViewport)
75
+ for (const line of visibleLines) {
76
+ // Truncate to inner width to avoid terminal wrapping
77
+ const truncated = line.length > innerWidth ? line.slice(0, innerWidth - 1) + '…' : line
78
+ lines.push(chalk.bold.cyan('║ ') + truncated.padEnd(innerWidth) + chalk.bold.cyan(' ║'))
79
+ }
80
+ // Pad to fill the content viewport
81
+ for (let i = visibleLines.length; i < contentViewport; i++) {
82
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
83
+ }
84
+
85
+ // ── Footer ─────────────────────────────────────────────────────────────────
86
+ lines.push(chalk.bold.cyan('╠' + '═'.repeat(innerWidth + 2) + '╣'))
87
+ lines.push(chalk.bold.cyan('║ ') + chalk.dim(NVD_ATTRIBUTION).slice(0, innerWidth).padEnd(innerWidth) + chalk.bold.cyan(' ║'))
88
+
89
+ const scrollHint = content.length > contentViewport ? ' ↑↓/PgUp/PgDn scroll' : ''
90
+ const openHint = firstRefUrl ? ' o open ref' : ''
91
+ const hintLine = ` ↑↓ scroll Esc back to table q exit${openHint}${scrollHint}`
92
+ const truncHint = hintLine.length > innerWidth ? hintLine.slice(0, innerWidth - 1) + '…' : hintLine
93
+ lines.push(chalk.bold.cyan('║ ') + chalk.dim(truncHint).padEnd(innerWidth) + chalk.bold.cyan(' ║'))
94
+ lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
95
+
96
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
97
+ }
98
+
99
+ // ──────────────────────────────────────────────────────────────────────────────
100
+ // T007: buildLoadingScreen
101
+ // ──────────────────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Build a "loading detail" screen to display while a CVE detail fetch is in flight.
105
+ * @param {string} cveId - The CVE identifier being fetched
106
+ * @param {number} termRows - Current terminal height
107
+ * @param {number} termCols - Current terminal width
108
+ * @returns {string}
109
+ */
110
+ export function buildLoadingScreen(cveId, termRows, termCols) {
111
+ const lines = []
112
+ const innerWidth = Math.max(20, termCols - 4)
113
+ const midRow = Math.floor(termRows / 2)
114
+
115
+ lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
116
+
117
+ for (let i = 1; i < midRow - 1; i++) {
118
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
119
+ }
120
+
121
+ const loadingText = `Loading ${cveId}…`
122
+ const centred = centerText(loadingText, innerWidth)
123
+ lines.push(chalk.bold.cyan('║ ') + chalk.yellow(centred) + chalk.bold.cyan(' ║'))
124
+
125
+ const remaining = termRows - lines.length - 1
126
+ for (let i = 0; i < remaining; i++) {
127
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
128
+ }
129
+
130
+ lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
131
+
132
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
133
+ }
134
+
135
+ // ──────────────────────────────────────────────────────────────────────────────
136
+ // T008: buildErrorScreen
137
+ // ──────────────────────────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Build an error modal screen displayed when a CVE detail fetch fails.
141
+ * @param {string} cveId - The CVE identifier that failed to load
142
+ * @param {string} errorMessage - The error message to display
143
+ * @param {number} termRows - Current terminal height
144
+ * @param {number} termCols - Current terminal width
145
+ * @returns {string}
146
+ */
147
+ export function buildErrorScreen(cveId, errorMessage, termRows, termCols) {
148
+ const lines = []
149
+ const innerWidth = Math.max(20, termCols - 4)
150
+ const midRow = Math.floor(termRows / 2)
151
+
152
+ lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
153
+
154
+ for (let i = 1; i < midRow - 2; i++) {
155
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
156
+ }
157
+
158
+ const titleText = `Failed to load ${cveId}`
159
+ lines.push(chalk.bold.cyan('║ ') + chalk.red.bold(centerText(titleText, innerWidth)) + chalk.bold.cyan(' ║'))
160
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
161
+
162
+ const truncErr =
163
+ errorMessage.length > innerWidth ? errorMessage.slice(0, innerWidth - 1) + '…' : errorMessage
164
+ lines.push(chalk.bold.cyan('║ ') + chalk.red(truncErr.padEnd(innerWidth)) + chalk.bold.cyan(' ║'))
165
+
166
+ const remaining = termRows - lines.length - 2
167
+ for (let i = 0; i < remaining; i++) {
168
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
169
+ }
170
+
171
+ const hintText = centerText('Press Esc to return to the table', innerWidth)
172
+ lines.push(chalk.bold.cyan('║ ') + chalk.dim(hintText) + chalk.bold.cyan(' ║'))
173
+ lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
174
+
175
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
176
+ }
177
+
178
+ // ──────────────────────────────────────────────────────────────────────────────
179
+ // T015: handleModalKeypress
180
+ // ──────────────────────────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Pure state reducer for keypresses in the modal view.
184
+ * Returns a new state on scroll, a control object on exit/back/open-url, or unchanged state.
185
+ * @param {import('./navigable-table.js').InteractiveTableState} state
186
+ * @param {{ name: string, ctrl?: boolean }} key - readline keypress event
187
+ * @returns {
188
+ * import('./navigable-table.js').InteractiveTableState |
189
+ * { backToTable: true } |
190
+ * { exit: true } |
191
+ * { openUrl: string }
192
+ * }
193
+ */
194
+ export function handleModalKeypress(state, key) {
195
+ const { modalContent, modalScrollOffset, termRows, firstRefUrl } = state
196
+
197
+ if (key.ctrl && key.name === 'c') return { exit: true }
198
+ if (key.name === 'q') return { exit: true }
199
+
200
+ if (key.name === 'escape') return { backToTable: true }
201
+
202
+ if (key.name === 'o' && firstRefUrl) return { openUrl: firstRefUrl }
203
+
204
+ const contentLen = modalContent ? modalContent.length : 0
205
+ const BORDER_LINES = 3
206
+ const FOOTER_LINES = 4
207
+ const contentViewport = Math.max(1, termRows - BORDER_LINES - FOOTER_LINES)
208
+ const maxOffset = Math.max(0, contentLen - contentViewport)
209
+
210
+ if (key.name === 'up') {
211
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset - 1, 0, maxOffset) }
212
+ }
213
+ if (key.name === 'down') {
214
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset + 1, 0, maxOffset) }
215
+ }
216
+ if (key.name === 'pageup') {
217
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset - contentViewport, 0, maxOffset) }
218
+ }
219
+ if (key.name === 'pagedown') {
220
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset + contentViewport, 0, maxOffset) }
221
+ }
222
+
223
+ return state // unrecognized key — no state change
224
+ }