diffstalker 0.1.7 → 0.2.1

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 (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
@@ -3,18 +3,26 @@
3
3
  import { formatDateAbsolute } from './formatDate.js';
4
4
  import { isDisplayableDiffLine } from './diffFilters.js';
5
5
  import { breakLine, getLineRowCount } from './lineBreaking.js';
6
+ import { computeWordDiff, areSimilarEnough } from './wordDiff.js';
7
+ import { getLanguageFromPath, highlightBlockPreserveBg, } from './languageDetection.js';
6
8
  /**
7
- * Get the text content from a diff line (strip leading +/-/space)
9
+ * Get the text content from a diff line (strip leading +/-/space and control chars)
8
10
  */
9
11
  function getLineContent(line) {
12
+ let content;
10
13
  if (line.type === 'addition' || line.type === 'deletion') {
11
- return line.content.slice(1);
14
+ content = line.content.slice(1);
12
15
  }
13
- if (line.type === 'context') {
16
+ else if (line.type === 'context') {
14
17
  // Context lines start with space
15
- return line.content.startsWith(' ') ? line.content.slice(1) : line.content;
18
+ content = line.content.startsWith(' ') ? line.content.slice(1) : line.content;
16
19
  }
17
- return line.content;
20
+ else {
21
+ content = line.content;
22
+ }
23
+ // Strip control characters that cause rendering artifacts
24
+ // and convert tabs to spaces for consistent width calculation
25
+ return content.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').replace(/\t/g, ' ');
18
26
  }
19
27
  /**
20
28
  * Convert a DiffLine to a DisplayRow
@@ -45,14 +53,185 @@ function convertDiffLineToDisplayRow(line) {
45
53
  };
46
54
  }
47
55
  }
56
+ /**
57
+ * Extract file path from a diff --git header line.
58
+ */
59
+ function extractFilePathFromHeader(content) {
60
+ const match = content.match(/^diff --git a\/.+ b\/(.+)$/);
61
+ return match ? match[1] : null;
62
+ }
48
63
  /**
49
64
  * Build display rows from a DiffResult.
50
65
  * Filters out non-displayable lines (index, ---, +++ headers).
66
+ * Pairs consecutive deletions/additions within hunks and computes word-level diffs.
67
+ * Applies block-based syntax highlighting to properly handle multi-line constructs.
51
68
  */
52
69
  export function buildDiffDisplayRows(diff) {
53
70
  if (!diff)
54
71
  return [];
55
- return diff.lines.filter(isDisplayableDiffLine).map(convertDiffLineToDisplayRow);
72
+ const filteredLines = diff.lines.filter(isDisplayableDiffLine);
73
+ const rows = [];
74
+ const fileSections = [];
75
+ let currentSection = null;
76
+ // Phase 1: Build display rows WITHOUT highlighting
77
+ // Also collect content for block highlighting
78
+ let i = 0;
79
+ while (i < filteredLines.length) {
80
+ const line = filteredLines[i];
81
+ // Headers - start new file section
82
+ if (line.type === 'header') {
83
+ const filePath = extractFilePathFromHeader(line.content);
84
+ if (filePath) {
85
+ // Save previous section if any
86
+ if (currentSection) {
87
+ fileSections.push(currentSection);
88
+ // Add spacer between files for visual separation
89
+ rows.push({ type: 'spacer' });
90
+ }
91
+ // Start new section
92
+ currentSection = {
93
+ language: getLanguageFromPath(filePath),
94
+ startRowIndex: rows.length,
95
+ oldContent: [],
96
+ oldRowIndices: [],
97
+ newContent: [],
98
+ newRowIndices: [],
99
+ };
100
+ }
101
+ rows.push(convertDiffLineToDisplayRow(line));
102
+ i++;
103
+ continue;
104
+ }
105
+ // Hunks - just convert (don't highlight)
106
+ if (line.type === 'hunk') {
107
+ rows.push(convertDiffLineToDisplayRow(line));
108
+ i++;
109
+ continue;
110
+ }
111
+ // Context lines - add to both streams
112
+ if (line.type === 'context') {
113
+ const content = getLineContent(line);
114
+ const rowIndex = rows.length;
115
+ rows.push({
116
+ type: 'diff-context',
117
+ lineNum: line.oldLineNum ?? line.newLineNum,
118
+ content,
119
+ });
120
+ // Context appears in both old and new streams
121
+ if (currentSection && currentSection.language) {
122
+ currentSection.oldContent.push(content);
123
+ currentSection.oldRowIndices.push(rowIndex);
124
+ currentSection.newContent.push(content);
125
+ currentSection.newRowIndices.push(rowIndex);
126
+ }
127
+ i++;
128
+ continue;
129
+ }
130
+ // Collect consecutive deletions
131
+ const deletions = [];
132
+ while (i < filteredLines.length && filteredLines[i].type === 'deletion') {
133
+ deletions.push(filteredLines[i]);
134
+ i++;
135
+ }
136
+ // Collect consecutive additions (immediately following deletions)
137
+ const additions = [];
138
+ while (i < filteredLines.length && filteredLines[i].type === 'addition') {
139
+ additions.push(filteredLines[i]);
140
+ i++;
141
+ }
142
+ // Build arrays to store word diff segments for paired lines
143
+ const delSegmentsMap = new Map();
144
+ const addSegmentsMap = new Map();
145
+ // Pair deletions with additions for word-level diff (only if similar enough)
146
+ const pairCount = Math.min(deletions.length, additions.length);
147
+ for (let j = 0; j < pairCount; j++) {
148
+ const delContent = getLineContent(deletions[j]);
149
+ const addContent = getLineContent(additions[j]);
150
+ // Only compute word diff if lines are similar enough
151
+ if (areSimilarEnough(delContent, addContent)) {
152
+ const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
153
+ delSegmentsMap.set(j, oldSegments);
154
+ addSegmentsMap.set(j, newSegments);
155
+ }
156
+ }
157
+ // Output deletions first (preserving original diff order)
158
+ for (let j = 0; j < deletions.length; j++) {
159
+ const delLine = deletions[j];
160
+ const delContent = getLineContent(delLine);
161
+ const segments = delSegmentsMap.get(j);
162
+ const rowIndex = rows.length;
163
+ rows.push({
164
+ type: 'diff-del',
165
+ lineNum: delLine.oldLineNum,
166
+ content: delContent,
167
+ ...(segments && { wordDiffSegments: segments }),
168
+ });
169
+ // Add to old stream (only if no word-diff, as word-diff takes priority)
170
+ if (currentSection && currentSection.language && !segments) {
171
+ currentSection.oldContent.push(delContent);
172
+ currentSection.oldRowIndices.push(rowIndex);
173
+ }
174
+ }
175
+ // Then output additions
176
+ for (let j = 0; j < additions.length; j++) {
177
+ const addLine = additions[j];
178
+ const addContent = getLineContent(addLine);
179
+ const segments = addSegmentsMap.get(j);
180
+ const rowIndex = rows.length;
181
+ rows.push({
182
+ type: 'diff-add',
183
+ lineNum: addLine.newLineNum,
184
+ content: addContent,
185
+ ...(segments && { wordDiffSegments: segments }),
186
+ });
187
+ // Add to new stream (only if no word-diff, as word-diff takes priority)
188
+ if (currentSection && currentSection.language && !segments) {
189
+ currentSection.newContent.push(addContent);
190
+ currentSection.newRowIndices.push(rowIndex);
191
+ }
192
+ }
193
+ }
194
+ // Save final section
195
+ if (currentSection) {
196
+ fileSections.push(currentSection);
197
+ }
198
+ // Phase 2: Apply block highlighting for each file section
199
+ for (const section of fileSections) {
200
+ if (!section.language)
201
+ continue;
202
+ // Highlight old stream (context + deletions)
203
+ if (section.oldContent.length > 0) {
204
+ const oldHighlighted = highlightBlockPreserveBg(section.oldContent, section.language);
205
+ for (let j = 0; j < section.oldRowIndices.length; j++) {
206
+ const rowIndex = section.oldRowIndices[j];
207
+ const row = rows[rowIndex];
208
+ const highlighted = oldHighlighted[j];
209
+ // Only set highlighted if it's different from content
210
+ if (highlighted &&
211
+ highlighted !== row.content &&
212
+ (row.type === 'diff-del' || row.type === 'diff-context')) {
213
+ row.highlighted = highlighted;
214
+ }
215
+ }
216
+ }
217
+ // Highlight new stream (context + additions)
218
+ if (section.newContent.length > 0) {
219
+ const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
220
+ for (let j = 0; j < section.newRowIndices.length; j++) {
221
+ const rowIndex = section.newRowIndices[j];
222
+ const row = rows[rowIndex];
223
+ const highlighted = newHighlighted[j];
224
+ // Only set highlighted if it's different from content
225
+ // Note: context lines appear in both streams, but result should be same
226
+ if (highlighted &&
227
+ highlighted !== row.content &&
228
+ (row.type === 'diff-add' || row.type === 'diff-context')) {
229
+ row.highlighted = highlighted;
230
+ }
231
+ }
232
+ }
233
+ }
234
+ return rows;
56
235
  }
57
236
  /**
58
237
  * Build display rows from commit + diff (for History tab).
@@ -17,8 +17,8 @@ import { getLanguageFromPath, highlightLine } from './languageDetection.js';
17
17
  export function buildExplorerContentRows(content, filePath, truncated) {
18
18
  if (!content)
19
19
  return [];
20
- const rows = [];
21
20
  const lines = content.split('\n');
21
+ const rows = [];
22
22
  // Detect language for highlighting
23
23
  const language = filePath ? getLanguageFromPath(filePath) : null;
24
24
  for (let i = 0; i < lines.length; i++) {
@@ -24,3 +24,40 @@ export function getFileListSectionCounts(files) {
24
24
  stagedCount: staged.length,
25
25
  };
26
26
  }
27
+ /**
28
+ * Which category does flat index `i` fall in, and what's the position within it?
29
+ * Flat order is: modified → untracked → staged.
30
+ */
31
+ export function getCategoryForIndex(files, index) {
32
+ const { modified, untracked } = categorizeFiles(files);
33
+ const modLen = modified.length;
34
+ const untLen = untracked.length;
35
+ if (index < modLen) {
36
+ return { category: 'modified', categoryIndex: index };
37
+ }
38
+ if (index < modLen + untLen) {
39
+ return { category: 'untracked', categoryIndex: index - modLen };
40
+ }
41
+ return { category: 'staged', categoryIndex: index - modLen - untLen };
42
+ }
43
+ /**
44
+ * Convert category + position back to a flat index (clamped).
45
+ * If the target category is empty, falls back to last file overall, or 0 if no files.
46
+ */
47
+ export function getIndexForCategoryPosition(files, category, categoryIndex) {
48
+ const { modified, untracked, staged, ordered } = categorizeFiles(files);
49
+ if (ordered.length === 0)
50
+ return 0;
51
+ const categories = { modified, untracked, staged };
52
+ const catFiles = categories[category];
53
+ if (catFiles.length === 0) {
54
+ return ordered.length - 1;
55
+ }
56
+ const clampedIndex = Math.min(categoryIndex, catFiles.length - 1);
57
+ const offsets = {
58
+ modified: 0,
59
+ untracked: modified.length,
60
+ staged: modified.length + untracked.length,
61
+ };
62
+ return offsets[category] + clampedIndex;
63
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Utility for building a tree view from flat file paths.
3
+ * Collapses single-child directories into combined path segments.
4
+ */
5
+ /**
6
+ * Build a tree structure from flat file paths.
7
+ * Paths should be sorted alphabetically before calling this.
8
+ */
9
+ export function buildFileTree(files) {
10
+ // Root node
11
+ const root = {
12
+ name: '',
13
+ fullPath: '',
14
+ isDirectory: true,
15
+ children: [],
16
+ depth: 0,
17
+ };
18
+ // Build initial trie structure
19
+ for (let i = 0; i < files.length; i++) {
20
+ const file = files[i];
21
+ const parts = file.path.split('/');
22
+ let current = root;
23
+ for (let j = 0; j < parts.length; j++) {
24
+ const part = parts[j];
25
+ const isFile = j === parts.length - 1;
26
+ const pathSoFar = parts.slice(0, j + 1).join('/');
27
+ let child = current.children.find((c) => c.name === part && c.isDirectory === !isFile);
28
+ if (!child) {
29
+ child = {
30
+ name: part,
31
+ fullPath: pathSoFar,
32
+ isDirectory: !isFile,
33
+ children: [],
34
+ depth: current.depth + 1,
35
+ fileIndex: isFile ? i : undefined,
36
+ };
37
+ current.children.push(child);
38
+ }
39
+ current = child;
40
+ }
41
+ }
42
+ // Collapse single-child directories
43
+ collapseTree(root);
44
+ // Sort children: directories first, then files, alphabetically
45
+ sortTree(root);
46
+ return root;
47
+ }
48
+ /**
49
+ * Collapse single-child directory chains.
50
+ * e.g., a -> b -> c -> file becomes "a/b/c" -> file
51
+ */
52
+ function collapseTree(node) {
53
+ // First, recursively collapse children
54
+ for (const child of node.children) {
55
+ collapseTree(child);
56
+ }
57
+ // Then collapse this node's single-child directory chains
58
+ for (let i = 0; i < node.children.length; i++) {
59
+ const child = node.children[i];
60
+ // Collapse if: directory with exactly one child that is also a directory
61
+ while (child.isDirectory && child.children.length === 1 && child.children[0].isDirectory) {
62
+ const grandchild = child.children[0];
63
+ child.name = `${child.name}/${grandchild.name}`;
64
+ child.fullPath = grandchild.fullPath;
65
+ child.children = grandchild.children;
66
+ // Update depths of all descendants
67
+ updateDepths(child, child.depth);
68
+ }
69
+ }
70
+ }
71
+ /**
72
+ * Update depths recursively after collapsing.
73
+ */
74
+ function updateDepths(node, depth) {
75
+ node.depth = depth;
76
+ for (const child of node.children) {
77
+ updateDepths(child, depth + 1);
78
+ }
79
+ }
80
+ /**
81
+ * Sort tree: directories first (alphabetically), then files (alphabetically).
82
+ */
83
+ function sortTree(node) {
84
+ node.children.sort((a, b) => {
85
+ // Directories before files
86
+ if (a.isDirectory && !b.isDirectory)
87
+ return -1;
88
+ if (!a.isDirectory && b.isDirectory)
89
+ return 1;
90
+ // Alphabetically within same type
91
+ return a.name.localeCompare(b.name);
92
+ });
93
+ for (const child of node.children) {
94
+ sortTree(child);
95
+ }
96
+ }
97
+ /**
98
+ * Flatten tree into a list of row items for rendering.
99
+ * Skips the root node (which has empty name).
100
+ */
101
+ export function flattenTree(root) {
102
+ const rows = [];
103
+ function traverse(node, parentIsLast) {
104
+ for (let i = 0; i < node.children.length; i++) {
105
+ const child = node.children[i];
106
+ const isLast = i === node.children.length - 1;
107
+ rows.push({
108
+ type: child.isDirectory ? 'directory' : 'file',
109
+ name: child.name,
110
+ fullPath: child.fullPath,
111
+ depth: child.depth - 1, // Subtract 1 because root is depth 0
112
+ fileIndex: child.fileIndex,
113
+ isLast,
114
+ parentIsLast: [...parentIsLast],
115
+ });
116
+ if (child.isDirectory) {
117
+ traverse(child, [...parentIsLast, isLast]);
118
+ }
119
+ }
120
+ }
121
+ traverse(root, []);
122
+ return rows;
123
+ }
124
+ /**
125
+ * Build tree prefix for rendering (the │ ├ └ characters).
126
+ */
127
+ export function buildTreePrefix(row) {
128
+ let prefix = '';
129
+ // Add vertical lines for parent levels
130
+ for (let i = 0; i < row.depth; i++) {
131
+ if (row.parentIsLast[i]) {
132
+ prefix += ' '; // Parent was last, no line needed
133
+ }
134
+ else {
135
+ prefix += '│ '; // Parent has siblings below, draw line
136
+ }
137
+ }
138
+ // Add connector for this item
139
+ if (row.depth >= 0) {
140
+ if (row.isLast) {
141
+ prefix += '└ ';
142
+ }
143
+ else {
144
+ prefix += '├ ';
145
+ }
146
+ }
147
+ return prefix;
148
+ }
@@ -155,6 +155,7 @@ export function getLanguageFromPath(filePath) {
155
155
  * Apply syntax highlighting to a line of code.
156
156
  * Returns the highlighted string with ANSI escape codes.
157
157
  * If highlighting fails, returns the original content.
158
+ * Skips highlighting for lines that look like comments (heuristic for multi-line context).
158
159
  */
159
160
  export function highlightLine(content, language) {
160
161
  if (!content || !language)
@@ -168,6 +169,61 @@ export function highlightLine(content, language) {
168
169
  return content;
169
170
  }
170
171
  }
172
+ /**
173
+ * Apply syntax highlighting preserving background color.
174
+ * Replaces full ANSI resets with foreground-only resets so that
175
+ * the caller's background color is not cleared.
176
+ * Returns the highlighted string, or original content if highlighting fails.
177
+ */
178
+ export function highlightLinePreserveBg(content, language) {
179
+ if (!content || !language)
180
+ return content;
181
+ try {
182
+ const result = emphasize.highlight(language, content);
183
+ // Replace full reset (\x1b[0m) with foreground-only reset (\x1b[39m)
184
+ // This preserves any background color set by the caller
185
+ return result.value.replace(/\x1b\[0m/g, '\x1b[39m');
186
+ }
187
+ catch {
188
+ return content;
189
+ }
190
+ }
191
+ /**
192
+ * Highlight multiple lines as a block, preserving multi-line context
193
+ * (e.g., block comments, multi-line strings).
194
+ * Returns an array of highlighted lines.
195
+ */
196
+ export function highlightBlock(lines, language) {
197
+ if (!language || lines.length === 0)
198
+ return lines;
199
+ try {
200
+ // Join lines and highlight as one block to preserve state
201
+ const block = lines.join('\n');
202
+ const result = emphasize.highlight(language, block);
203
+ return result.value.split('\n');
204
+ }
205
+ catch {
206
+ return lines;
207
+ }
208
+ }
209
+ /**
210
+ * Highlight multiple lines as a block, preserving background color.
211
+ * Returns an array of highlighted lines with foreground-only resets.
212
+ */
213
+ export function highlightBlockPreserveBg(lines, language) {
214
+ if (!language || lines.length === 0)
215
+ return lines;
216
+ try {
217
+ const block = lines.join('\n');
218
+ const result = emphasize.highlight(language, block);
219
+ // Replace full resets with foreground-only resets
220
+ const highlighted = result.value.replace(/\x1b\[0m/g, '\x1b[39m');
221
+ return highlighted.split('\n');
222
+ }
223
+ catch {
224
+ return lines;
225
+ }
226
+ }
171
227
  /**
172
228
  * Apply syntax highlighting to multiple lines.
173
229
  * More efficient than calling highlightLine for each line
@@ -0,0 +1,27 @@
1
+ import * as path from 'node:path';
2
+ import * as os from 'node:os';
3
+ /**
4
+ * Expand tilde (~) to home directory.
5
+ */
6
+ export function expandPath(p) {
7
+ if (p.startsWith('~/')) {
8
+ return path.join(os.homedir(), p.slice(2));
9
+ }
10
+ if (p === '~') {
11
+ return os.homedir();
12
+ }
13
+ return p;
14
+ }
15
+ /**
16
+ * Get the last non-empty line from content.
17
+ * Supports append-only files by reading only the last non-empty line.
18
+ */
19
+ export function getLastNonEmptyLine(content) {
20
+ const lines = content.split('\n');
21
+ for (let i = lines.length - 1; i >= 0; i--) {
22
+ const line = lines[i].trim();
23
+ if (line)
24
+ return line;
25
+ }
26
+ return '';
27
+ }
@@ -0,0 +1,50 @@
1
+ // Word-level diff utility using fast-diff
2
+ import fastDiff from 'fast-diff';
3
+ /**
4
+ * Check if two lines are similar enough to warrant word-level diffing.
5
+ * Returns true if they share at least 30% common content.
6
+ */
7
+ export function areSimilarEnough(oldText, newText) {
8
+ if (!oldText || !newText)
9
+ return false;
10
+ const diffs = fastDiff(oldText, newText);
11
+ let commonLength = 0;
12
+ let totalLength = 0;
13
+ for (const [type, text] of diffs) {
14
+ totalLength += text.length;
15
+ if (type === fastDiff.EQUAL) {
16
+ commonLength += text.length;
17
+ }
18
+ }
19
+ if (totalLength === 0)
20
+ return false;
21
+ // Require at least 50% similarity for word-level highlighting to be useful
22
+ const similarity = commonLength / totalLength;
23
+ return similarity >= 0.5;
24
+ }
25
+ /**
26
+ * Compute word-level diff between two strings.
27
+ * Returns segments for both the old (deleted) and new (added) lines,
28
+ * marking which portions changed.
29
+ */
30
+ export function computeWordDiff(oldText, newText) {
31
+ const diffs = fastDiff(oldText, newText);
32
+ const oldSegments = [];
33
+ const newSegments = [];
34
+ for (const [type, text] of diffs) {
35
+ if (type === fastDiff.EQUAL) {
36
+ // Same in both - add to both segment lists
37
+ oldSegments.push({ text, type: 'same' });
38
+ newSegments.push({ text, type: 'same' });
39
+ }
40
+ else if (type === fastDiff.DELETE) {
41
+ // Deleted from old - only in old segments
42
+ oldSegments.push({ text, type: 'changed' });
43
+ }
44
+ else if (type === fastDiff.INSERT) {
45
+ // Inserted in new - only in new segments
46
+ newSegments.push({ text, type: 'changed' });
47
+ }
48
+ }
49
+ return { oldSegments, newSegments };
50
+ }
@@ -0,0 +1,16 @@
1
+ // eslint.metrics.js
2
+ // Used by the metrics script to gather complexity data.
3
+ // Warns at low thresholds so all functions appear in the output.
4
+ import baseConfig from './eslint.config.js';
5
+
6
+ export default [
7
+ ...baseConfig,
8
+ {
9
+ rules: {
10
+ complexity: ['warn', { max: 1 }],
11
+ 'sonarjs/cognitive-complexity': ['warn', 1],
12
+ 'max-depth': ['warn', { max: 1 }],
13
+ 'max-lines-per-function': ['warn', { max: 1 }],
14
+ },
15
+ },
16
+ ];
File without changes