@dunkinfrunkin/mdcat 0.1.16 → 0.1.17
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 +3 -2
- package/package.json +1 -1
- package/src/cli.js +12 -3
- package/src/git.js +207 -0
- package/src/tui.js +19 -4
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://github.com/dunkinfrunkin/mdcat/actions/workflows/ci.yml)
|
|
7
7
|
[](https://mdcat.frankchan.dev)
|
|
8
8
|
|
|
9
|
-
**Terminal pager for Markdown.** Full colour, syntax highlighting, incremental search, mouse support — zero config.
|
|
9
|
+
**Terminal pager for Markdown.** Full colour, syntax highlighting, git diff gutter, incremental search, mouse support — zero config.
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm install -g @dunkinfrunkin/mdcat
|
|
@@ -98,6 +98,7 @@ MDCAT_THEME=light mdcat file.md # env var override
|
|
|
98
98
|
| Images | `[alt text]` badge in dim |
|
|
99
99
|
| Horizontal rules | Full-width dim `─` line |
|
|
100
100
|
| HTML entities | Decoded (`&`, `<`, …) |
|
|
101
|
+
| Git diff gutter | Green `+` added, yellow `~` modified, red `-` deleted |
|
|
101
102
|
|
|
102
103
|
## Contributing
|
|
103
104
|
|
|
@@ -110,7 +111,7 @@ npm install
|
|
|
110
111
|
npm test
|
|
111
112
|
```
|
|
112
113
|
|
|
113
|
-
All PRs must pass `npm test` (
|
|
114
|
+
All PRs must pass `npm test` (107 tests).
|
|
114
115
|
|
|
115
116
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
|
|
116
117
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { launch } from "./tui.js";
|
|
|
9
9
|
import { toDocx } from "./docx.js";
|
|
10
10
|
import { detectTheme, themeFromArgs, stripThemeArgs } from "./theme.js";
|
|
11
11
|
import { concatFiles } from "./concat.js";
|
|
12
|
+
import { getDiffMap, mapDiffToRendered } from "./git.js";
|
|
12
13
|
|
|
13
14
|
marked.use({ gfm: true });
|
|
14
15
|
|
|
@@ -150,12 +151,20 @@ function openInBrowser(title, content) {
|
|
|
150
151
|
catch { console.error(`mdcat: could not open browser`); process.exit(1); }
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
function runTUI(title, content) {
|
|
154
|
+
function runTUI(title, content, filePath) {
|
|
154
155
|
const termCols = process.stdout.columns || 80;
|
|
155
156
|
const cols = Math.min(termCols, MAX_COLS);
|
|
156
157
|
const tokens = marked.lexer(content);
|
|
157
158
|
const lines = renderTokens(tokens, cols, termCols > MAX_COLS ? termCols : undefined);
|
|
158
|
-
|
|
159
|
+
|
|
160
|
+
let diffMap = new Map();
|
|
161
|
+
if (filePath) {
|
|
162
|
+
const sourceLineCount = content.split("\n").length;
|
|
163
|
+
const sourceDiffMap = getDiffMap(filePath, sourceLineCount);
|
|
164
|
+
diffMap = mapDiffToRendered(sourceDiffMap, sourceLineCount, lines.length);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
launch(title, lines, activeTheme, { lineNumbers: showLineNumbers, diffMap });
|
|
159
168
|
}
|
|
160
169
|
|
|
161
170
|
// --web / --doc flags
|
|
@@ -205,5 +214,5 @@ if (!process.stdin.isTTY && fileArgs.length === 0) {
|
|
|
205
214
|
if (docMode) exportDocx(title, content);
|
|
206
215
|
else if (webMode) openInBrowser(title, content);
|
|
207
216
|
else if (plainMode) runPlain(content);
|
|
208
|
-
else runTUI(title, content);
|
|
217
|
+
else runTUI(title, content, filePath);
|
|
209
218
|
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse unified diff output into a Map of source line numbers to change types.
|
|
6
|
+
*
|
|
7
|
+
* Change types:
|
|
8
|
+
* "added" — line exists only in the working copy
|
|
9
|
+
* "modified" — line was changed (deletion + addition at the same position)
|
|
10
|
+
* "deleted" — a deletion occurred at or just before this line
|
|
11
|
+
*
|
|
12
|
+
* @param {string} diffOutput - raw unified diff text (from `git diff`)
|
|
13
|
+
* @returns {Map<number, string>} lineNumber (1-based) → "added" | "modified" | "deleted"
|
|
14
|
+
*/
|
|
15
|
+
export function parseDiff(diffOutput) {
|
|
16
|
+
const map = new Map();
|
|
17
|
+
if (!diffOutput) return map;
|
|
18
|
+
|
|
19
|
+
const lines = diffOutput.split("\n");
|
|
20
|
+
let newLine = 0; // current line number in the new (working) file
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23
|
+
const line = lines[i];
|
|
24
|
+
|
|
25
|
+
// Parse hunk header: @@ -oldstart,oldcount +newstart,newcount @@
|
|
26
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
27
|
+
if (hunkMatch) {
|
|
28
|
+
const oldStart = parseInt(hunkMatch[1], 10);
|
|
29
|
+
const oldCount = hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1;
|
|
30
|
+
const newStart = parseInt(hunkMatch[3], 10);
|
|
31
|
+
const newCount = hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1;
|
|
32
|
+
|
|
33
|
+
newLine = newStart;
|
|
34
|
+
|
|
35
|
+
// Walk through hunk body lines
|
|
36
|
+
let j = i + 1;
|
|
37
|
+
let curNew = newStart;
|
|
38
|
+
let curOld = oldStart;
|
|
39
|
+
|
|
40
|
+
while (j < lines.length && !lines[j].startsWith("@@") && !lines[j].startsWith("diff ")) {
|
|
41
|
+
const hLine = lines[j];
|
|
42
|
+
|
|
43
|
+
if (hLine.startsWith("---") || hLine.startsWith("+++")) {
|
|
44
|
+
j++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (hLine.startsWith("-")) {
|
|
49
|
+
// Deletion — look ahead to see if the next line is an addition (modification)
|
|
50
|
+
let k = j + 1;
|
|
51
|
+
// Peek ahead: if the next non-delete line is a +, this is a modification
|
|
52
|
+
if (k < lines.length && lines[k].startsWith("+")) {
|
|
53
|
+
// This is a modification: the + line replaces the - line
|
|
54
|
+
map.set(curNew, "modified");
|
|
55
|
+
curOld++;
|
|
56
|
+
curNew++;
|
|
57
|
+
j = k + 1; // skip both - and + lines
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Pure deletion — mark the current new-file line
|
|
61
|
+
// If curNew hasn't gone past the file, mark it as a deletion point
|
|
62
|
+
if (!map.has(curNew)) {
|
|
63
|
+
map.set(curNew, "deleted");
|
|
64
|
+
}
|
|
65
|
+
curOld++;
|
|
66
|
+
j++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (hLine.startsWith("+")) {
|
|
71
|
+
// Pure addition
|
|
72
|
+
map.set(curNew, "added");
|
|
73
|
+
curNew++;
|
|
74
|
+
j++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Context line (starts with space or is empty after diff markers)
|
|
79
|
+
curNew++;
|
|
80
|
+
curOld++;
|
|
81
|
+
j++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Advance outer loop past the hunk body we just consumed
|
|
85
|
+
i = j - 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check whether a file is tracked by git.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} filePath - absolute path to the file
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function isTracked(filePath) {
|
|
100
|
+
try {
|
|
101
|
+
execFileSync("git", ["ls-files", "--error-unmatch", filePath], {
|
|
102
|
+
cwd: dirname(filePath),
|
|
103
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
104
|
+
});
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check whether a path is inside a git repository.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} filePath - absolute path to the file
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function isGitRepo(filePath) {
|
|
118
|
+
try {
|
|
119
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
120
|
+
cwd: dirname(filePath),
|
|
121
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
122
|
+
});
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get a diff map for a file, mapping source line numbers to change types.
|
|
131
|
+
*
|
|
132
|
+
* Handles:
|
|
133
|
+
* - Not a git repo: returns empty map
|
|
134
|
+
* - Untracked file: returns map with every line marked "added"
|
|
135
|
+
* - Tracked file with changes: parses `git diff HEAD -- <file>`
|
|
136
|
+
* - Tracked file with no changes: returns empty map
|
|
137
|
+
*
|
|
138
|
+
* @param {string} filePath - absolute path to the file
|
|
139
|
+
* @param {number} totalLines - total number of lines in the file
|
|
140
|
+
* @returns {Map<number, string>} lineNumber (1-based) → "added" | "modified" | "deleted"
|
|
141
|
+
*/
|
|
142
|
+
export function getDiffMap(filePath, totalLines) {
|
|
143
|
+
if (!isGitRepo(filePath)) {
|
|
144
|
+
return new Map();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!isTracked(filePath)) {
|
|
148
|
+
// Untracked file — everything is "added"
|
|
149
|
+
const map = new Map();
|
|
150
|
+
for (let i = 1; i <= totalLines; i++) {
|
|
151
|
+
map.set(i, "added");
|
|
152
|
+
}
|
|
153
|
+
return map;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const diff = execFileSync("git", ["diff", "HEAD", "--", filePath], {
|
|
158
|
+
cwd: dirname(filePath),
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
161
|
+
});
|
|
162
|
+
return parseDiff(diff);
|
|
163
|
+
} catch {
|
|
164
|
+
return new Map();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Map source-file diff markers to rendered output lines using proportional mapping.
|
|
170
|
+
*
|
|
171
|
+
* Since markdown rendering changes the line count (headings become boxes, code blocks
|
|
172
|
+
* get borders, etc.), we use a proportional mapping from source lines to rendered lines.
|
|
173
|
+
*
|
|
174
|
+
* @param {Map<number, string>} sourceDiffMap - from getDiffMap (1-based source lines)
|
|
175
|
+
* @param {number} sourceLineCount - total lines in the source file
|
|
176
|
+
* @param {number} renderedLineCount - total lines in the rendered output
|
|
177
|
+
* @returns {Map<number, string>} renderedLineIndex (0-based) → "added" | "modified" | "deleted"
|
|
178
|
+
*/
|
|
179
|
+
export function mapDiffToRendered(sourceDiffMap, sourceLineCount, renderedLineCount) {
|
|
180
|
+
const rendered = new Map();
|
|
181
|
+
if (sourceDiffMap.size === 0 || sourceLineCount === 0 || renderedLineCount === 0) {
|
|
182
|
+
return rendered;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const [srcLine, type] of sourceDiffMap) {
|
|
186
|
+
// Proportional mapping: source line 1..N → rendered line 0..(M-1)
|
|
187
|
+
const ratio = (srcLine - 1) / Math.max(sourceLineCount - 1, 1);
|
|
188
|
+
const renderedLine = Math.round(ratio * (renderedLineCount - 1));
|
|
189
|
+
const clamped = Math.max(0, Math.min(renderedLine, renderedLineCount - 1));
|
|
190
|
+
|
|
191
|
+
// If a line already has a stronger marker, keep it
|
|
192
|
+
// Priority: modified > added > deleted
|
|
193
|
+
const existing = rendered.get(clamped);
|
|
194
|
+
if (!existing || priority(type) > priority(existing)) {
|
|
195
|
+
rendered.set(clamped, type);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return rendered;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function priority(type) {
|
|
203
|
+
if (type === "modified") return 3;
|
|
204
|
+
if (type === "added") return 2;
|
|
205
|
+
if (type === "deleted") return 1;
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
package/src/tui.js
CHANGED
|
@@ -143,6 +143,9 @@ export function launch(title, lines, theme, opts = {}) {
|
|
|
143
143
|
const totalDigits = String(lines.length).length;
|
|
144
144
|
const gutterW = totalDigits + 2; // " N " width
|
|
145
145
|
|
|
146
|
+
// Git diff map (rendered line index → "added" | "modified" | "deleted")
|
|
147
|
+
const diffMap = opts.diffMap ?? new Map();
|
|
148
|
+
|
|
146
149
|
// Viewport state
|
|
147
150
|
let offset = 0;
|
|
148
151
|
|
|
@@ -230,6 +233,15 @@ export function launch(title, lines, theme, opts = {}) {
|
|
|
230
233
|
return `${C.chromeBg}${left}${C.dimFg}${" ".repeat(gap)}${cat}${RESET}`;
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
function diffMarkerFor(absLine) {
|
|
237
|
+
const type = diffMap.get(absLine);
|
|
238
|
+
if (!type) return "";
|
|
239
|
+
if (type === "added") return `${C.greenFg}+${RESET}`;
|
|
240
|
+
if (type === "modified") return `${C.matchFg}~${RESET}`;
|
|
241
|
+
if (type === "deleted") return `${C.redFg}-${RESET}`;
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
244
|
+
|
|
233
245
|
function gutterFor(absLine) {
|
|
234
246
|
let prefix = "";
|
|
235
247
|
if (lineNumbers) {
|
|
@@ -237,10 +249,13 @@ export function launch(title, lines, theme, opts = {}) {
|
|
|
237
249
|
prefix = `${C.dimFg}${num}${RESET} `;
|
|
238
250
|
}
|
|
239
251
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return prefix
|
|
252
|
+
// Diff gutter marker (single char column before content)
|
|
253
|
+
const diff = diffMap.size > 0 ? (diffMarkerFor(absLine) || " ") : "";
|
|
254
|
+
|
|
255
|
+
if (mode === "normal" || !searchQuery) return `${prefix}${diff}${diff ? " " : prefix ? "" : " "}`;
|
|
256
|
+
if (matchLines[matchIdx] === absLine) return `${prefix}${diff ? diff + " " : ""}${C.matchFg}▶${RESET} `;
|
|
257
|
+
if (matchSet.has(absLine)) return `${prefix}${diff ? diff + " " : ""}${C.otherMatchFg}›${RESET} `;
|
|
258
|
+
return `${prefix}${diff}${diff ? " " : prefix ? "" : " "}`;
|
|
244
259
|
}
|
|
245
260
|
|
|
246
261
|
function statusBar(w) {
|