@profullstack/readm3 0.20.0 → 0.24.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/dist/viewer.js CHANGED
@@ -5,11 +5,16 @@
5
5
  * `markdown.ts`; editing shows the same buffer raw, with a caret. Both read the
6
6
  * same text, so leaving edit mode is all it takes to see the change rendered —
7
7
  * there is no separate preview to keep in step.
8
+ *
9
+ * A CSV or TSV reads as a grid instead (src/csv.ts parses it, hqtui's data grid
10
+ * draws it): a cell cursor, sort by column, a row filter, a row sheet and column
11
+ * statistics, the way tabiew does it. Editing it is the same raw editor.
8
12
  */
9
13
  import { readFileSync, statSync, writeFileSync } from "node:fs";
10
14
  import { basename, dirname, resolve } from "node:path";
11
15
  import { createApp, stringWidth } from "@profullstack/hqtui";
12
16
  import { backspace, breakRun, createEditor, del, indent, insert, killToLineEnd, killToLineStart, markSaved, moveDocEnd, moveDocStart, moveDown, moveEnd, moveHome, moveLeft, moveRight, moveUp, moveVertical, moveWordLeft, moveWordRight, newline, redo, text as bufferText, undo, } from "./editor.js";
17
+ import { alignOf, columnStats, delimiterName, displayCell, isTableName, nextSort, parseTable, statsRows, viewRows, } from "./csv.js";
13
18
  import { DEFAULT_FLAVOR } from "./flavors.js";
14
19
  import { renderMarkdown } from "./markdown.js";
15
20
  import { colorOf } from "./roles.js";
