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.
Files changed (3) hide show
  1. package/README.md +55 -0
  2. package/index.js +359 -0
  3. 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
+ }
package/package.json CHANGED
@@ -1,4 +1,8 @@
1
1
  {
2
2
  "name": "table-viewer",
3
- "version": "0.0.0"
3
+ "version": "0.0.1",
4
+ "description": "A keyboard-navigable terminal table viewer",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "bin": "./index.js"
4
8
  }