@abelfubu/dv 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.
Files changed (118) hide show
  1. package/dist/ansi-html.d.ts +42 -0
  2. package/dist/ansi-html.d.ts.map +1 -0
  3. package/dist/ansi-html.js +327 -0
  4. package/dist/ansi-output.d.ts +22 -0
  5. package/dist/ansi-output.d.ts.map +1 -0
  6. package/dist/ansi-output.js +154 -0
  7. package/dist/balance-delimiters.d.ts +25 -0
  8. package/dist/balance-delimiters.d.ts.map +1 -0
  9. package/dist/balance-delimiters.js +539 -0
  10. package/dist/balance-delimiters.test.d.ts +2 -0
  11. package/dist/balance-delimiters.test.d.ts.map +1 -0
  12. package/dist/balance-delimiters.test.js +1029 -0
  13. package/dist/cli-copy-notification.test.d.ts +2 -0
  14. package/dist/cli-copy-notification.test.d.ts.map +1 -0
  15. package/dist/cli-copy-notification.test.js +80 -0
  16. package/dist/cli-scroll.test.d.ts +2 -0
  17. package/dist/cli-scroll.test.d.ts.map +1 -0
  18. package/dist/cli-scroll.test.js +283 -0
  19. package/dist/cli.d.ts +9 -0
  20. package/dist/cli.d.ts.map +1 -0
  21. package/dist/cli.js +976 -0
  22. package/dist/clipboard.d.ts +16 -0
  23. package/dist/clipboard.d.ts.map +1 -0
  24. package/dist/clipboard.js +128 -0
  25. package/dist/components/diff-view.d.ts +32 -0
  26. package/dist/components/diff-view.d.ts.map +1 -0
  27. package/dist/components/diff-view.js +123 -0
  28. package/dist/components/diff-view.test.d.ts +5 -0
  29. package/dist/components/diff-view.test.d.ts.map +1 -0
  30. package/dist/components/diff-view.test.js +312 -0
  31. package/dist/components/directory-tree-view.d.ts +33 -0
  32. package/dist/components/directory-tree-view.d.ts.map +1 -0
  33. package/dist/components/directory-tree-view.js +262 -0
  34. package/dist/components/index.d.ts +4 -0
  35. package/dist/components/index.d.ts.map +1 -0
  36. package/dist/components/index.js +5 -0
  37. package/dist/components/toast.d.ts +21 -0
  38. package/dist/components/toast.d.ts.map +1 -0
  39. package/dist/components/toast.js +47 -0
  40. package/dist/diff-cursor-utils.d.ts +20 -0
  41. package/dist/diff-cursor-utils.d.ts.map +1 -0
  42. package/dist/diff-cursor-utils.js +105 -0
  43. package/dist/diff-cursor-utils.test.d.ts +2 -0
  44. package/dist/diff-cursor-utils.test.d.ts.map +1 -0
  45. package/dist/diff-cursor-utils.test.js +40 -0
  46. package/dist/diff-surface-copy.d.ts +23 -0
  47. package/dist/diff-surface-copy.d.ts.map +1 -0
  48. package/dist/diff-surface-copy.js +64 -0
  49. package/dist/diff-surface-copy.test.d.ts +5 -0
  50. package/dist/diff-surface-copy.test.d.ts.map +1 -0
  51. package/dist/diff-surface-copy.test.js +142 -0
  52. package/dist/diff-utils.d.ts +196 -0
  53. package/dist/diff-utils.d.ts.map +1 -0
  54. package/dist/diff-utils.js +682 -0
  55. package/dist/diff-utils.test.d.ts +2 -0
  56. package/dist/diff-utils.test.d.ts.map +1 -0
  57. package/dist/diff-utils.test.js +727 -0
  58. package/dist/directory-tree.d.ts +72 -0
  59. package/dist/directory-tree.d.ts.map +1 -0
  60. package/dist/directory-tree.js +161 -0
  61. package/dist/directory-tree.test.d.ts +2 -0
  62. package/dist/directory-tree.test.d.ts.map +1 -0
  63. package/dist/directory-tree.test.js +383 -0
  64. package/dist/dropdown.d.ts +26 -0
  65. package/dist/dropdown.d.ts.map +1 -0
  66. package/dist/dropdown.js +172 -0
  67. package/dist/dropdown.test.d.ts +2 -0
  68. package/dist/dropdown.test.d.ts.map +1 -0
  69. package/dist/dropdown.test.js +106 -0
  70. package/dist/filter-submodule.e2e.test.d.ts +2 -0
  71. package/dist/filter-submodule.e2e.test.d.ts.map +1 -0
  72. package/dist/filter-submodule.e2e.test.js +109 -0
  73. package/dist/hooks/use-copy-selection.d.ts +29 -0
  74. package/dist/hooks/use-copy-selection.d.ts.map +1 -0
  75. package/dist/hooks/use-copy-selection.js +46 -0
  76. package/dist/kv-codec.d.ts +16 -0
  77. package/dist/kv-codec.d.ts.map +1 -0
  78. package/dist/kv-codec.js +36 -0
  79. package/dist/license.d.ts +14 -0
  80. package/dist/license.d.ts.map +1 -0
  81. package/dist/license.js +63 -0
  82. package/dist/logger.d.ts +9 -0
  83. package/dist/logger.d.ts.map +1 -0
  84. package/dist/logger.js +78 -0
  85. package/dist/monochrome.d.ts +34 -0
  86. package/dist/monochrome.d.ts.map +1 -0
  87. package/dist/monochrome.js +613 -0
  88. package/dist/monotone.d.ts +22 -0
  89. package/dist/monotone.d.ts.map +1 -0
  90. package/dist/monotone.js +185 -0
  91. package/dist/parsers-config.d.ts +19 -0
  92. package/dist/parsers-config.d.ts.map +1 -0
  93. package/dist/parsers-config.js +271 -0
  94. package/dist/patch-terminal-dimensions.d.ts +2 -0
  95. package/dist/patch-terminal-dimensions.d.ts.map +1 -0
  96. package/dist/patch-terminal-dimensions.js +45 -0
  97. package/dist/stdin-pager.test.d.ts +2 -0
  98. package/dist/stdin-pager.test.d.ts.map +1 -0
  99. package/dist/stdin-pager.test.js +497 -0
  100. package/dist/store.d.ts +16 -0
  101. package/dist/store.d.ts.map +1 -0
  102. package/dist/store.js +48 -0
  103. package/dist/themes/github.json +247 -0
  104. package/dist/themes.d.ts +59 -0
  105. package/dist/themes.d.ts.map +1 -0
  106. package/dist/themes.js +248 -0
  107. package/dist/tree-icons.d.ts +4 -0
  108. package/dist/tree-icons.d.ts.map +1 -0
  109. package/dist/tree-icons.js +18 -0
  110. package/dist/utils.d.ts +2 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils.js +13 -0
  113. package/dist/web-utils.d.ts +56 -0
  114. package/dist/web-utils.d.ts.map +1 -0
  115. package/dist/web-utils.js +363 -0
  116. package/package.json +37 -0
  117. package/public/jetbrains-mono-nerd.ttf +0 -0
  118. package/public/jetbrains-mono-nerd.woff2 +0 -0
