@profullstack/readm3 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/readm3.mjs +7 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +146 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +35 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +568 -0
- package/dist/markdown.js.map +1 -0
- package/dist/tree.d.ts +48 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +172 -0
- package/dist/tree.js.map +1 -0
- package/dist/viewer.d.ts +13 -0
- package/dist/viewer.d.ts.map +1 -0
- package/dist/viewer.js +477 -0
- package/dist/viewer.js.map +1 -0
- package/package.json +68 -0
package/dist/tree.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file browser's model: a pruned tree of markdown files under a root.
|
|
3
|
+
*
|
|
4
|
+
* Directories with no markdown anywhere beneath them are dropped, so the pane
|
|
5
|
+
* shows a map of the documentation rather than a map of the repository.
|
|
6
|
+
*/
|
|
7
|
+
import { readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { basename, join, relative, sep } from "node:path";
|
|
9
|
+
export const MARKDOWN = /\.(?:md|markdown|mdown|mkd|mdx)$/i;
|
|
10
|
+
export const DEFAULT_IGNORE = [
|
|
11
|
+
"node_modules",
|
|
12
|
+
".git",
|
|
13
|
+
".svn",
|
|
14
|
+
".hg",
|
|
15
|
+
"dist",
|
|
16
|
+
"build",
|
|
17
|
+
"out",
|
|
18
|
+
"target",
|
|
19
|
+
"vendor",
|
|
20
|
+
"coverage",
|
|
21
|
+
".next",
|
|
22
|
+
".nuxt",
|
|
23
|
+
".turbo",
|
|
24
|
+
".cache",
|
|
25
|
+
".venv",
|
|
26
|
+
"venv",
|
|
27
|
+
"__pycache__",
|
|
28
|
+
".pnpm-store",
|
|
29
|
+
];
|
|
30
|
+
/** README first, then the rest alphabetically; directories lead. */
|
|
31
|
+
function order(a, b) {
|
|
32
|
+
if (a.dir !== b.dir)
|
|
33
|
+
return a.dir ? -1 : 1;
|
|
34
|
+
if (!a.dir) {
|
|
35
|
+
const ra = /^readme\b/i.test(a.name);
|
|
36
|
+
const rb = /^readme\b/i.test(b.name);
|
|
37
|
+
if (ra !== rb)
|
|
38
|
+
return ra ? -1 : 1;
|
|
39
|
+
}
|
|
40
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
|
41
|
+
}
|
|
42
|
+
export function scan(root, options = {}) {
|
|
43
|
+
const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
|
|
44
|
+
const maxDepth = options.maxDepth ?? 12;
|
|
45
|
+
const budget = { left: options.maxEntries ?? 5000 };
|
|
46
|
+
const walk = (dir, depth) => {
|
|
47
|
+
if (depth > maxDepth || budget.left <= 0)
|
|
48
|
+
return [];
|
|
49
|
+
let names;
|
|
50
|
+
try {
|
|
51
|
+
names = readdirSync(dir);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const entries = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
if (budget.left <= 0)
|
|
59
|
+
break;
|
|
60
|
+
if (!options.all && name.startsWith("."))
|
|
61
|
+
continue;
|
|
62
|
+
if (ignore.has(name))
|
|
63
|
+
continue;
|
|
64
|
+
const path = join(dir, name);
|
|
65
|
+
let isDir;
|
|
66
|
+
try {
|
|
67
|
+
isDir = statSync(path).isDirectory();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (isDir) {
|
|
73
|
+
const children = walk(path, depth + 1);
|
|
74
|
+
if (children.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
budget.left--;
|
|
77
|
+
entries.push({ name, path, dir: true, children, expanded: depth < 1 });
|
|
78
|
+
}
|
|
79
|
+
else if (MARKDOWN.test(name)) {
|
|
80
|
+
budget.left--;
|
|
81
|
+
entries.push({ name, path, dir: false, children: [], expanded: false });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return entries.sort(order);
|
|
85
|
+
};
|
|
86
|
+
return walk(root, 0);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Depth-first order of what is currently visible. Matches HQTUI's own tree
|
|
90
|
+
* flattening, so a selected index means the same thing to both.
|
|
91
|
+
*/
|
|
92
|
+
export function flatten(entries, depth = 0, parent = -1, out = []) {
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const index = out.length;
|
|
95
|
+
out.push({ entry, depth, parent });
|
|
96
|
+
if (entry.dir && entry.expanded)
|
|
97
|
+
flatten(entry.children, depth + 1, index, out);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Every markdown file in the tree, in display order. */
|
|
102
|
+
export function files(entries, out = []) {
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.dir)
|
|
105
|
+
files(entry.children, out);
|
|
106
|
+
else
|
|
107
|
+
out.push(entry);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A copy of the tree keeping only files whose path matches `query`, with every
|
|
113
|
+
* surviving directory expanded. Matching is a case-insensitive subsequence, so
|
|
114
|
+
* "gsg" finds "getting-started-guide.md".
|
|
115
|
+
*/
|
|
116
|
+
export function filterTree(entries, query, root) {
|
|
117
|
+
const q = query.trim().toLowerCase();
|
|
118
|
+
if (!q)
|
|
119
|
+
return entries;
|
|
120
|
+
const keep = (list) => {
|
|
121
|
+
const result = [];
|
|
122
|
+
for (const entry of list) {
|
|
123
|
+
if (entry.dir) {
|
|
124
|
+
const children = keep(entry.children);
|
|
125
|
+
if (children.length > 0)
|
|
126
|
+
result.push({ ...entry, children, expanded: true });
|
|
127
|
+
}
|
|
128
|
+
else if (matches(relative(root, entry.path), q)) {
|
|
129
|
+
result.push(entry);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
};
|
|
134
|
+
return keep(entries);
|
|
135
|
+
}
|
|
136
|
+
/** Case-insensitive subsequence match, with a substring hit always winning. */
|
|
137
|
+
export function matches(haystack, needle) {
|
|
138
|
+
const h = haystack.toLowerCase();
|
|
139
|
+
const n = needle.toLowerCase();
|
|
140
|
+
if (h.includes(n))
|
|
141
|
+
return true;
|
|
142
|
+
let i = 0;
|
|
143
|
+
for (const ch of n) {
|
|
144
|
+
const at = h.indexOf(ch, i);
|
|
145
|
+
if (at < 0)
|
|
146
|
+
return false;
|
|
147
|
+
i = at + 1;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
/** Expand every directory on the way to `path`, so a file can be revealed. */
|
|
152
|
+
export function reveal(entries, path) {
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (entry.path === path)
|
|
155
|
+
return true;
|
|
156
|
+
if (entry.dir && path.startsWith(entry.path + sep) && reveal(entry.children, path)) {
|
|
157
|
+
entry.expanded = true;
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
/** Index of `path` in the flattened list, or -1. */
|
|
164
|
+
export function indexOfPath(flat, path) {
|
|
165
|
+
return flat.findIndex((f) => f.entry.path === path);
|
|
166
|
+
}
|
|
167
|
+
/** A short label for the header: the path relative to the root. */
|
|
168
|
+
export function label(root, path) {
|
|
169
|
+
const rel = relative(root, path);
|
|
170
|
+
return rel && !rel.startsWith("..") ? rel : basename(path);
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=tree.js.map
|
package/dist/tree.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree.js","sourceRoot":"","sources":["../src/tree.ts"],"names":[],"mappings":"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"}
|
package/dist/viewer.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ViewerOptions {
|
|
2
|
+
/** Directory the browser is rooted at. */
|
|
3
|
+
root: string;
|
|
4
|
+
/** File to open on start. */
|
|
5
|
+
open?: string | undefined;
|
|
6
|
+
theme?: string | undefined;
|
|
7
|
+
/** Sidebar columns. Clamped to the terminal. */
|
|
8
|
+
sidebar?: number | undefined;
|
|
9
|
+
mouse?: boolean | undefined;
|
|
10
|
+
all?: boolean | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare function run(options: ViewerOptions): Promise<void>;
|
|
13
|
+
//# sourceMappingURL=viewer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"viewer.d.ts","sourceRoot":"","sources":["../src/viewer.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,6BAA6B;IAC7B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,GAAG,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAC3B;AAuCD,wBAAsB,GAAG,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAkc/D"}
|
package/dist/viewer.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader: a file browser on the left, the rendered document on the right.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
6
|
+
import { createApp, stringWidth } from "@profullstack/hqtui";
|
|
7
|
+
import { renderMarkdown } from "./markdown.js";
|
|
8
|
+
import { filterTree, flatten, indexOfPath, label as pathLabel, reveal, scan, } from "./tree.js";
|
|
9
|
+
/** Files above this size are refused rather than rendered. */
|
|
10
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
12
|
+
/** Map a span's semantic role onto the active theme. */
|
|
13
|
+
function colorOf(span, theme) {
|
|
14
|
+
switch (span.role) {
|
|
15
|
+
case "h1":
|
|
16
|
+
return theme.title;
|
|
17
|
+
case "h2":
|
|
18
|
+
return theme.primary;
|
|
19
|
+
case "h3":
|
|
20
|
+
return theme.secondary;
|
|
21
|
+
case "code":
|
|
22
|
+
case "lang":
|
|
23
|
+
case "bullet":
|
|
24
|
+
return theme.accent;
|
|
25
|
+
case "fence":
|
|
26
|
+
return theme.foreground;
|
|
27
|
+
case "gutter":
|
|
28
|
+
case "rule":
|
|
29
|
+
return theme.border;
|
|
30
|
+
case "link":
|
|
31
|
+
case "url":
|
|
32
|
+
return theme.info;
|
|
33
|
+
case "quote":
|
|
34
|
+
return theme.secondary;
|
|
35
|
+
case "meta":
|
|
36
|
+
return theme.muted;
|
|
37
|
+
case "th":
|
|
38
|
+
return theme.title;
|
|
39
|
+
default:
|
|
40
|
+
return theme.foreground;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function run(options) {
|
|
44
|
+
const root = resolve(options.root);
|
|
45
|
+
const app = await createApp({
|
|
46
|
+
theme: options.theme,
|
|
47
|
+
mouse: options.mouse !== false,
|
|
48
|
+
// `q` is handled below so it can be typed into the filter.
|
|
49
|
+
quitKeys: ["ctrl+c"],
|
|
50
|
+
});
|
|
51
|
+
let entries = scan(root, { all: options.all ?? false });
|
|
52
|
+
let filter = "";
|
|
53
|
+
let filtering = false;
|
|
54
|
+
let view = entries;
|
|
55
|
+
let flat = flatten(view);
|
|
56
|
+
let selected = 0;
|
|
57
|
+
let pane = "tree";
|
|
58
|
+
let help = false;
|
|
59
|
+
let message = "";
|
|
60
|
+
let openPath = null;
|
|
61
|
+
let source = null;
|
|
62
|
+
let doc = null;
|
|
63
|
+
let scroll = 0;
|
|
64
|
+
let sidebar = options.sidebar ?? 0;
|
|
65
|
+
// Last measured pane interiors; the panel header needs them a frame early.
|
|
66
|
+
let viewport = { w: 80, h: 20 };
|
|
67
|
+
let treeHeight = 20;
|
|
68
|
+
// The panel header quotes numbers only the draw can measure, so a change in
|
|
69
|
+
// geometry or document length schedules exactly one more frame.
|
|
70
|
+
let measured = { total: -1, h: -1 };
|
|
71
|
+
const rebuild = (keepPath) => {
|
|
72
|
+
view = filter ? filterTree(entries, filter, root) : entries;
|
|
73
|
+
flat = flatten(view);
|
|
74
|
+
const at = keepPath ? indexOfPath(flat, keepPath) : -1;
|
|
75
|
+
selected = at >= 0 ? at : clamp(selected, 0, Math.max(0, flat.length - 1));
|
|
76
|
+
};
|
|
77
|
+
const openFile = (path) => {
|
|
78
|
+
try {
|
|
79
|
+
const info = statSync(path);
|
|
80
|
+
if (info.size > MAX_BYTES) {
|
|
81
|
+
message = `${basename(path)} is ${(info.size / 1024 / 1024).toFixed(1)} MB — too large to render`;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
source = readFileSync(path, "utf8");
|
|
85
|
+
openPath = path;
|
|
86
|
+
doc = null;
|
|
87
|
+
scroll = 0;
|
|
88
|
+
message = "";
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
message = `Cannot read ${basename(path)}: ${error instanceof Error ? error.message : String(error)}`;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const maxScroll = () => Math.max(0, (doc?.lines.length ?? 0) - viewport.h);
|
|
95
|
+
const moveSelection = (delta) => {
|
|
96
|
+
if (flat.length === 0)
|
|
97
|
+
return;
|
|
98
|
+
selected = clamp(selected + delta, 0, flat.length - 1);
|
|
99
|
+
};
|
|
100
|
+
const activate = () => {
|
|
101
|
+
const current = flat[selected];
|
|
102
|
+
if (!current)
|
|
103
|
+
return;
|
|
104
|
+
if (current.entry.dir) {
|
|
105
|
+
current.entry.expanded = !current.entry.expanded;
|
|
106
|
+
rebuild(current.entry.path);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
openFile(current.entry.path);
|
|
110
|
+
pane = "view";
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
// Open whatever was asked for, or fall back to the first file in the tree.
|
|
114
|
+
if (options.open) {
|
|
115
|
+
openFile(resolve(options.open));
|
|
116
|
+
if (openPath) {
|
|
117
|
+
reveal(entries, openPath);
|
|
118
|
+
rebuild(openPath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
app.on("key", (event) => {
|
|
122
|
+
const { key, name, char } = event;
|
|
123
|
+
if (help) {
|
|
124
|
+
help = false;
|
|
125
|
+
app.invalidate();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (filtering) {
|
|
129
|
+
if (key === "escape") {
|
|
130
|
+
filtering = false;
|
|
131
|
+
filter = "";
|
|
132
|
+
rebuild(openPath);
|
|
133
|
+
}
|
|
134
|
+
else if (name === "enter") {
|
|
135
|
+
filtering = false;
|
|
136
|
+
const current = flat[selected];
|
|
137
|
+
if (current && !current.entry.dir)
|
|
138
|
+
activate();
|
|
139
|
+
}
|
|
140
|
+
else if (name === "backspace") {
|
|
141
|
+
filter = filter.slice(0, -1);
|
|
142
|
+
rebuild();
|
|
143
|
+
}
|
|
144
|
+
else if (name === "up") {
|
|
145
|
+
moveSelection(-1);
|
|
146
|
+
}
|
|
147
|
+
else if (name === "down") {
|
|
148
|
+
moveSelection(1);
|
|
149
|
+
}
|
|
150
|
+
else if (char && !event.ctrl && !event.alt) {
|
|
151
|
+
filter += char;
|
|
152
|
+
rebuild();
|
|
153
|
+
}
|
|
154
|
+
app.invalidate();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
switch (key) {
|
|
158
|
+
case "q":
|
|
159
|
+
app.stop();
|
|
160
|
+
return;
|
|
161
|
+
case "?":
|
|
162
|
+
help = true;
|
|
163
|
+
app.invalidate();
|
|
164
|
+
return;
|
|
165
|
+
case "tab":
|
|
166
|
+
pane = pane === "tree" ? "view" : "tree";
|
|
167
|
+
app.invalidate();
|
|
168
|
+
return;
|
|
169
|
+
case "r": {
|
|
170
|
+
const keep = openPath;
|
|
171
|
+
entries = scan(root, { all: options.all ?? false });
|
|
172
|
+
if (keep)
|
|
173
|
+
reveal(entries, keep);
|
|
174
|
+
rebuild(keep);
|
|
175
|
+
if (keep)
|
|
176
|
+
openFile(keep);
|
|
177
|
+
app.invalidate();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
case "/":
|
|
181
|
+
filtering = true;
|
|
182
|
+
pane = "tree";
|
|
183
|
+
app.invalidate();
|
|
184
|
+
return;
|
|
185
|
+
case "[":
|
|
186
|
+
sidebar = clamp(sidebar - 2, 16, Math.max(16, app.width - 24));
|
|
187
|
+
app.invalidate();
|
|
188
|
+
return;
|
|
189
|
+
case "]":
|
|
190
|
+
sidebar = clamp(sidebar + 2, 16, Math.max(16, app.width - 24));
|
|
191
|
+
app.invalidate();
|
|
192
|
+
return;
|
|
193
|
+
case "escape":
|
|
194
|
+
if (filter) {
|
|
195
|
+
filter = "";
|
|
196
|
+
rebuild(openPath);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
pane = "tree";
|
|
200
|
+
}
|
|
201
|
+
app.invalidate();
|
|
202
|
+
return;
|
|
203
|
+
default:
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
if (pane === "tree") {
|
|
207
|
+
switch (key) {
|
|
208
|
+
case "up":
|
|
209
|
+
case "k":
|
|
210
|
+
moveSelection(-1);
|
|
211
|
+
break;
|
|
212
|
+
case "down":
|
|
213
|
+
case "j":
|
|
214
|
+
moveSelection(1);
|
|
215
|
+
break;
|
|
216
|
+
case "pageup":
|
|
217
|
+
moveSelection(-Math.max(1, treeHeight - 1));
|
|
218
|
+
break;
|
|
219
|
+
case "pagedown":
|
|
220
|
+
moveSelection(Math.max(1, treeHeight - 1));
|
|
221
|
+
break;
|
|
222
|
+
case "home":
|
|
223
|
+
case "g":
|
|
224
|
+
selected = 0;
|
|
225
|
+
break;
|
|
226
|
+
case "end":
|
|
227
|
+
case "G":
|
|
228
|
+
case "shift+g":
|
|
229
|
+
selected = Math.max(0, flat.length - 1);
|
|
230
|
+
break;
|
|
231
|
+
case "right":
|
|
232
|
+
case "l":
|
|
233
|
+
case "enter":
|
|
234
|
+
case "space": {
|
|
235
|
+
const current = flat[selected];
|
|
236
|
+
if (current?.entry.dir && current.entry.expanded && key !== "space")
|
|
237
|
+
moveSelection(1);
|
|
238
|
+
else
|
|
239
|
+
activate();
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case "left":
|
|
243
|
+
case "h": {
|
|
244
|
+
const current = flat[selected];
|
|
245
|
+
if (current?.entry.dir && current.entry.expanded) {
|
|
246
|
+
current.entry.expanded = false;
|
|
247
|
+
rebuild(current.entry.path);
|
|
248
|
+
}
|
|
249
|
+
else if (current && current.parent >= 0) {
|
|
250
|
+
selected = current.parent;
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
default:
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
app.invalidate();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const page = Math.max(1, viewport.h - 2);
|
|
261
|
+
switch (key) {
|
|
262
|
+
case "up":
|
|
263
|
+
case "k":
|
|
264
|
+
scroll = clamp(scroll - 1, 0, maxScroll());
|
|
265
|
+
break;
|
|
266
|
+
case "down":
|
|
267
|
+
case "j":
|
|
268
|
+
scroll = clamp(scroll + 1, 0, maxScroll());
|
|
269
|
+
break;
|
|
270
|
+
case "pageup":
|
|
271
|
+
case "b":
|
|
272
|
+
scroll = clamp(scroll - page, 0, maxScroll());
|
|
273
|
+
break;
|
|
274
|
+
case "pagedown":
|
|
275
|
+
case "space":
|
|
276
|
+
scroll = clamp(scroll + page, 0, maxScroll());
|
|
277
|
+
break;
|
|
278
|
+
case "home":
|
|
279
|
+
case "g":
|
|
280
|
+
scroll = 0;
|
|
281
|
+
break;
|
|
282
|
+
case "end":
|
|
283
|
+
case "G":
|
|
284
|
+
case "shift+g":
|
|
285
|
+
scroll = maxScroll();
|
|
286
|
+
break;
|
|
287
|
+
case "left":
|
|
288
|
+
case "h":
|
|
289
|
+
pane = "tree";
|
|
290
|
+
break;
|
|
291
|
+
default:
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
app.invalidate();
|
|
295
|
+
});
|
|
296
|
+
app.render(({ ui, theme, width, height }) => {
|
|
297
|
+
if (sidebar === 0)
|
|
298
|
+
sidebar = clamp(Math.round(width * 0.28), 22, 42);
|
|
299
|
+
const side = clamp(sidebar, 16, Math.max(16, width - 24));
|
|
300
|
+
const openRel = openPath ? pathLabel(root, openPath) : "";
|
|
301
|
+
const total = doc?.lines.length ?? 0;
|
|
302
|
+
const percent = total <= viewport.h ? 100 : Math.round((Math.min(scroll + viewport.h, total) / total) * 100);
|
|
303
|
+
ui.column({}, (screen) => {
|
|
304
|
+
screen.row({ size: "fill" }, (panes) => {
|
|
305
|
+
// ------------------------------------------------------- browser
|
|
306
|
+
panes.panel({
|
|
307
|
+
width: side,
|
|
308
|
+
title: filtering || filter ? ` /${filter}` : ` ${basename(root) || root} `,
|
|
309
|
+
titleColor: filter ? theme.warning : theme.title,
|
|
310
|
+
subtitle: filtering ? "▏" : `${flat.length}`,
|
|
311
|
+
borderColor: pane === "tree" ? theme.borderFocused : theme.border,
|
|
312
|
+
padding: [0, 1],
|
|
313
|
+
}, (p) => {
|
|
314
|
+
treeHeight = p.height;
|
|
315
|
+
if (flat.length === 0) {
|
|
316
|
+
p.text(filter ? "No match." : "No markdown here.", { fg: theme.muted });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
p.list({
|
|
320
|
+
items: flat.map(({ entry, depth }) => {
|
|
321
|
+
const indent = " ".repeat(depth);
|
|
322
|
+
const glyph = entry.dir ? (entry.expanded ? "▾ " : "▸ ") : " ";
|
|
323
|
+
const color = entry.dir
|
|
324
|
+
? theme.primary
|
|
325
|
+
: entry.path === openPath
|
|
326
|
+
? theme.accent
|
|
327
|
+
: theme.foreground;
|
|
328
|
+
return { label: `${indent}${glyph}${entry.name}`, color };
|
|
329
|
+
}),
|
|
330
|
+
selected,
|
|
331
|
+
followSelection: true,
|
|
332
|
+
scrollbar: true,
|
|
333
|
+
onScroll: (delta) => {
|
|
334
|
+
moveSelection(delta * 3);
|
|
335
|
+
app.invalidate();
|
|
336
|
+
},
|
|
337
|
+
onSelectRow: (row) => {
|
|
338
|
+
const offset = Math.max(0, Math.min(selected - Math.floor(p.height / 2), flat.length - p.height));
|
|
339
|
+
const index = clamp((flat.length > p.height ? offset : 0) + row, 0, flat.length - 1);
|
|
340
|
+
selected = index;
|
|
341
|
+
pane = "tree";
|
|
342
|
+
activate();
|
|
343
|
+
app.invalidate();
|
|
344
|
+
},
|
|
345
|
+
onFocus: () => {
|
|
346
|
+
pane = "tree";
|
|
347
|
+
app.invalidate();
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
// -------------------------------------------------------- reader
|
|
352
|
+
panes.panel({
|
|
353
|
+
title: openRel ? ` ${openRel} ` : " readm3 ",
|
|
354
|
+
subtitle: openPath ? `${percent}%` : undefined,
|
|
355
|
+
footer: openPath && total > 0 ? `${Math.min(scroll + viewport.h, total)}/${total}` : undefined,
|
|
356
|
+
borderColor: pane === "view" ? theme.borderFocused : theme.border,
|
|
357
|
+
padding: [0, 1],
|
|
358
|
+
}, (p) => {
|
|
359
|
+
const w = p.width;
|
|
360
|
+
const h = p.height;
|
|
361
|
+
viewport = { w, h };
|
|
362
|
+
p.ctx.hit({
|
|
363
|
+
rect: p.surface.hitRect(),
|
|
364
|
+
onScroll: (delta) => {
|
|
365
|
+
scroll = clamp(scroll + delta * 3, 0, maxScroll());
|
|
366
|
+
app.invalidate();
|
|
367
|
+
},
|
|
368
|
+
onClick: () => {
|
|
369
|
+
pane = "view";
|
|
370
|
+
app.invalidate();
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
if (message) {
|
|
374
|
+
p.text(message, { fg: theme.danger, wrap: true });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (!openPath || source === null) {
|
|
378
|
+
p.spacer("fill");
|
|
379
|
+
p.text("readm3", { fg: theme.title, bold: true, align: "center" });
|
|
380
|
+
p.text("Pick a file on the left. ? for keys.", { fg: theme.muted, align: "center" });
|
|
381
|
+
p.spacer("fill");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (!doc || doc.path !== openPath || doc.width !== w) {
|
|
385
|
+
doc = { path: openPath, width: w, lines: renderMarkdown(source, w) };
|
|
386
|
+
}
|
|
387
|
+
scroll = clamp(scroll, 0, Math.max(0, doc.lines.length - h));
|
|
388
|
+
if (measured.total !== doc.lines.length || measured.h !== h) {
|
|
389
|
+
measured = { total: doc.lines.length, h };
|
|
390
|
+
app.invalidate();
|
|
391
|
+
}
|
|
392
|
+
for (const line of doc.lines.slice(scroll, scroll + h)) {
|
|
393
|
+
const spans = line.spans;
|
|
394
|
+
const only = spans[0];
|
|
395
|
+
if (spans.length <= 1) {
|
|
396
|
+
p.text(only?.text ?? "", {
|
|
397
|
+
height: 1,
|
|
398
|
+
fg: only ? colorOf(only, theme) : theme.foreground,
|
|
399
|
+
bold: only?.bold ?? false,
|
|
400
|
+
italic: only?.italic ?? false,
|
|
401
|
+
underline: only?.underline ?? false,
|
|
402
|
+
dim: only?.dim ?? false,
|
|
403
|
+
});
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
p.row({ height: 1 }, (row) => {
|
|
407
|
+
for (const span of spans) {
|
|
408
|
+
row.text(span.text, {
|
|
409
|
+
width: stringWidth(span.text),
|
|
410
|
+
fg: colorOf(span, theme),
|
|
411
|
+
bold: span.bold ?? false,
|
|
412
|
+
italic: span.italic ?? false,
|
|
413
|
+
underline: span.underline ?? false,
|
|
414
|
+
dim: span.dim ?? false,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
row.spacer("fill");
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
screen.statusBar({
|
|
423
|
+
items: filtering
|
|
424
|
+
? [
|
|
425
|
+
{ key: "type", label: "filter" },
|
|
426
|
+
{ key: "↵", label: "open" },
|
|
427
|
+
{ key: "esc", label: "clear" },
|
|
428
|
+
]
|
|
429
|
+
: pane === "tree"
|
|
430
|
+
? [
|
|
431
|
+
{ key: "↑↓", label: "move" },
|
|
432
|
+
{ key: "↵", label: "open" },
|
|
433
|
+
{ key: "←→", label: "fold" },
|
|
434
|
+
{ key: "/", label: "find" },
|
|
435
|
+
{ key: "tab", label: "reader" },
|
|
436
|
+
{ key: "?", label: "keys" },
|
|
437
|
+
{ key: "q", label: "quit" },
|
|
438
|
+
]
|
|
439
|
+
: [
|
|
440
|
+
{ key: "↑↓", label: "scroll" },
|
|
441
|
+
{ key: "spc", label: "page" },
|
|
442
|
+
{ key: "g/G", label: "ends" },
|
|
443
|
+
{ key: "tab", label: "files" },
|
|
444
|
+
{ key: "?", label: "keys" },
|
|
445
|
+
{ key: "q", label: "quit" },
|
|
446
|
+
],
|
|
447
|
+
right: [
|
|
448
|
+
{ label: openPath ? (dirname(openRel) === "." ? basename(root) : dirname(openRel)) : basename(root) },
|
|
449
|
+
{ label: theme.name, color: theme.muted },
|
|
450
|
+
],
|
|
451
|
+
});
|
|
452
|
+
if (help) {
|
|
453
|
+
screen.modal({ title: " readm3 — keys ", width: 52, height: 18 }, (m) => {
|
|
454
|
+
m.keyValues([
|
|
455
|
+
{ label: "↑ ↓ j k", value: "move / scroll" },
|
|
456
|
+
{ label: "→ ← l h", value: "expand / collapse" },
|
|
457
|
+
{ label: "enter", value: "open file" },
|
|
458
|
+
{ label: "tab", value: "switch pane" },
|
|
459
|
+
{ label: "space / b", value: "page down / up" },
|
|
460
|
+
{ label: "g / G", value: "top / bottom" },
|
|
461
|
+
{ label: "/", value: "filter files" },
|
|
462
|
+
{ label: "esc", value: "clear filter" },
|
|
463
|
+
{ label: "r", value: "rescan and reload" },
|
|
464
|
+
{ label: "[ ]", value: "sidebar width" },
|
|
465
|
+
{ label: "?", value: "this help" },
|
|
466
|
+
{ label: "q / ctrl+c", value: "quit" },
|
|
467
|
+
], { labelColor: theme.accent, labelWidth: 14 });
|
|
468
|
+
m.spacer(1);
|
|
469
|
+
m.text("Any key closes.", { fg: theme.muted, align: "center" });
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
void height;
|
|
474
|
+
});
|
|
475
|
+
await app.start();
|
|
476
|
+
}
|
|
477
|
+
//# sourceMappingURL=viewer.js.map
|