@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 CHANGED
@@ -6,7 +6,7 @@
6
6
  [![CI](https://github.com/dunkinfrunkin/mdcat/actions/workflows/ci.yml/badge.svg)](https://github.com/dunkinfrunkin/mdcat/actions/workflows/ci.yml)
7
7
  [![site](https://img.shields.io/badge/site-mdcat.frankchan.dev-61afef)](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` (97 tests).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunkinfrunkin/mdcat",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "View markdown files beautifully in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
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
- launch(title, lines, activeTheme, { lineNumbers: showLineNumbers });
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
- if (mode === "normal" || !searchQuery) return prefix || " ";
241
- if (matchLines[matchIdx] === absLine) return `${prefix}${C.matchFg}▶${RESET} `;
242
- if (matchSet.has(absLine)) return `${prefix}${C.otherMatchFg}›${RESET} `;
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) {