@@ -50,6 +55,24 @@ export async function run(options) {
50
55
  let scroll = 0;
51
56
  /** Bumped on every edit, so the rendered document is rebuilt exactly then. */
52
57
  let stamp = 0;
58
+ // ---- the table view, for a CSV or TSV
59
+ /** The open file parsed, rebuilt when the text changes. */
60
+ let grid = null;
61
+ /** Row indices shown: filtered, then sorted. */
62
+ let gridRows = [];
63
+ let gridKey = "";
64
+ let rowFilter = "";
65
+ let rowFiltering = false;
66
+ let sort = null;
67
+ let cursor = { row: 0, col: 0 };
68
+ let gridOffset = { row: 0, col: 0 };
69
+ /** Body rows that fit, as the grid last measured them; page keys move by this. */
70
+ let gridPage = 10;
71
+ /** Show the table's text rather than the grid. */
72
+ let rawTable = false;
73
+ /** The cursor row as a sheet, or the cursor column's statistics. */
74
+ let sheet = false;
75
+ let stats = false;
53
76
  /** A yes/no the next keypress answers. */
54
77
  let pending = null;
55
78
  let sidebar = options.sidebar ?? 0;
@@ -83,6 +106,14 @@ export async function run(options) {
83
106
  buffer = createEditor(readFileSync(path, "utf8"));
84
107
  openPath = path;
85
108
  doc = null;
109
+ grid = null;
110
+ gridKey = "";
111
+ rowFilter = "";
112
+ rowFiltering = false;
113
+ sort = null;
114
+ cursor = { row: 0, col: 0 };
115
+ gridOffset = { row: 0, col: 0 };
116
+ rawTable = false;
86
117
  scroll = 0;
87
118
  editTop = 0;
88
119
  editLeft = 0;
@@ -125,6 +156,105 @@ export async function run(options) {
125
156
  breakRun(buffer);
126
157
  };
127
158
  const maxScroll = () => Math.max(0, (doc?.lines.length ?? 0) - viewport.h);
159
+ /** Whether the open file is drawn as a grid right now. */
160
+ const tableMode = () => !!openPath && isTableName(openPath) && !rawTable && mode === "read";
161
+ const currentTable = () => {
162
+ if (!openPath || !buffer || !isTableName(openPath))
163
+ return null;
164
+ if (!grid || grid.stamp !== stamp || grid.path !== openPath) {
165
+ grid = { stamp, path: openPath, table: parseTable(bufferText(buffer), { name: openPath }) };
166
+ gridKey = "";
167
+ }
168
+ return grid.table;
169
+ };
170
+ /** The rows shown, recomputed only when the text, the filter or the sort changed. */
171
+ const shownRows = () => {
172
+ const table = currentTable();
173
+ if (!table)
174
+ return [];
175
+ const key = JSON.stringify([grid?.stamp, openPath, rowFilter, sort]);
176
+ if (key !== gridKey) {
177
+ gridRows = viewRows(table, { filter: rowFilter, sort });
178
+ gridKey = key;
179
+ }
180
+ cursor.row = clamp(cursor.row, 0, Math.max(0, gridRows.length - 1));
181
+ cursor.col = clamp(cursor.col, 0, Math.max(0, table.columns.length - 1));
182
+ return gridRows;
183
+ };
184
+ /** The table's text inside a fence, so `v` shows it raw through the same renderer. */
185
+ const fenced = (text) => {
186
+ const longest = Math.max(2, ...(text.match(/`+/g) ?? []).map((run) => run.length));
187
+ const fence = "`".repeat(longest + 1);
188
+ return `${fence}csv\n${text}${text.endsWith("\n") ? "" : "\n"}${fence}\n`;
189
+ };
190
+ /** Keys while the grid has focus. */
191
+ const tableKey = (key) => {
192
+ const rows = shownRows();
193
+ const table = currentTable();
194
+ if (!table)
195
+ return;
196
+ const last = Math.max(0, rows.length - 1);
197
+ const page = Math.max(1, gridPage - 1);
198
+ switch (key) {
199
+ case "up":
200
+ case "k":
201
+ cursor.row = clamp(cursor.row - 1, 0, last);
202
+ return;
203
+ case "down":
204
+ case "j":
205
+ cursor.row = clamp(cursor.row + 1, 0, last);
206
+ return;
207
+ case "pageup":
208
+ case "b":
209
+ cursor.row = clamp(cursor.row - page, 0, last);
210
+ return;
211
+ case "pagedown":
212
+ case "space":
213
+ cursor.row = clamp(cursor.row + page, 0, last);
214
+ return;
215
+ case "home":
216
+ case "g":
217
+ cursor.row = 0;
218
+ return;
219
+ case "end":
220
+ case "G":
221
+ case "shift+g":
222
+ cursor.row = last;
223
+ return;
224
+ case "right":
225
+ case "l":
226
+ cursor.col = clamp(cursor.col + 1, 0, table.columns.length - 1);
227
+ return;
228
+ case "left":
229
+ case "h":
230
+ if (cursor.col === 0)
231
+ pane = "tree";
232
+ else
233
+ cursor.col--;
234
+ return;
235
+ case "0":
236
+ case "^":
237
+ cursor.col = 0;
238
+ return;
239
+ case "$":
240
+ cursor.col = table.columns.length - 1;
241
+ return;
242
+ case "o":
243
+ // A new order is read from its top, as in a spreadsheet.
244
+ sort = nextSort(sort, cursor.col);
245
+ cursor.row = 0;
246
+ return;
247
+ case "enter":
248
+ if (rows.length)
249
+ sheet = true;
250
+ return;
251
+ case "c":
252
+ stats = true;
253
+ return;
254
+ default:
255
+ return;
256
+ }
257
+ };
128
258
  const moveSelection = (delta) => {
129
259
  if (flat.length === 0)
130
260
  return;
@@ -277,8 +407,10 @@ export async function run(options) {
277
407
  app.invalidate();
278
408
  return;
279
409
  }
280
- if (help) {
410
+ if (help || sheet || stats) {
281
411
  help = false;
412
+ sheet = false;
413
+ stats = false;
282
414
  app.invalidate();
283
415
  return;
284
416
  }
@@ -287,6 +419,27 @@ export async function run(options) {
287
419
  app.invalidate();
288
420
  return;
289
421
  }
422
+ if (rowFiltering) {
423
+ if (key === "escape") {
424
+ rowFiltering = false;
425
+ rowFilter = "";
426
+ }
427
+ else if (name === "enter") {
428
+ rowFiltering = false;
429
+ }
430
+ else if (name === "backspace") {
431
+ rowFilter = rowFilter.slice(0, -1);
432
+ }
433
+ else if (name === "up" || name === "down") {
434
+ tableKey(name);
435
+ }
436
+ else if (char && !event.ctrl && !event.alt) {
437
+ rowFilter += char;
438
+ cursor.row = 0;
439
+ }
440
+ app.invalidate();
441
+ return;
442
+ }
290
443
  if (filtering) {
291
444
  if (key === "escape") {
292
445
  filtering = false;
@@ -358,8 +511,21 @@ export async function run(options) {
358
511
  return;
359
512
  }
360
513
  case "/":
361
- filtering = true;
362
- pane = "tree";
514
+ // Over a grid, `/` filters its rows; anywhere else, the file list.
515
+ if (pane === "view" && tableMode())
516
+ rowFiltering = true;
517
+ else {
518
+ filtering = true;
519
+ pane = "tree";
520
+ }
521
+ app.invalidate();
522
+ return;
523
+ case "v":
524
+ if (openPath && isTableName(openPath)) {
525
+ rawTable = !rawTable;
526
+ doc = null;
527
+ pane = "view";
528
+ }
363
529
  app.invalidate();
364
530
  return;
365
531
  case "[":
@@ -371,7 +537,10 @@ export async function run(options) {
371
537
  app.invalidate();
372
538
  return;
373
539
  case "escape":
374
- if (filter) {
540
+ if (pane === "view" && tableMode() && rowFilter) {
541
+ rowFilter = "";
542
+ }
543
+ else if (filter) {
375
544
  filter = "";
376
545
  rebuild(openPath);
377
546
  }
@@ -437,6 +606,11 @@ export async function run(options) {
437
606
  app.invalidate();
438
607
  return;
439
608
  }
609
+ if (tableMode()) {
610
+ tableKey(key);
611
+ app.invalidate();
612
+ return;
613
+ }
440
614
  const page = Math.max(1, viewport.h - 2);
441
615
  switch (key) {
442
616
  case "up":
@@ -480,6 +654,9 @@ export async function run(options) {
480
654
  const openRel = openPath ? pathLabel(root, openPath) : "";
481
655
  const total = doc?.lines.length ?? 0;
482
656
  const percent = total <= viewport.h ? 100 : Math.round((Math.min(scroll + viewport.h, total) / total) * 100);
657
+ const gridding = tableMode();
658
+ const table = gridding ? currentTable() : null;
659
+ const rows = gridding ? shownRows() : [];
483
660
  ui.column({}, (screen) => {
484
661
  screen.row({ size: "fill" }, (panes) => {
485
662
  // ------------------------------------------------------- browser
@@ -499,7 +676,7 @@ export async function run(options) {
499
676
  treeOffset = selected - capacity + 1;
500
677
  treeOffset = clamp(treeOffset, 0, Math.max(0, flat.length - capacity));
501
678
  if (flat.length === 0) {
502
- p.text(filter ? "No match." : "No markdown here.", { fg: theme.muted });
679
+ p.text(filter ? "No match." : "No markdown or CSV here.", { fg: theme.muted });
503
680
  return;
504
681
  }
505
682
  p.list({
@@ -540,12 +717,22 @@ export async function run(options) {
540
717
  panes.panel({
541
718
  title: openRel ? ` ${dirty() ? "● " : ""}${openRel} ` : " readm3 ",
542
719
  titleColor: editing ? theme.warning : theme.title,
543
- subtitle: openPath ? (editing ? "EDIT" : `${percent}%`) : undefined,
720
+ subtitle: openPath
721
+ ? editing
722
+ ? "EDIT"
723
+ : table
724
+ ? `${rows.length === table.rows.length ? "" : `${rows.length.toLocaleString()} of `}${table.rows.length.toLocaleString()} rows · ${table.columns.length} cols · ${delimiterName(table.delimiter)}`
725
+ : `${percent}%`
726
+ : undefined,
544
727
  footer: openPath && editing && buffer
545
728
  ? `${buffer.row + 1}:${buffer.col + 1}`
546
- : openPath && total > 0
547
- ? `${Math.min(scroll + viewport.h, total)}/${total}`
548
- : undefined,
729
+ : table
730
+ ? rows.length
731
+ ? `${cursor.row + 1}/${rows.length} · ${table.columns[cursor.col]?.name ?? ""}`
732
+ : "no rows"
733
+ : openPath && total > 0
734
+ ? `${Math.min(scroll + viewport.h, total)}/${total}`
735
+ : undefined,
549
736
  borderColor: editing ? theme.warning : pane === "view" ? theme.borderFocused : theme.border,
550
737
  padding: [0, 1],
551
738
  }, (p) => {
@@ -581,13 +768,62 @@ export async function run(options) {
581
768
  drawEditor(p, buffer, theme, w, h);
582
769
  return;
583
770
  }
771
+ if (table) {
772
+ if (rowFiltering || rowFilter) {
773
+ p.row({ height: 1 }, (r) => {
774
+ r.text(`/${rowFilter}${rowFiltering ? "▏" : ""}`, { width: stringWidth(rowFilter) + 2, fg: theme.warning });
775
+ r.text(rowFiltering ? " col:text col>n col=v !term" : " esc clears", { fg: theme.muted });
776
+ });
777
+ }
778
+ if (table.columns.length === 0) {
779
+ p.text("This file has no rows.", { fg: theme.muted });
780
+ return;
781
+ }
782
+ p.dataGrid({
783
+ columns: table.columns.map((column) => ({ title: column.name, align: alignOf(column.type) })),
784
+ rows: table.rows,
785
+ order: rows,
786
+ selectedRow: rows.length ? cursor.row : -1,
787
+ selectedColumn: cursor.col,
788
+ rowOffset: gridOffset.row,
789
+ columnOffset: gridOffset.col,
790
+ sort,
791
+ zebra: true,
792
+ scrollbar: true,
793
+ onLayout: (frame) => {
794
+ gridOffset = { row: frame.rowOffset, col: frame.columnOffset };
795
+ gridPage = Math.max(1, frame.capacity);
796
+ },
797
+ onScroll: (delta) => {
798
+ cursor.row = clamp(cursor.row + delta * 3, 0, Math.max(0, rows.length - 1));
799
+ pane = "view";
800
+ app.invalidate();
801
+ },
802
+ onSelectCell: (row, column) => {
803
+ cursor = { row, col: column >= 0 ? column : cursor.col };
804
+ pane = "view";
805
+ app.invalidate();
806
+ },
807
+ onActivateCell: () => {
808
+ sheet = true;
809
+ app.invalidate();
810
+ },
811
+ onHeaderClick: (column) => {
812
+ cursor.col = column;
813
+ tableKey("o");
814
+ pane = "view";
815
+ app.invalidate();
816
+ },
817
+ });
818
+ return;
819
+ }
584
820
  if (!doc || doc.path !== openPath || doc.width !== w || doc.stamp !== stamp || doc.spoilers !== spoilers) {
585
821
  doc = {
586
822
  path: openPath,
587
823
  width: w,
588
824
  stamp,
589
825
  spoilers,
590
- lines: renderMarkdown(bufferText(buffer), w, { flavor, spoilers }),
826
+ lines: renderMarkdown(isTableName(openPath) ? fenced(bufferText(buffer)) : bufferText(buffer), w, { flavor, spoilers }),
591
827
  };
592
828
  }
593
829
  scroll = clamp(scroll, 0, Math.max(0, doc.lines.length - h));
@@ -634,31 +870,49 @@ export async function run(options) {
634
870
  { key: "^k", label: "kill" },
635
871
  { key: "^g", label: "keys" },
636
872
  ]
637
- : filtering
873
+ : rowFiltering
638
874
  ? [
639
- { key: "type", label: "filter" },
640
- { key: "↵", label: "open" },
875
+ { key: "type", label: "filter rows" },
876
+ { key: "↵", label: "keep" },
641
877
  { key: "esc", label: "clear" },
642
878
  ]
643
- : pane === "tree"
879
+ : filtering
644
880
  ? [
645
- { key: "↑↓", label: "move" },
881
+ { key: "type", label: "filter" },
646
882
  { key: "↵", label: "open" },
647
- { key: "←→", label: "fold" },
648
- { key: "/", label: "find" },
649
- { key: "tab", label: "reader" },
650
- { key: "?", label: "keys" },
651
- { key: "q", label: "quit" },
883
+ { key: "esc", label: "clear" },
652
884
  ]
653
- : [
654
- { key: "↑↓", label: "scroll" },
655
- { key: "spc", label: "page" },
656
- { key: "e", label: "edit" },
657
- { key: "g/G", label: "ends" },
658
- { key: "tab", label: "files" },
659
- { key: "?", label: "keys" },
660
- { key: "q", label: "quit" },
661
- ],
885
+ : pane === "view" && gridding
886
+ ? [
887
+ { key: "←↑↓→", label: "move" },
888
+ { key: "o", label: "sort" },
889
+ { key: "/", label: "filter" },
890
+ { key: "↵", label: "row" },
891
+ { key: "c", label: "stats" },
892
+ { key: "v", label: "text" },
893
+ { key: "e", label: "edit" },
894
+ { key: "?", label: "keys" },
895
+ { key: "q", label: "quit" },
896
+ ]
897
+ : pane === "tree"
898
+ ? [
899
+ { key: "↑↓", label: "move" },
900
+ { key: "↵", label: "open" },
901
+ { key: "←→", label: "fold" },
902
+ { key: "/", label: "find" },
903
+ { key: "tab", label: "reader" },
904
+ { key: "?", label: "keys" },
905
+ { key: "q", label: "quit" },
906
+ ]
907
+ : [
908
+ { key: "↑↓", label: "scroll" },
909
+ { key: "spc", label: "page" },
910
+ { key: "e", label: "edit" },
911
+ { key: "g/G", label: "ends" },
912
+ { key: "tab", label: "files" },
913
+ { key: "?", label: "keys" },
914
+ { key: "q", label: "quit" },
915
+ ],
662
916
  right: [
663
917
  { label: openPath ? (dirname(openRel) === "." ? basename(root) : dirname(openRel)) : basename(root) },
664
918
  { label: flavor, color: theme.muted },
@@ -674,38 +928,74 @@ export async function run(options) {
674
928
  m.spacer("fill");
675
929
  });
676
930
  }
931
+ else if (sheet && table && rows.length) {
932
+ const record = table.rows[rows[cursor.row]] ?? [];
933
+ const height = clamp(table.columns.length + 6, 8, Math.max(8, app.height - 2));
934
+ const labelWidth = clamp(Math.max(...table.columns.map((c) => stringWidth(c.name))) + 2, 8, 28);
935
+ const valueWidth = Math.max(...record.map((cell) => stringWidth(displayCell(cell))), 12);
936
+ // As wide as the row needs, so a short value sits beside its name, not across the screen.
937
+ const width = clamp(labelWidth + valueWidth + 6, 40, Math.max(40, app.width - 8));
938
+ screen.modal({ title: ` row ${(rows[cursor.row] ?? 0) + 1} `, width, height }, (m) => {
939
+ m.keyValues(table.columns.map((column, i) => ({ label: column.name, value: displayCell(record[i] ?? "") })), { labelColor: theme.accent, labelWidth });
940
+ m.spacer("fill");
941
+ m.text("Any key closes.", { fg: theme.muted, align: "center" });
942
+ });
943
+ }
944
+ else if (stats && table) {
945
+ const lines = statsRows(columnStats(table, cursor.col, rows));
946
+ screen.modal({ title: ` ${table.columns[cursor.col]?.name ?? ""} `, width: 56, height: clamp(lines.length + 6, 10, Math.max(10, app.height - 2)) }, (m) => {
947
+ m.keyValues(lines.map((line) => ({ label: line.label, value: displayCell(line.value) })), { labelColor: theme.accent, labelWidth: 12 });
948
+ m.spacer("fill");
949
+ m.text(rowFilter ? "Over the filtered rows. Any key closes." : "Any key closes.", { fg: theme.muted, align: "center" });
950
+ });
951
+ }
677
952
  else if (help) {
678
953
  screen.modal({ title: " readm3 — keys ", width: 56, height: 24 }, (m) => {
679
- m.keyValues(mode === "edit"
954
+ m.keyValues(mode !== "edit" && gridding
680
955
  ? [
681
- { label: "esc", value: "back to the preview" },
682
- { label: "ctrl+g", value: "this help" },
683
- { label: "ctrl+s", value: "save" },
684
- { label: "ctrl+z / ctrl+y", value: "undo / redo" },
685
- { label: "ctrl+k / ctrl+u", value: "kill to end / start" },
686
- { label: "ctrl+a / ctrl+e", value: "line start / end" },
687
- { label: "ctrl+← →", value: "by word" },
688
- { label: "ctrl+home/end", value: "top / bottom" },
689
- { label: "enter", value: "split, continuing a list" },
690
- { label: "tab", value: "indent two spaces" },
691
- ]
692
- : [
693
- { label: "↑ ↓ j k", value: "move / scroll" },
694
- { label: "→ ← l h", value: "expand / collapse" },
695
- { label: "enter", value: "open file" },
696
- { label: "e / i", value: "edit this file" },
697
- { label: "ctrl+s", value: "save" },
956
+ { label: "← ↑ ↓ → h j k l", value: "move the cell cursor" },
957
+ { label: "0 / $", value: "first / last column" },
958
+ { label: "g / G space b", value: "top / bottom, page" },
959
+ { label: "o", value: "sort by column ▲ ▼ off" },
960
+ { label: "/", value: "filter: text col:x col>n !x" },
961
+ { label: "enter", value: "the row as a sheet" },
962
+ { label: "c", value: "column statistics" },
963
+ { label: "v", value: "grid / raw text" },
964
+ { label: "e / i", value: "edit the file" },
965
+ { label: "esc", value: "clear the filter" },
698
966
  { label: "tab", value: "switch pane" },
699
- { label: "space / b", value: "page down / up" },
700
- { label: "g / G", value: "top / bottom" },
701
- { label: "/", value: "filter files" },
702
- { label: "s", value: "reveal spoilers" },
703
- { label: "esc", value: "clear filter" },
704
- { label: "r", value: "rescan and reload" },
705
- { label: "[ ]", value: "sidebar width" },
706
- { label: "?", value: "this help" },
707
967
  { label: "q / ctrl+c", value: "quit" },
708
- ], { labelColor: theme.accent, labelWidth: 16 });
968
+ ]
969
+ : mode === "edit"
970
+ ? [
971
+ { label: "esc", value: "back to the preview" },
972
+ { label: "ctrl+g", value: "this help" },
973
+ { label: "ctrl+s", value: "save" },
974
+ { label: "ctrl+z / ctrl+y", value: "undo / redo" },
975
+ { label: "ctrl+k / ctrl+u", value: "kill to end / start" },
976
+ { label: "ctrl+a / ctrl+e", value: "line start / end" },
977
+ { label: "ctrl+← →", value: "by word" },
978
+ { label: "ctrl+home/end", value: "top / bottom" },
979
+ { label: "enter", value: "split, continuing a list" },
980
+ { label: "tab", value: "indent two spaces" },
981
+ ]
982
+ : [
983
+ { label: "↑ ↓ j k", value: "move / scroll" },
984
+ { label: "→ ← l h", value: "expand / collapse" },
985
+ { label: "enter", value: "open file" },
986
+ { label: "e / i", value: "edit this file" },
987
+ { label: "ctrl+s", value: "save" },
988
+ { label: "tab", value: "switch pane" },
989
+ { label: "space / b", value: "page down / up" },
990
+ { label: "g / G", value: "top / bottom" },
991
+ { label: "/", value: "filter files" },
992
+ { label: "s", value: "reveal spoilers" },
993
+ { label: "esc", value: "clear filter" },
994
+ { label: "r", value: "rescan and reload" },
995
+ { label: "[ ]", value: "sidebar width" },
996
+ { label: "?", value: "this help" },
997
+ { label: "q / ctrl+c", value: "quit" },
998
+ ], { labelColor: theme.accent, labelWidth: 16 });
709
999
  m.spacer(1);
710
1000
  m.text("Any key closes.", { fg: theme.muted, align: "center" });
711
1001
  });