@@ -0,0 +1,682 @@
1
+ // Shared utilities for git diff processing across CLI commands.
2
+ // Builds git commands, parses diff files, detects filetypes for syntax highlighting,
3
+ // and provides helpers for unified/split view mode selection.
4
+ import { execSync } from "child_process";
5
+ import fs from "fs";
6
+ import { join } from "path";
7
+ import { buildDirectoryTree } from "./directory-tree.js";
8
+ /**
9
+ * Check if the current directory is inside a git repository.
10
+ * If not, print a friendly error message and exit.
11
+ */
12
+ export function ensureGitRepo() {
13
+ try {
14
+ execSync("git rev-parse --is-inside-work-tree", { stdio: "pipe" });
15
+ }
16
+ catch {
17
+ console.error("fatal: not a git repository (or any parent up to mount point /)");
18
+ console.error("");
19
+ console.error("Run critique inside a git repository.");
20
+ process.exit(128);
21
+ }
22
+ }
23
+ /**
24
+ * Get the absolute path to the git repository root.
25
+ */
26
+ export function getGitRepoRoot() {
27
+ try {
28
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf-8", stdio: "pipe" }).trim();
29
+ }
30
+ catch {
31
+ return process.cwd();
32
+ }
33
+ }
34
+ /**
35
+ * Strip submodule status lines from git diff output.
36
+ * git diff --submodule=diff adds various status lines that the diff parser doesn't understand:
37
+ * - "Submodule name hash1..hash2:" (header before submodule diff)
38
+ * - "Submodule name contains modified content"
39
+ * - "Submodule name contains untracked content"
40
+ * - "Submodule name (new commits)"
41
+ * - "Submodule name (commits not present)"
42
+ */
43
+ export function stripSubmoduleHeaders(diffOutput) {
44
+ return diffOutput
45
+ .split("\n")
46
+ .filter((line) => {
47
+ // Match lines like "Submodule errore 1bf6fc8..d746b25:"
48
+ if (line.match(/^Submodule \S+ [a-f0-9]+\.\.[a-f0-9]+:?$/))
49
+ return false;
50
+ // Match lines like "Submodule unframer contains modified content"
51
+ if (line.match(/^Submodule \S+ contains (modified|untracked) content$/))
52
+ return false;
53
+ // Match lines like "Submodule name (new commits)" or "(commits not present)"
54
+ if (line.match(/^Submodule \S+ \(.*\)$/))
55
+ return false;
56
+ return true;
57
+ })
58
+ .join("\n");
59
+ }
60
+ /**
61
+ * Preprocess raw git diff output to handle rename/copy detection.
62
+ *
63
+ * The `diff` npm package's parsePatch does not understand git's rename/copy
64
+ * headers (similarity index, rename from/to, copy from/to). For pure renames
65
+ * (100% similarity, no content changes), it produces broken entries because
66
+ * there are no ---/+++ or @@ lines for it to parse.
67
+ *
68
+ * This function:
69
+ * 1. Injects synthetic --- and +++ headers for pure renames/copies so parsePatch
70
+ * creates proper entries with correct filenames
71
+ * 2. Extracts rename/copy metadata (type, from, to, similarity) for each file section
72
+ *
73
+ * @returns processedDiff: diff string safe for parsePatch, renameInfo: metadata per file index
74
+ */
75
+ export function preprocessDiff(rawDiff) {
76
+ const renameInfo = new Map();
77
+ // Split into per-file sections at "diff --git" boundaries
78
+ const lines = rawDiff.split("\n");
79
+ const sections = [];
80
+ let currentSection = null;
81
+ for (const line of lines) {
82
+ if (line.startsWith("diff --git ")) {
83
+ if (currentSection) {
84
+ sections.push({ startIdx: sections.length, lines: currentSection });
85
+ }
86
+ currentSection = [line];
87
+ }
88
+ else if (currentSection) {
89
+ currentSection.push(line);
90
+ }
91
+ // Lines before the first "diff --git" (e.g. commit metadata from git show) are ignored
92
+ }
93
+ if (currentSection) {
94
+ sections.push({ startIdx: sections.length, lines: currentSection });
95
+ }
96
+ // Some callers may pass patch text produced by `diff`'s formatPatch(), which
97
+ // uses "Index:" headers instead of "diff --git". In that case, do not
98
+ // drop the whole payload: return it as-is so parsePatch can still parse hunks.
99
+ if (sections.length === 0) {
100
+ return {
101
+ processedDiff: rawDiff,
102
+ renameInfo,
103
+ };
104
+ }
105
+ const outputSections = [];
106
+ for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
107
+ const section = sections[sectionIdx];
108
+ const sectionLines = section.lines;
109
+ // Extract rename/copy metadata from this section
110
+ let renameFrom;
111
+ let renameTo;
112
+ let copyFrom;
113
+ let copyTo;
114
+ let similarity;
115
+ let hasFileHeaders = false;
116
+ for (const line of sectionLines) {
117
+ if (line.startsWith("--- "))
118
+ hasFileHeaders = true;
119
+ const renameFromMatch = line.match(/^rename from (.+)$/);
120
+ if (renameFromMatch)
121
+ renameFrom = renameFromMatch[1];
122
+ const renameToMatch = line.match(/^rename to (.+)$/);
123
+ if (renameToMatch)
124
+ renameTo = renameToMatch[1];
125
+ const copyFromMatch = line.match(/^copy from (.+)$/);
126
+ if (copyFromMatch)
127
+ copyFrom = copyFromMatch[1];
128
+ const copyToMatch = line.match(/^copy to (.+)$/);
129
+ if (copyToMatch)
130
+ copyTo = copyToMatch[1];
131
+ const similarityMatch = line.match(/^similarity index (\d+)%$/);
132
+ if (similarityMatch)
133
+ similarity = parseInt(similarityMatch[1], 10);
134
+ }
135
+ // Store rename/copy metadata
136
+ if (renameFrom && renameTo) {
137
+ renameInfo.set(sectionIdx, {
138
+ type: "rename",
139
+ from: renameFrom,
140
+ to: renameTo,
141
+ similarity: similarity ?? 100,
142
+ });
143
+ }
144
+ else if (copyFrom && copyTo) {
145
+ renameInfo.set(sectionIdx, {
146
+ type: "copy",
147
+ from: copyFrom,
148
+ to: copyTo,
149
+ similarity: similarity ?? 100,
150
+ });
151
+ }
152
+ // For pure renames/copies (no --- +++ headers), inject synthetic headers
153
+ // so parsePatch creates a proper entry with filenames
154
+ if (!hasFileHeaders && (renameFrom && renameTo)) {
155
+ outputSections.push([...sectionLines, `--- ${renameFrom}`, `+++ ${renameTo}`].join("\n"));
156
+ }
157
+ else if (!hasFileHeaders && (copyFrom && copyTo)) {
158
+ outputSections.push([...sectionLines, `--- ${copyFrom}`, `+++ ${copyTo}`].join("\n"));
159
+ }
160
+ else {
161
+ outputSections.push(sectionLines.join("\n"));
162
+ }
163
+ }
164
+ return {
165
+ processedDiff: outputSections.join("\n"),
166
+ renameInfo,
167
+ };
168
+ }
169
+ /**
170
+ * Parse git diff output with rename/copy detection support.
171
+ * Preprocesses the diff for pure renames, delegates to parsePatch from the `diff` package,
172
+ * and enriches results with rename metadata.
173
+ *
174
+ * Use this instead of calling parsePatch directly when processing git diff -M output.
175
+ *
176
+ * Generic to preserve the concrete type returned by parsePatch (e.g. StructuredPatch).
177
+ */
178
+ export function parseGitDiffFiles(rawDiff, parsePatch) {
179
+ const { processedDiff, renameInfo } = preprocessDiff(rawDiff);
180
+ const files = parsePatch(processedDiff);
181
+ // Enrich files with rename metadata
182
+ return files.map((file, index) => {
183
+ const info = renameInfo.get(index);
184
+ if (!info)
185
+ return file;
186
+ return {
187
+ ...file,
188
+ renameFrom: info.from,
189
+ renameTo: info.to,
190
+ similarity: info.similarity,
191
+ };
192
+ });
193
+ }
194
+ export const IGNORED_FILES = [
195
+ "pnpm-lock.yaml",
196
+ "package-lock.json",
197
+ "yarn.lock",
198
+ "bun.lockb",
199
+ "Cargo.lock",
200
+ "poetry.lock",
201
+ "Gemfile.lock",
202
+ "composer.lock",
203
+ "snapshot.json",
204
+ "worker-configuration.d.ts",
205
+ ];
206
+ /** Default number of context lines around each diff hunk */
207
+ export const DEFAULT_CONTEXT_LINES = 6;
208
+ /**
209
+ * Normalize file filter patterns from both --filter and positional args after --.
210
+ */
211
+ export function getFilterPatterns(options) {
212
+ const filterOptions = options.filter
213
+ ? Array.isArray(options.filter)
214
+ ? options.filter
215
+ : [options.filter]
216
+ : [];
217
+ const positionalFilters = options.positionalFilters || [];
218
+ return [...new Set([...filterOptions, ...positionalFilters].filter((pattern) => pattern.length > 0))];
219
+ }
220
+ /**
221
+ * Check whether a filepath matches any user-provided file filter glob.
222
+ * No patterns means "match everything".
223
+ */
224
+ export function matchesFileFilters(filePath, patterns) {
225
+ if (patterns.length === 0)
226
+ return true;
227
+ return patterns.some((rawPattern) => {
228
+ const pattern = rawPattern.startsWith("./") ? rawPattern.slice(2) : rawPattern;
229
+ if (pattern === "." || pattern === "")
230
+ return true;
231
+ // Keep compatibility with existing git pathspec behavior for plain paths:
232
+ // - "src" should match "src/**"
233
+ // - "src/" should match descendants under src/
234
+ // - "src/file.ts" should match that exact file
235
+ const hasGlobMagic = /[*?[\]{}!]/.test(pattern);
236
+ if (!hasGlobMagic) {
237
+ if (pattern.endsWith("/")) {
238
+ return filePath.startsWith(pattern);
239
+ }
240
+ return filePath === pattern || filePath.startsWith(pattern + "/");
241
+ }
242
+ const glob = new Bun.Glob(pattern);
243
+ return glob.match(filePath);
244
+ });
245
+ }
246
+ /**
247
+ * Apply critique --filter globs to already-parsed diff files.
248
+ * This is used after appending submodule diffs, where git pathspec filters are
249
+ * no longer sufficient.
250
+ */
251
+ export function filterParsedFilesByPatterns(files, options) {
252
+ const patterns = getFilterPatterns(options);
253
+ if (patterns.length === 0)
254
+ return files;
255
+ return files.filter((file) => matchesFileFilters(getFileName(file), patterns));
256
+ }
257
+ /**
258
+ * Build git command string based on options
259
+ */
260
+ export function buildGitCommand(options) {
261
+ const contextArg = `-U${options.context ?? DEFAULT_CONTEXT_LINES}`;
262
+ // Show full submodule diffs instead of just commit hashes
263
+ const submoduleArg = "--submodule=diff";
264
+ // Detect renames instead of showing full delete+add
265
+ const renameArg = "-M";
266
+ // Combine --filter options with positional args after --
267
+ const filters = getFilterPatterns(options);
268
+ // Use single quotes to prevent shell expansion of $ in paths like d.$owner.$repo.$.tsx
269
+ const filterArg = filters.length > 0
270
+ ? `-- ${filters.map((f) => `'${f}'`).join(" ")}`
271
+ : "";
272
+ // If --commit contains range syntax (A..B or A...B), treat it as a base ref
273
+ // instead. git show with ranges outputs commit metadata interleaved with diffs
274
+ // that parsePatch cannot parse. Redirecting to base reuses the existing range
275
+ // handling below (two-dot and three-dot parsing).
276
+ if (options.commit?.includes("..")) {
277
+ options = { ...options, base: options.commit, commit: undefined };
278
+ }
279
+ if (options.staged) {
280
+ return `git diff --cached --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
281
+ }
282
+ if (options.commit) {
283
+ return `git show ${options.commit} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
284
+ }
285
+ // Two refs: compare base...head (three-dot, shows changes since branches diverged, like GitHub PRs)
286
+ if (options.base && options.head) {
287
+ return `git diff ${options.base}...${options.head} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
288
+ }
289
+ // Detect range syntax in single base argument (e.g., "origin/main...HEAD" or "main..feature")
290
+ if (options.base && !options.head) {
291
+ // Three-dot syntax: A...B (merge-base to B, like GitHub PRs)
292
+ const threeDotsMatch = options.base.match(/^(.+)\.\.\.(.+)$/);
293
+ if (threeDotsMatch) {
294
+ const [, rangeBase, rangeHead] = threeDotsMatch;
295
+ return `git diff ${rangeBase}...${rangeHead} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
296
+ }
297
+ // Two-dot syntax: A..B (commits in B not in A)
298
+ const twoDotsMatch = options.base.match(/^(.+)\.\.(.+)$/);
299
+ if (twoDotsMatch) {
300
+ const [, rangeBase, rangeHead] = twoDotsMatch;
301
+ return `git diff ${rangeBase}..${rangeHead} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
302
+ }
303
+ }
304
+ // Single ref: compare ref to working tree (like git diff)
305
+ if (options.base) {
306
+ return `git diff ${options.base} --no-prefix ${renameArg} ${submoduleArg} ${contextArg} ${filterArg}`.trim();
307
+ }
308
+ // Default (no args): ignore submodules here — dirty submodule diffs are fetched
309
+ // separately via buildSubmoduleDiffCommand() to avoid showing committed submodule
310
+ // ref changes that have no actual uncommitted content.
311
+ // Untracked files are appended synthetically in the caller to avoid mutating the
312
+ // git index (git add -N would modify .git/index).
313
+ return `git diff --no-prefix ${renameArg} --ignore-submodules=all ${contextArg} ${filterArg}`.trim();
314
+ }
315
+ /**
316
+ * Get submodule paths that have dirty working trees (uncommitted changes).
317
+ * Returns only submodules with actual uncommitted modifications, not those
318
+ * that merely point to a different commit than what the parent repo recorded.
319
+ *
320
+ * Uses `git submodule status` which prefixes each line with:
321
+ * - ' ' (space): submodule matches recorded commit and is clean
322
+ * - '+': submodule is at a different commit than recorded
323
+ * - '-': submodule is not initialized
324
+ * - 'U': submodule has merge conflicts
325
+ *
326
+ * A submodule with '+' prefix AND a trailing dirty marker (e.g. " (modified content)")
327
+ * or one where `git status --porcelain` inside it is non-empty has dirty changes.
328
+ */
329
+ export function getDirtySubmodulePaths() {
330
+ try {
331
+ // git submodule foreach runs a command in each initialized submodule.
332
+ // We check if the submodule has any uncommitted changes (modified, staged, or untracked).
333
+ // $displaypath gives us the relative path from the parent repo root.
334
+ const output = execSync(`git submodule foreach --quiet 'if [ -n "$(git status --porcelain)" ]; then echo "$displaypath"; fi'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
335
+ return output
336
+ .trim()
337
+ .split("\n")
338
+ .filter((line) => line.length > 0);
339
+ }
340
+ catch {
341
+ // No submodules, or git command failed — return empty
342
+ return [];
343
+ }
344
+ }
345
+ /**
346
+ * Build a git diff command that only shows diffs for specific submodule paths.
347
+ * Used to get the actual file-level diffs inside dirty submodules.
348
+ */
349
+ export function buildSubmoduleDiffCommand(submodulePaths, options) {
350
+ const contextArg = `-U${options.context ?? DEFAULT_CONTEXT_LINES}`;
351
+ const renameArg = "-M";
352
+ const submoduleArg = "--submodule=diff";
353
+ const pathArgs = submodulePaths.map((p) => `'${p}'`).join(" ");
354
+ return `git diff --no-prefix ${renameArg} ${submoduleArg} ${contextArg} -- ${pathArgs}`.trim();
355
+ }
356
+ /**
357
+ * Get paths of untracked files in the working tree.
358
+ */
359
+ export function getUntrackedFilePaths() {
360
+ try {
361
+ const output = execSync("git ls-files --others --exclude-standard", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
362
+ return output
363
+ .trim()
364
+ .split("\n")
365
+ .filter((line) => line.length > 0);
366
+ }
367
+ catch {
368
+ return [];
369
+ }
370
+ }
371
+ /**
372
+ * Check if a buffer contains binary (non-text) content.
373
+ * Uses the same heuristic as git: look for null bytes in the first 8000 bytes.
374
+ */
375
+ function isBinaryContent(buffer) {
376
+ const sample = buffer.slice(0, 8000);
377
+ for (let i = 0; i < sample.length; i++) {
378
+ if (sample[i] === 0)
379
+ return true;
380
+ }
381
+ return false;
382
+ }
383
+ /**
384
+ * Generate a synthetic git diff for an untracked file.
385
+ * Produces output equivalent to `git add -N <file> && git diff <file>`.
386
+ */
387
+ export function buildUntrackedFileDiff(filePath) {
388
+ const repoRoot = getGitRepoRoot();
389
+ const fullPath = join(repoRoot, filePath);
390
+ let content;
391
+ try {
392
+ const buffer = fs.readFileSync(fullPath);
393
+ if (isBinaryContent(buffer)) {
394
+ // Binary files: emit a minimal diff without hunks
395
+ return `diff --git ${filePath} ${filePath}\nnew file mode 100644\nindex 0000000..e69de29\n--- /dev/null\n+++ ${filePath}\n`;
396
+ }
397
+ content = buffer.toString("utf-8");
398
+ }
399
+ catch {
400
+ return "";
401
+ }
402
+ // Handle trailing newline: git diff strips it from the last line
403
+ const endsWithNewline = content.endsWith("\n");
404
+ const lines = content.split("\n");
405
+ // split("\n") on trailing newline gives an extra empty string at the end
406
+ if (endsWithNewline) {
407
+ lines.pop();
408
+ }
409
+ const lineCount = lines.length;
410
+ if (lineCount === 0) {
411
+ return `diff --git ${filePath} ${filePath}\nnew file mode 100644\nindex 0000000..e69de29\n--- /dev/null\n+++ ${filePath}\n`;
412
+ }
413
+ const hunkHeader = `@@ -0,0 +1,${lineCount} @@`;
414
+ const diffLines = lines.map((line) => "+" + line).join("\n");
415
+ // If file doesn't end with newline, mark it
416
+ const noNewlineMarker = endsWithNewline ? "" : "\n\";
417
+ return `diff --git ${filePath} ${filePath}\nnew file mode 100644\nindex 0000000..e69de29\n--- /dev/null\n+++ ${filePath}\n${hunkHeader}\n${diffLines}${noNewlineMarker}`;
418
+ }
419
+ /**
420
+ * Common git diff prefixes that appear in external diffs (git diff, gh pr diff, etc.)
421
+ * - a/, b/: standard git diff prefixes
422
+ * - w/: worktree prefix (git diff develop | dv)
423
+ */
424
+ const COMMON_DIFF_PREFIXES = ["a/", "b/", "w/"];
425
+ /**
426
+ * Strip git diff prefixes from file paths.
427
+ * External diffs (e.g. gh pr diff) include these prefixes, but internal git
428
+ * commands use --no-prefix. Normalizing ensures consistent display.
429
+ */
430
+ function stripDiffPrefix(path) {
431
+ if (!path)
432
+ return path;
433
+ if (path === "/dev/null")
434
+ return path;
435
+ if (COMMON_DIFF_PREFIXES.some((prefix) => path.startsWith(prefix))) {
436
+ return path.slice(2);
437
+ }
438
+ return path;
439
+ }
440
+ /**
441
+ * Get file status from parsed diff file
442
+ * - added: oldFileName is /dev/null (new file)
443
+ * - deleted: newFileName is /dev/null (removed file)
444
+ * - renamed: file has renameFrom/renameTo metadata, or oldFileName !== newFileName
445
+ * (with --no-prefix, different filenames means rename since there's no a/ b/ prefix)
446
+ * - modified: both files exist with same name (changed file)
447
+ */
448
+ export function getFileStatus(file) {
449
+ const oldName = stripDiffPrefix(file.oldFileName);
450
+ const newName = stripDiffPrefix(file.newFileName);
451
+ if (!oldName || oldName === "/dev/null")
452
+ return "added";
453
+ if (!newName || newName === "/dev/null")
454
+ return "deleted";
455
+ // Explicit rename metadata from preprocessDiff
456
+ if (file.renameFrom && file.renameTo)
457
+ return "renamed";
458
+ // With --no-prefix, different filenames means rename
459
+ if (oldName !== newName)
460
+ return "renamed";
461
+ return "modified";
462
+ }
463
+ /**
464
+ * Get filename from parsed diff file, handling /dev/null for new/deleted files.
465
+ * For renames, returns the new name (destination).
466
+ */
467
+ export function getFileName(file) {
468
+ // For renames, prefer the renameTo metadata (always clean, no prefix)
469
+ if (file.renameTo)
470
+ return file.renameTo;
471
+ const newName = stripDiffPrefix(file.newFileName);
472
+ const oldName = stripDiffPrefix(file.oldFileName);
473
+ // Filter out /dev/null which appears for new/deleted files
474
+ if (newName && newName !== "/dev/null")
475
+ return newName;
476
+ if (oldName && oldName !== "/dev/null")
477
+ return oldName;
478
+ return "unknown";
479
+ }
480
+ /**
481
+ * Get the old filename for display purposes (e.g., "old-name.ts -> new-name.ts").
482
+ * Returns undefined if the file was not renamed.
483
+ */
484
+ export function getOldFileName(file) {
485
+ if (file.renameFrom && file.renameTo)
486
+ return file.renameFrom;
487
+ const oldName = stripDiffPrefix(file.oldFileName);
488
+ const newName = stripDiffPrefix(file.newFileName);
489
+ if (oldName && newName && oldName !== newName && oldName !== "/dev/null" && newName !== "/dev/null") {
490
+ return oldName;
491
+ }
492
+ return undefined;
493
+ }
494
+ /**
495
+ * Count additions and deletions from hunks
496
+ */
497
+ export function countChanges(hunks) {
498
+ let additions = 0;
499
+ let deletions = 0;
500
+ for (const hunk of hunks) {
501
+ for (const line of hunk.lines) {
502
+ if (line.startsWith("+"))
503
+ additions++;
504
+ if (line.startsWith("-"))
505
+ deletions++;
506
+ }
507
+ }
508
+ return { additions, deletions };
509
+ }
510
+ /**
511
+ * Determine view mode based on changes and terminal width
512
+ * @param splitThreshold - minimum cols for split view (default 100 for TUI, 150 for web)
513
+ */
514
+ export function getViewMode(additions, deletions, cols, splitThreshold = 100) {
515
+ // Use unified view for fully added or fully deleted files (one side would be empty in split view)
516
+ const isFullyAdded = additions > 0 && deletions === 0;
517
+ const isFullyDeleted = deletions > 0 && additions === 0;
518
+ const useUnifiedForFile = isFullyAdded || isFullyDeleted;
519
+ if (useUnifiedForFile)
520
+ return "unified";
521
+ return cols >= splitThreshold ? "split" : "unified";
522
+ }
523
+ /**
524
+ * Filter and sort parsed diff files, add rawDiff
525
+ */
526
+ export function processFiles(files, formatPatch) {
527
+ const filteredFiles = files.filter((file) => {
528
+ const fileName = getFileName(file);
529
+ const baseName = fileName.split("/").pop() || "";
530
+ if (IGNORED_FILES.includes(baseName) || baseName.endsWith(".lock")) {
531
+ return false;
532
+ }
533
+ const totalLines = file.hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0);
534
+ return totalLines <= 6000;
535
+ });
536
+ const treeFiles = filteredFiles.map((file, index) => {
537
+ const { additions, deletions } = countChanges(file.hunks);
538
+ return {
539
+ path: getFileName(file),
540
+ status: getFileStatus(file),
541
+ additions,
542
+ deletions,
543
+ fileIndex: index,
544
+ };
545
+ });
546
+ const treeFileOrder = buildDirectoryTree(treeFiles)
547
+ .filter((node) => node.isFile && node.fileIndex !== undefined)
548
+ .map((node) => node.fileIndex);
549
+ const seenIndexes = new Set();
550
+ const sortedFiles = [];
551
+ for (const index of treeFileOrder) {
552
+ if (seenIndexes.has(index))
553
+ continue;
554
+ const file = filteredFiles[index];
555
+ if (!file)
556
+ continue;
557
+ seenIndexes.add(index);
558
+ sortedFiles.push(file);
559
+ }
560
+ // Defensive fallback: keep any unmatched files in original order.
561
+ // This should be rare, but avoids dropping files if tree metadata and
562
+ // parsed file list ever diverge.
563
+ for (let index = 0; index < filteredFiles.length; index++) {
564
+ if (seenIndexes.has(index))
565
+ continue;
566
+ const file = filteredFiles[index];
567
+ if (!file)
568
+ continue;
569
+ sortedFiles.push(file);
570
+ }
571
+ // Add rawDiff for each file
572
+ return sortedFiles.map((file) => ({
573
+ ...file,
574
+ rawDiff: formatPatch(file),
575
+ }));
576
+ }
577
+ /**
578
+ * Detect filetype from filename for syntax highlighting
579
+ * Maps to tree-sitter parsers available in @opentuah/core and parsers-config.ts
580
+ */
581
+ export function detectFiletype(filePath) {
582
+ const ext = filePath.split(".").pop()?.toLowerCase();
583
+ switch (ext) {
584
+ // TypeScript parser handles TS, TSX, JS, JSX (it's a superset)
585
+ case "ts":
586
+ case "tsx":
587
+ case "js":
588
+ case "jsx":
589
+ case "mjs":
590
+ case "cjs":
591
+ case "mts":
592
+ case "cts":
593
+ return "typescript";
594
+ case "json":
595
+ case "jsonc":
596
+ case "json5":
597
+ return "json";
598
+ case "md":
599
+ case "mdx":
600
+ case "mkd":
601
+ case "mkdn":
602
+ case "mdown":
603
+ case "markdown":
604
+ return "markdown";
605
+ case "zig":
606
+ return "zig";
607
+ // Languages from parsers-config.ts
608
+ case "py":
609
+ case "pyw":
610
+ case "pyi":
611
+ return "python";
612
+ case "rs":
613
+ return "rust";
614
+ case "go":
615
+ return "go";
616
+ case "cpp":
617
+ case "cc":
618
+ case "cxx":
619
+ case "hpp":
620
+ case "hxx":
621
+ case "hh":
622
+ case "tpp":
623
+ case "ipp":
624
+ case "inl":
625
+ case "h":
626
+ return "cpp";
627
+ case "cs":
628
+ return "csharp";
629
+ case "sh":
630
+ case "bash":
631
+ case "zsh":
632
+ case "ksh":
633
+ return "bash";
634
+ case "c":
635
+ return "c";
636
+ case "java":
637
+ return "java";
638
+ case "rb":
639
+ case "rake":
640
+ case "gemspec":
641
+ return "ruby";
642
+ case "php":
643
+ return "php";
644
+ case "scala":
645
+ case "sc":
646
+ return "scala";
647
+ case "html":
648
+ case "htm":
649
+ case "xhtml":
650
+ case "xml":
651
+ case "svg":
652
+ return "html";
653
+ case "yaml":
654
+ case "yml":
655
+ return "yaml";
656
+ case "hs":
657
+ case "lhs":
658
+ return "haskell";
659
+ case "css":
660
+ case "scss":
661
+ case "less":
662
+ return "css";
663
+ case "jl":
664
+ return "julia";
665
+ case "ml":
666
+ case "mli":
667
+ return "ocaml";
668
+ case "clj":
669
+ case "cljs":
670
+ case "cljc":
671
+ case "edn":
672
+ return "clojure";
673
+ case "swift":
674
+ return "swift";
675
+ case "nix":
676
+ return "nix";
677
+ case "prisma":
678
+ return "prisma";
679
+ default:
680
+ return undefined;
681
+ }
682
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=diff-utils.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff-utils.test.d.ts","sourceRoot":"","sources":["../src/diff-utils.test.ts"],"names":[],"mappings":""}