table-viewer 0.0.0 → 0.0.1
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/README.md +55 -0
- package/index.js +359 -0
- package/package.json +5 -1
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# table-viewer
|
|
2
|
+
|
|
3
|
+
A keyboard-navigable terminal table viewer. Pipe in JSON, scroll through large datasets right in your terminal.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
cat table-data.json | npx table-viewer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Pipe in any JSON that [`console.table`](https://developer.mozilla.org/en-US/docs/Web/API/console/table_static) can render — arrays of objects are the most common case:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
node -e '
|
|
15
|
+
const rows = 40
|
|
16
|
+
const cols = 30
|
|
17
|
+
const tableData = Array.from({length: rows}, () => Object.fromEntries(
|
|
18
|
+
Array.from({length: cols}, (_, c) => [`col_${c}`, Math.random().toFixed(2)])
|
|
19
|
+
))
|
|
20
|
+
console.log(JSON.stringify(tableData))
|
|
21
|
+
' | npx table-viewer
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## How it works
|
|
25
|
+
|
|
26
|
+
`table-viewer` reads JSON from stdin, passes it to `console.table`, and renders the result as a scrollable, keyboard-navigable view. Navigate with arrow keys (or vim motions if that's your thing). The table stays interactive until you quit — no paging through `less`, no truncated output.
|
|
27
|
+
|
|
28
|
+
## Keybindings
|
|
29
|
+
|
|
30
|
+
### Navigation
|
|
31
|
+
|
|
32
|
+
| Key | Action |
|
|
33
|
+
| --------------- | ------------------- |
|
|
34
|
+
| `←` / `→` | Scroll left / right |
|
|
35
|
+
| `↑` / `↓` | Scroll up / down |
|
|
36
|
+
| `Shift` + arrow | Scroll faster |
|
|
37
|
+
| `q` / `Ctrl+c` | Quit |
|
|
38
|
+
|
|
39
|
+
### Page movement
|
|
40
|
+
|
|
41
|
+
| Key | Action |
|
|
42
|
+
| ------------------- | ------------------- |
|
|
43
|
+
| `Ctrl+d` / `Ctrl+u` | Half page down / up |
|
|
44
|
+
| `Ctrl+f` / `Ctrl+b` | Full page down / up |
|
|
45
|
+
|
|
46
|
+
### Vim motions
|
|
47
|
+
|
|
48
|
+
| Key | Action |
|
|
49
|
+
| ---------------- | --------------------- |
|
|
50
|
+
| `h` `j` `k` `l` | Left, down, up, right |
|
|
51
|
+
| `Shift` + motion | Scroll faster |
|
|
52
|
+
| `gg` | Jump to top |
|
|
53
|
+
| `G` | Jump to bottom |
|
|
54
|
+
| `0` | Scroll to leftmost |
|
|
55
|
+
| `$` | Scroll to rightmost |
|
package/index.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as fs from 'node:fs'
|
|
4
|
+
import * as readline from 'node:readline'
|
|
5
|
+
import * as tty from 'node:tty'
|
|
6
|
+
|
|
7
|
+
if (!process.stdout.isTTY) process.exit(1)
|
|
8
|
+
|
|
9
|
+
const tableData = await getTableDataFromStdin()
|
|
10
|
+
|
|
11
|
+
// Reopen stdin for interactive keyboard input (platform-agnostic)
|
|
12
|
+
const ttyIn = new tty.ReadStream(
|
|
13
|
+
fs.openSync(process.platform === 'win32' ? 'CONIN$' : '/dev/tty', 'r'),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
ttyIn.setRawMode(true)
|
|
17
|
+
|
|
18
|
+
readline.emitKeypressEvents(ttyIn)
|
|
19
|
+
|
|
20
|
+
/** @type {{[index: number]: string}} */
|
|
21
|
+
const consoleTableStartChars = {
|
|
22
|
+
0: '┌',
|
|
23
|
+
2: '├',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const consoleTableOutput = captureConsoleTableOutput(tableData)
|
|
27
|
+
|
|
28
|
+
const consoleTableOutputSplit = consoleTableOutput.split('\n')
|
|
29
|
+
|
|
30
|
+
let consoleWidth = process.stdout.columns
|
|
31
|
+
let consoleHeight = process.stdout.rows
|
|
32
|
+
let xOffset = 0
|
|
33
|
+
let yOffset = 0
|
|
34
|
+
|
|
35
|
+
if (!consoleTableOutputSplit.length) process.exit(0)
|
|
36
|
+
|
|
37
|
+
const consoleTableOutputWidth = consoleTableOutputSplit[0].length
|
|
38
|
+
const consoleTableOutputHeight = consoleTableOutputSplit.length
|
|
39
|
+
|
|
40
|
+
// Hide cursor
|
|
41
|
+
process.stdout.write('\x1B[?25l')
|
|
42
|
+
|
|
43
|
+
// Restore cursor on exit
|
|
44
|
+
process.on('exit', () => {
|
|
45
|
+
process.stdout.write('\x1B[?25h')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
process.stdout.on('resize', () => {
|
|
49
|
+
consoleWidth = process.stdout.columns
|
|
50
|
+
consoleHeight = process.stdout.rows
|
|
51
|
+
|
|
52
|
+
console.clear()
|
|
53
|
+
|
|
54
|
+
render()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
let pendingG = false
|
|
58
|
+
|
|
59
|
+
ttyIn.on('keypress', (_str, key) => {
|
|
60
|
+
// Ctrl+C or q to exit
|
|
61
|
+
if ((key.ctrl && key.name === 'c') || key.name === 'q') {
|
|
62
|
+
process.exit(0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { needsScrollbarX, needsScrollbarY } = computeScrollbarVisibility()
|
|
66
|
+
|
|
67
|
+
const maxXOffset = Math.max(
|
|
68
|
+
0,
|
|
69
|
+
consoleTableOutputWidth - consoleWidth + (needsScrollbarY ? 1 : 0),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const maxYOffset = Math.max(
|
|
73
|
+
0,
|
|
74
|
+
consoleTableOutputHeight - consoleHeight + (needsScrollbarX ? 1 : 0),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const visibleHeight = consoleHeight - (needsScrollbarX ? 1 : 0)
|
|
78
|
+
|
|
79
|
+
// Handle gg (go to top)
|
|
80
|
+
if (key.name === 'g' && !key.ctrl && !key.shift) {
|
|
81
|
+
if (pendingG) {
|
|
82
|
+
pendingG = false
|
|
83
|
+
yOffset = 0
|
|
84
|
+
render()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pendingG = true
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// G (shift+g) — go to bottom
|
|
93
|
+
if (key.shift && key.name === 'g') {
|
|
94
|
+
pendingG = false
|
|
95
|
+
yOffset = maxYOffset
|
|
96
|
+
|
|
97
|
+
render()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
pendingG = false
|
|
102
|
+
|
|
103
|
+
// Vim motions
|
|
104
|
+
switch (key.name) {
|
|
105
|
+
// -- vertical movement --
|
|
106
|
+
case 'k':
|
|
107
|
+
case 'up':
|
|
108
|
+
yOffset = Math.max(0, yOffset - (key.shift ? 10 : 5))
|
|
109
|
+
break
|
|
110
|
+
case 'j':
|
|
111
|
+
case 'down':
|
|
112
|
+
yOffset = Math.min(maxYOffset, yOffset + (key.shift ? 10 : 5))
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
// -- horizontal movement --
|
|
116
|
+
case 'h':
|
|
117
|
+
case 'left':
|
|
118
|
+
xOffset = Math.max(0, xOffset - (key.shift ? 10 : 5))
|
|
119
|
+
break
|
|
120
|
+
case 'l':
|
|
121
|
+
case 'right':
|
|
122
|
+
xOffset = Math.min(maxXOffset, xOffset + (key.shift ? 10 : 5))
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
// Ctrl+d — half page down
|
|
126
|
+
case 'd':
|
|
127
|
+
if (key.ctrl) {
|
|
128
|
+
yOffset = Math.min(maxYOffset, yOffset + Math.floor(visibleHeight / 2))
|
|
129
|
+
} else return
|
|
130
|
+
break
|
|
131
|
+
// Ctrl+u — half page up
|
|
132
|
+
case 'u':
|
|
133
|
+
if (key.ctrl) {
|
|
134
|
+
yOffset = Math.max(0, yOffset - Math.floor(visibleHeight / 2))
|
|
135
|
+
} else {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
break
|
|
139
|
+
// Ctrl+f — full page down
|
|
140
|
+
case 'f':
|
|
141
|
+
if (key.ctrl) {
|
|
142
|
+
yOffset = Math.min(maxYOffset, yOffset + visibleHeight)
|
|
143
|
+
} else {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
break
|
|
147
|
+
// Ctrl+b — full page up
|
|
148
|
+
case 'b':
|
|
149
|
+
if (key.ctrl) {
|
|
150
|
+
yOffset = Math.max(0, yOffset - visibleHeight)
|
|
151
|
+
} else {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
// 0 — scroll to leftmost
|
|
157
|
+
case '0':
|
|
158
|
+
xOffset = 0
|
|
159
|
+
break
|
|
160
|
+
// $ — scroll to rightmost
|
|
161
|
+
case '4': // shift+4 = $
|
|
162
|
+
if (key.shift) {
|
|
163
|
+
xOffset = maxXOffset
|
|
164
|
+
} else {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
default:
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
render()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
render()
|
|
177
|
+
|
|
178
|
+
function render() {
|
|
179
|
+
console.clear()
|
|
180
|
+
|
|
181
|
+
const { needsScrollbarX, needsScrollbarY } = computeScrollbarVisibility()
|
|
182
|
+
|
|
183
|
+
if (!needsScrollbarX && !needsScrollbarY) {
|
|
184
|
+
process.stdout.write(consoleTableOutput)
|
|
185
|
+
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const scrollbarX = renderScrollbarX(needsScrollbarX, needsScrollbarY)
|
|
190
|
+
const scrollbarY = renderScrollbarY(needsScrollbarX, needsScrollbarY).split(
|
|
191
|
+
'',
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const consoleTableOutputSegment = consoleTableOutputSplit
|
|
195
|
+
.slice(yOffset, yOffset + consoleHeight - (scrollbarX ? 1 : 0))
|
|
196
|
+
.map(
|
|
197
|
+
(line, index) =>
|
|
198
|
+
line.slice(
|
|
199
|
+
xOffset,
|
|
200
|
+
xOffset + consoleWidth - (scrollbarY[index] ? 1 : 0),
|
|
201
|
+
) + (scrollbarY[index] || ''),
|
|
202
|
+
)
|
|
203
|
+
.join('\n')
|
|
204
|
+
|
|
205
|
+
process.stdout.write(
|
|
206
|
+
consoleTableOutputSegment + (scrollbarX ? '\n' + scrollbarX : ''),
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function computeScrollbarVisibility() {
|
|
211
|
+
let needsScrollbarX = consoleTableOutputWidth > consoleWidth
|
|
212
|
+
let needsScrollbarY = consoleTableOutputHeight > consoleHeight
|
|
213
|
+
|
|
214
|
+
// A scrollbar takes 1 row/column of space, which may cause
|
|
215
|
+
// the other scrollbar to become necessary
|
|
216
|
+
if (needsScrollbarY && !needsScrollbarX) {
|
|
217
|
+
needsScrollbarX = consoleTableOutputWidth > consoleWidth - 1
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (needsScrollbarX && !needsScrollbarY) {
|
|
221
|
+
needsScrollbarY = consoleTableOutputHeight > consoleHeight - 1
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
needsScrollbarX,
|
|
226
|
+
needsScrollbarY,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {boolean} needsScrollbarX
|
|
232
|
+
* @param {boolean} needsScrollbarY
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
function renderScrollbarX(needsScrollbarX, needsScrollbarY) {
|
|
236
|
+
if (!needsScrollbarX) return ''
|
|
237
|
+
|
|
238
|
+
const visibleWidth = consoleWidth - (needsScrollbarY ? 1 : 0)
|
|
239
|
+
const trackLength = visibleWidth - (needsScrollbarY ? 1 : 2)
|
|
240
|
+
|
|
241
|
+
const thumbSize = Math.max(
|
|
242
|
+
1,
|
|
243
|
+
Math.floor((visibleWidth / consoleTableOutputWidth) * trackLength),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const maxThumbPos = trackLength - thumbSize
|
|
247
|
+
|
|
248
|
+
const thumbPos =
|
|
249
|
+
Math.floor(
|
|
250
|
+
(xOffset / (consoleTableOutputWidth - visibleWidth)) * maxThumbPos,
|
|
251
|
+
) || 0
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
' ' +
|
|
255
|
+
'-'.repeat(thumbPos) +
|
|
256
|
+
'═'.repeat(thumbSize) +
|
|
257
|
+
'-'.repeat(trackLength - thumbPos - thumbSize) +
|
|
258
|
+
' '
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {boolean} needsScrollbarX
|
|
264
|
+
* @param {boolean} needsScrollbarY
|
|
265
|
+
* @returns {string}
|
|
266
|
+
*/
|
|
267
|
+
function renderScrollbarY(needsScrollbarX, needsScrollbarY) {
|
|
268
|
+
if (!needsScrollbarY) return ''
|
|
269
|
+
|
|
270
|
+
const visibleHeight = consoleHeight - (needsScrollbarX ? 1 : 0)
|
|
271
|
+
const trackLength = visibleHeight - (needsScrollbarX ? 1 : 2)
|
|
272
|
+
|
|
273
|
+
const thumbSize = Math.max(
|
|
274
|
+
1,
|
|
275
|
+
Math.floor((visibleHeight / consoleTableOutputHeight) * trackLength),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
const maxThumbPos = trackLength - thumbSize
|
|
279
|
+
|
|
280
|
+
const thumbPos =
|
|
281
|
+
Math.floor(
|
|
282
|
+
(yOffset / (consoleTableOutputHeight - visibleHeight)) * maxThumbPos,
|
|
283
|
+
) || 0
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
' ' +
|
|
287
|
+
'╎'.repeat(thumbPos) +
|
|
288
|
+
'║'.repeat(thumbSize) +
|
|
289
|
+
'╎'.repeat(trackLength - thumbPos - thumbSize) +
|
|
290
|
+
(needsScrollbarX ? '' : ' ')
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {Parameters<typeof console.table>} args
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
function captureConsoleTableOutput(...args) {
|
|
299
|
+
let consoleTableOutput = ''
|
|
300
|
+
|
|
301
|
+
const originalWrite = process.stdout.write.bind(process.stdout)
|
|
302
|
+
|
|
303
|
+
process.stdout.write = (chunk) => {
|
|
304
|
+
consoleTableOutput += chunk
|
|
305
|
+
|
|
306
|
+
return true
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.table(...args)
|
|
310
|
+
|
|
311
|
+
process.stdout.write = originalWrite
|
|
312
|
+
|
|
313
|
+
return removeIndexColumn(consoleTableOutput.replace(/\x1B\[[0-9;]*m/g, ''))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Removes the `| (index) |` column from `console.table` output
|
|
318
|
+
* @param {string} consoleTableOutput
|
|
319
|
+
* @returns {string}
|
|
320
|
+
*/
|
|
321
|
+
function removeIndexColumn(consoleTableOutput) {
|
|
322
|
+
const consoleTableOutputSplit = consoleTableOutput.trim().split('\n')
|
|
323
|
+
|
|
324
|
+
const lastRowIndex = consoleTableOutputSplit.length - 1
|
|
325
|
+
|
|
326
|
+
const secondPipeIndex = consoleTableOutputSplit[1].indexOf('│', 1)
|
|
327
|
+
|
|
328
|
+
return consoleTableOutputSplit
|
|
329
|
+
.map((row, rowIndex) => {
|
|
330
|
+
const consoleTableStartChar =
|
|
331
|
+
consoleTableStartChars[rowIndex] ||
|
|
332
|
+
(rowIndex === lastRowIndex ? '└' : '')
|
|
333
|
+
|
|
334
|
+
if (!consoleTableStartChar) {
|
|
335
|
+
return row.slice(secondPipeIndex)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return `${consoleTableStartChar}${row.slice(secondPipeIndex + 1)}`
|
|
339
|
+
})
|
|
340
|
+
.join('\n')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* @returns {Promise<object[]>}
|
|
345
|
+
*/
|
|
346
|
+
async function getTableDataFromStdin() {
|
|
347
|
+
let tableDataString = ''
|
|
348
|
+
|
|
349
|
+
for await (const chunk of process.stdin) {
|
|
350
|
+
tableDataString += chunk
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
return JSON.parse(tableDataString)
|
|
355
|
+
} catch {
|
|
356
|
+
console.error('Error: Invalid JSON input')
|
|
357
|
+
process.exit(1)
|
|
358
|
+
}
|
|
359
|
+
}
|