table-viewer 0.0.0 → 0.0.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.
Files changed (3) hide show
  1. package/README.md +55 -0
  2. package/index.js +367 -0
  3. package/package.json +13 -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,367 @@
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 scrollbarEndChars = {
27
+ up: '·',
28
+ down: '·',
29
+ left: '·',
30
+ right: '·',
31
+ }
32
+
33
+ const consoleTableOutput = captureConsoleTableOutput(tableData)
34
+
35
+ const consoleTableOutputSplit = consoleTableOutput.split('\n')
36
+
37
+ let consoleWidth = process.stdout.columns
38
+ let consoleHeight = process.stdout.rows
39
+ let xOffset = 0
40
+ let yOffset = 0
41
+
42
+ if (!consoleTableOutputSplit.length) process.exit(0)
43
+
44
+ const consoleTableOutputWidth = consoleTableOutputSplit[0].length
45
+ const consoleTableOutputHeight = consoleTableOutputSplit.length
46
+
47
+ // Enter alternate screen buffer and hide cursor
48
+ process.stdout.write('\x1B[?1049h\x1B[?25l')
49
+
50
+ // Restore cursor and leave alternate screen buffer on exit
51
+ process.on('exit', () => {
52
+ process.stdout.write('\x1B[?25h\x1B[?1049l')
53
+ })
54
+
55
+ process.stdout.on('resize', () => {
56
+ consoleWidth = process.stdout.columns
57
+ consoleHeight = process.stdout.rows
58
+
59
+ console.clear()
60
+
61
+ render()
62
+ })
63
+
64
+ let pendingG = false
65
+
66
+ ttyIn.on('keypress', (_str, key) => {
67
+ // Ctrl+C or q to exit
68
+ if ((key.ctrl && key.name === 'c') || key.name === 'q') {
69
+ process.exit(0)
70
+ }
71
+
72
+ const { needsScrollbarX, needsScrollbarY } = computeScrollbarVisibility()
73
+
74
+ const maxXOffset = Math.max(
75
+ 0,
76
+ consoleTableOutputWidth - consoleWidth + (needsScrollbarY ? 1 : 0),
77
+ )
78
+
79
+ const maxYOffset = Math.max(
80
+ 0,
81
+ consoleTableOutputHeight - consoleHeight + (needsScrollbarX ? 1 : 0),
82
+ )
83
+
84
+ const visibleHeight = consoleHeight - (needsScrollbarX ? 1 : 0)
85
+
86
+ // Handle gg (go to top)
87
+ if (key.name === 'g' && !key.ctrl && !key.shift) {
88
+ if (pendingG) {
89
+ pendingG = false
90
+ yOffset = 0
91
+ render()
92
+ return
93
+ }
94
+
95
+ pendingG = true
96
+ return
97
+ }
98
+
99
+ // G (shift+g) — go to bottom
100
+ if (key.shift && key.name === 'g') {
101
+ pendingG = false
102
+ yOffset = maxYOffset
103
+
104
+ render()
105
+ return
106
+ }
107
+
108
+ pendingG = false
109
+
110
+ // Vim motions
111
+ switch (key.name) {
112
+ // -- vertical movement --
113
+ case 'k':
114
+ case 'up':
115
+ yOffset = Math.max(0, yOffset - (key.shift ? 10 : 5))
116
+ break
117
+ case 'j':
118
+ case 'down':
119
+ yOffset = Math.min(maxYOffset, yOffset + (key.shift ? 10 : 5))
120
+ break
121
+
122
+ // -- horizontal movement --
123
+ case 'h':
124
+ case 'left':
125
+ xOffset = Math.max(0, xOffset - (key.shift ? 10 : 5))
126
+ break
127
+ case 'l':
128
+ case 'right':
129
+ xOffset = Math.min(maxXOffset, xOffset + (key.shift ? 10 : 5))
130
+ break
131
+
132
+ // Ctrl+d — half page down
133
+ case 'd':
134
+ if (key.ctrl) {
135
+ yOffset = Math.min(maxYOffset, yOffset + Math.floor(visibleHeight / 2))
136
+ } else return
137
+ break
138
+ // Ctrl+u — half page up
139
+ case 'u':
140
+ if (key.ctrl) {
141
+ yOffset = Math.max(0, yOffset - Math.floor(visibleHeight / 2))
142
+ } else {
143
+ return
144
+ }
145
+ break
146
+ // Ctrl+f — full page down
147
+ case 'f':
148
+ if (key.ctrl) {
149
+ yOffset = Math.min(maxYOffset, yOffset + visibleHeight)
150
+ } else {
151
+ return
152
+ }
153
+ break
154
+ // Ctrl+b — full page up
155
+ case 'b':
156
+ if (key.ctrl) {
157
+ yOffset = Math.max(0, yOffset - visibleHeight)
158
+ } else {
159
+ return
160
+ }
161
+ break
162
+
163
+ // 0 — scroll to leftmost
164
+ case '0':
165
+ xOffset = 0
166
+ break
167
+ // $ — scroll to rightmost
168
+ case '4': // shift+4 = $
169
+ if (key.shift) {
170
+ xOffset = maxXOffset
171
+ } else {
172
+ return
173
+ }
174
+ break
175
+
176
+ default:
177
+ return
178
+ }
179
+
180
+ render()
181
+ })
182
+
183
+ render()
184
+
185
+ function render() {
186
+ console.clear()
187
+
188
+ const { needsScrollbarX, needsScrollbarY } = computeScrollbarVisibility()
189
+
190
+ if (!needsScrollbarX && !needsScrollbarY) {
191
+ process.stdout.write(consoleTableOutput)
192
+
193
+ return
194
+ }
195
+
196
+ const scrollbarX = renderScrollbarX(needsScrollbarX, needsScrollbarY)
197
+ const scrollbarY = renderScrollbarY(needsScrollbarX, needsScrollbarY).split(
198
+ '',
199
+ )
200
+
201
+ const consoleTableOutputSegment = consoleTableOutputSplit
202
+ .slice(yOffset, yOffset + consoleHeight - (scrollbarX ? 1 : 0))
203
+ .map(
204
+ (line, index) =>
205
+ line.slice(
206
+ xOffset,
207
+ xOffset + consoleWidth - (scrollbarY[index] ? 1 : 0),
208
+ ) + (scrollbarY[index] || ''),
209
+ )
210
+ .join('\n')
211
+
212
+ process.stdout.write(
213
+ consoleTableOutputSegment + (scrollbarX ? '\n' + scrollbarX : ''),
214
+ )
215
+ }
216
+
217
+ function computeScrollbarVisibility() {
218
+ let needsScrollbarX = consoleTableOutputWidth > consoleWidth
219
+ let needsScrollbarY = consoleTableOutputHeight > consoleHeight
220
+
221
+ // A scrollbar takes 1 row/column of space, which may cause
222
+ // the other scrollbar to become necessary
223
+ if (needsScrollbarY && !needsScrollbarX) {
224
+ needsScrollbarX = consoleTableOutputWidth > consoleWidth - 1
225
+ }
226
+
227
+ if (needsScrollbarX && !needsScrollbarY) {
228
+ needsScrollbarY = consoleTableOutputHeight > consoleHeight - 1
229
+ }
230
+
231
+ return {
232
+ needsScrollbarX,
233
+ needsScrollbarY,
234
+ }
235
+ }
236
+
237
+ /**
238
+ * @param {boolean} needsScrollbarX
239
+ * @param {boolean} needsScrollbarY
240
+ * @returns {string}
241
+ */
242
+ function renderScrollbarX(needsScrollbarX, needsScrollbarY) {
243
+ if (!needsScrollbarX) return ''
244
+
245
+ const visibleWidth = consoleWidth - (needsScrollbarY ? 1 : 0)
246
+ const trackLength = visibleWidth - 2
247
+
248
+ const thumbSize = Math.max(
249
+ 1,
250
+ Math.floor((visibleWidth / consoleTableOutputWidth) * trackLength),
251
+ )
252
+
253
+ const maxThumbPos = trackLength - thumbSize
254
+
255
+ const thumbPos =
256
+ Math.floor(
257
+ (xOffset / (consoleTableOutputWidth - visibleWidth)) * maxThumbPos,
258
+ ) || 0
259
+
260
+ return (
261
+ scrollbarEndChars.left +
262
+ '-'.repeat(thumbPos) +
263
+ '═'.repeat(thumbSize) +
264
+ '-'.repeat(trackLength - thumbPos - thumbSize) +
265
+ scrollbarEndChars.right +
266
+ (needsScrollbarY ? ' ' : '')
267
+ )
268
+ }
269
+
270
+ /**
271
+ * @param {boolean} needsScrollbarX
272
+ * @param {boolean} needsScrollbarY
273
+ * @returns {string}
274
+ */
275
+ function renderScrollbarY(needsScrollbarX, needsScrollbarY) {
276
+ if (!needsScrollbarY) return ''
277
+
278
+ const visibleHeight = consoleHeight - (needsScrollbarX ? 1 : 0)
279
+ const trackLength = visibleHeight - 2
280
+
281
+ const thumbSize = Math.max(
282
+ 1,
283
+ Math.floor((visibleHeight / consoleTableOutputHeight) * trackLength),
284
+ )
285
+
286
+ const maxThumbPos = trackLength - thumbSize
287
+
288
+ const thumbPos =
289
+ Math.floor(
290
+ (yOffset / (consoleTableOutputHeight - visibleHeight)) * maxThumbPos,
291
+ ) || 0
292
+
293
+ return (
294
+ scrollbarEndChars.up +
295
+ '╎'.repeat(thumbPos) +
296
+ '║'.repeat(thumbSize) +
297
+ '╎'.repeat(trackLength - thumbPos - thumbSize) +
298
+ scrollbarEndChars.down
299
+ )
300
+ }
301
+
302
+ /**
303
+ * @param {Parameters<typeof console.table>} args
304
+ * @returns {string}
305
+ */
306
+ function captureConsoleTableOutput(...args) {
307
+ let consoleTableOutput = ''
308
+
309
+ const originalWrite = process.stdout.write.bind(process.stdout)
310
+
311
+ process.stdout.write = (chunk) => {
312
+ consoleTableOutput += chunk
313
+
314
+ return true
315
+ }
316
+
317
+ console.table(...args)
318
+
319
+ process.stdout.write = originalWrite
320
+
321
+ return removeIndexColumn(consoleTableOutput.replace(/\x1B\[[0-9;]*m/g, ''))
322
+ }
323
+
324
+ /**
325
+ * Removes the `| (index) |` column from `console.table` output
326
+ * @param {string} consoleTableOutput
327
+ * @returns {string}
328
+ */
329
+ function removeIndexColumn(consoleTableOutput) {
330
+ const consoleTableOutputSplit = consoleTableOutput.trim().split('\n')
331
+
332
+ const lastRowIndex = consoleTableOutputSplit.length - 1
333
+
334
+ const secondPipeIndex = consoleTableOutputSplit[1].indexOf('│', 1)
335
+
336
+ return consoleTableOutputSplit
337
+ .map((row, rowIndex) => {
338
+ const consoleTableStartChar =
339
+ consoleTableStartChars[rowIndex] ||
340
+ (rowIndex === lastRowIndex ? '└' : '')
341
+
342
+ if (!consoleTableStartChar) {
343
+ return row.slice(secondPipeIndex)
344
+ }
345
+
346
+ return `${consoleTableStartChar}${row.slice(secondPipeIndex + 1)}`
347
+ })
348
+ .join('\n')
349
+ }
350
+
351
+ /**
352
+ * @returns {Promise<object[]>}
353
+ */
354
+ async function getTableDataFromStdin() {
355
+ let tableDataString = ''
356
+
357
+ for await (const chunk of process.stdin) {
358
+ tableDataString += chunk
359
+ }
360
+
361
+ try {
362
+ return JSON.parse(tableDataString)
363
+ } catch {
364
+ console.error('Error: Invalid JSON input')
365
+ process.exit(1)
366
+ }
367
+ }
package/package.json CHANGED
@@ -1,4 +1,16 @@
1
1
  {
2
2
  "name": "table-viewer",
3
- "version": "0.0.0"
3
+ "version": "0.0.2",
4
+ "description": "A keyboard-navigable terminal table viewer",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "bin": "./index.js",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/aaronccasanova/table-viewer.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/aaronccasanova/table-viewer/issues"
14
+ },
15
+ "homepage": "https://github.com/aaronccasanova/table-viewer"
4
16
  }