diffstalker 0.2.0 → 0.2.2

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 (46) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. package/dist/utils/rowCalculations.js +0 -246
@@ -85,7 +85,6 @@ export function truncateAnsi(str, maxVisualLength, suffix = '…') {
85
85
  else {
86
86
  // Partial fit - truncate this segment
87
87
  result += segment.content.slice(0, remainingSpace);
88
- currentVisualLength += remainingSpace;
89
88
  truncated = true;
90
89
  break;
91
90
  }
@@ -4,7 +4,7 @@ import { formatDateAbsolute } from './formatDate.js';
4
4
  import { isDisplayableDiffLine } from './diffFilters.js';
5
5
  import { breakLine, getLineRowCount } from './lineBreaking.js';
6
6
  import { computeWordDiff, areSimilarEnough } from './wordDiff.js';
7
- import { getLanguageFromPath, highlightBlockPreserveBg, } from './languageDetection.js';
7
+ import { getLanguageFromPath, highlightBlockPreserveBg } from './languageDetection.js';
8
8
  /**
9
9
  * Get the text content from a diff line (strip leading +/-/space and control chars)
10
10
  */
@@ -73,8 +73,7 @@ export function buildDiffDisplayRows(diff) {
73
73
  const rows = [];
74
74
  const fileSections = [];
75
75
  let currentSection = null;
76
- // Phase 1: Build display rows WITHOUT highlighting
77
- // Also collect content for block highlighting
76
+ // Phase 1: Build display rows and collect content streams per file section
78
77
  let i = 0;
79
78
  while (i < filteredLines.length) {
80
79
  const line = filteredLines[i];
@@ -82,13 +81,10 @@ export function buildDiffDisplayRows(diff) {
82
81
  if (line.type === 'header') {
83
82
  const filePath = extractFilePathFromHeader(line.content);
84
83
  if (filePath) {
85
- // Save previous section if any
86
84
  if (currentSection) {
87
85
  fileSections.push(currentSection);
88
- // Add spacer between files for visual separation
89
86
  rows.push({ type: 'spacer' });
90
87
  }
91
- // Start new section
92
88
  currentSection = {
93
89
  language: getLanguageFromPath(filePath),
94
90
  startRowIndex: rows.length,
@@ -102,7 +98,6 @@ export function buildDiffDisplayRows(diff) {
102
98
  i++;
103
99
  continue;
104
100
  }
105
- // Hunks - just convert (don't highlight)
106
101
  if (line.type === 'hunk') {
107
102
  rows.push(convertDiffLineToDisplayRow(line));
108
103
  i++;
@@ -117,7 +112,6 @@ export function buildDiffDisplayRows(diff) {
117
112
  lineNum: line.oldLineNum ?? line.newLineNum,
118
113
  content,
119
114
  });
120
- // Context appears in both old and new streams
121
115
  if (currentSection && currentSection.language) {
122
116
  currentSection.oldContent.push(content);
123
117
  currentSection.oldRowIndices.push(rowIndex);
@@ -139,22 +133,19 @@ export function buildDiffDisplayRows(diff) {
139
133
  additions.push(filteredLines[i]);
140
134
  i++;
141
135
  }
142
- // Build arrays to store word diff segments for paired lines
136
+ // Pair deletions with additions for word-level diff
143
137
  const delSegmentsMap = new Map();
144
138
  const addSegmentsMap = new Map();
145
- // Pair deletions with additions for word-level diff (only if similar enough)
146
139
  const pairCount = Math.min(deletions.length, additions.length);
147
140
  for (let j = 0; j < pairCount; j++) {
148
141
  const delContent = getLineContent(deletions[j]);
149
142
  const addContent = getLineContent(additions[j]);
150
- // Only compute word diff if lines are similar enough
151
143
  if (areSimilarEnough(delContent, addContent)) {
152
144
  const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
153
145
  delSegmentsMap.set(j, oldSegments);
154
146
  addSegmentsMap.set(j, newSegments);
155
147
  }
156
148
  }
157
- // Output deletions first (preserving original diff order)
158
149
  for (let j = 0; j < deletions.length; j++) {
159
150
  const delLine = deletions[j];
160
151
  const delContent = getLineContent(delLine);
@@ -166,13 +157,11 @@ export function buildDiffDisplayRows(diff) {
166
157
  content: delContent,
167
158
  ...(segments && { wordDiffSegments: segments }),
168
159
  });
169
- // Add to old stream (only if no word-diff, as word-diff takes priority)
170
160
  if (currentSection && currentSection.language && !segments) {
171
161
  currentSection.oldContent.push(delContent);
172
162
  currentSection.oldRowIndices.push(rowIndex);
173
163
  }
174
164
  }
175
- // Then output additions
176
165
  for (let j = 0; j < additions.length; j++) {
177
166
  const addLine = additions[j];
178
167
  const addContent = getLineContent(addLine);
@@ -184,14 +173,12 @@ export function buildDiffDisplayRows(diff) {
184
173
  content: addContent,
185
174
  ...(segments && { wordDiffSegments: segments }),
186
175
  });
187
- // Add to new stream (only if no word-diff, as word-diff takes priority)
188
176
  if (currentSection && currentSection.language && !segments) {
189
177
  currentSection.newContent.push(addContent);
190
178
  currentSection.newRowIndices.push(rowIndex);
191
179
  }
192
180
  }
193
181
  }
194
- // Save final section
195
182
  if (currentSection) {
196
183
  fileSections.push(currentSection);
197
184
  }
@@ -199,14 +186,12 @@ export function buildDiffDisplayRows(diff) {
199
186
  for (const section of fileSections) {
200
187
  if (!section.language)
201
188
  continue;
202
- // Highlight old stream (context + deletions)
203
189
  if (section.oldContent.length > 0) {
204
190
  const oldHighlighted = highlightBlockPreserveBg(section.oldContent, section.language);
205
191
  for (let j = 0; j < section.oldRowIndices.length; j++) {
206
192
  const rowIndex = section.oldRowIndices[j];
207
193
  const row = rows[rowIndex];
208
194
  const highlighted = oldHighlighted[j];
209
- // Only set highlighted if it's different from content
210
195
  if (highlighted &&
211
196
  highlighted !== row.content &&
212
197
  (row.type === 'diff-del' || row.type === 'diff-context')) {
@@ -214,15 +199,12 @@ export function buildDiffDisplayRows(diff) {
214
199
  }
215
200
  }
216
201
  }
217
- // Highlight new stream (context + additions)
218
202
  if (section.newContent.length > 0) {
219
203
  const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
220
204
  for (let j = 0; j < section.newRowIndices.length; j++) {
221
205
  const rowIndex = section.newRowIndices[j];
222
206
  const row = rows[rowIndex];
223
207
  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
208
  if (highlighted &&
227
209
  highlighted !== row.content &&
228
210
  (row.type === 'diff-add' || row.type === 'diff-context')) {
@@ -323,6 +305,33 @@ export function wrapDisplayRows(rows, contentWidth, wrapEnabled) {
323
305
  }
324
306
  return result;
325
307
  }
308
+ /**
309
+ * Find hunk boundaries in a DisplayRow or WrappedDisplayRow array.
310
+ * Each hunk spans from a 'diff-hunk' row to the next 'diff-hunk', 'diff-header', 'spacer', or end.
311
+ */
312
+ export function getHunkBoundaries(rows) {
313
+ const boundaries = [];
314
+ let currentStart = -1;
315
+ for (let i = 0; i < rows.length; i++) {
316
+ const type = rows[i].type;
317
+ if (type === 'diff-hunk') {
318
+ if (currentStart !== -1) {
319
+ boundaries.push({ startRow: currentStart, endRow: i });
320
+ }
321
+ currentStart = i;
322
+ }
323
+ else if (type === 'diff-header' || type === 'spacer') {
324
+ if (currentStart !== -1) {
325
+ boundaries.push({ startRow: currentStart, endRow: i });
326
+ currentStart = -1;
327
+ }
328
+ }
329
+ }
330
+ if (currentStart !== -1) {
331
+ boundaries.push({ startRow: currentStart, endRow: rows.length });
332
+ }
333
+ return boundaries;
334
+ }
326
335
  /**
327
336
  * Calculate the total row count after wrapping.
328
337
  * More efficient than wrapDisplayRows().length when you only need the count.
@@ -349,3 +358,74 @@ export function getWrappedRowCount(rows, contentWidth, wrapEnabled) {
349
358
  }
350
359
  return count;
351
360
  }
361
+ /**
362
+ * Parse the index-referenced line number from a hunk header.
363
+ * Staged diffs (HEAD→index): use +new (new-side = index lines).
364
+ * Unstaged diffs (index→working tree): use -old (old-side = index lines).
365
+ */
366
+ function parseHunkSortKey(hunkContent, source) {
367
+ const m = hunkContent.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/);
368
+ if (!m)
369
+ return 0;
370
+ return source === 'staged' ? parseInt(m[2], 10) : parseInt(m[1], 10);
371
+ }
372
+ /**
373
+ * Extract individual hunks from a DiffResult's lines.
374
+ * Returns file-level header lines separately.
375
+ */
376
+ function extractHunks(diff, source) {
377
+ if (!diff || diff.lines.length === 0)
378
+ return { fileHeaders: [], hunks: [] };
379
+ const fileHeaders = [];
380
+ const hunks = [];
381
+ let currentHunk = null;
382
+ let hunkIdx = 0;
383
+ for (const line of diff.lines) {
384
+ if (line.type === 'header') {
385
+ if (currentHunk) {
386
+ hunks.push(currentHunk);
387
+ currentHunk = null;
388
+ }
389
+ fileHeaders.push(line);
390
+ }
391
+ else if (line.type === 'hunk') {
392
+ if (currentHunk)
393
+ hunks.push(currentHunk);
394
+ currentHunk = {
395
+ headerLine: line,
396
+ bodyLines: [],
397
+ sortKey: parseHunkSortKey(line.content, source),
398
+ source,
399
+ hunkIndex: hunkIdx++,
400
+ };
401
+ }
402
+ else if (currentHunk) {
403
+ currentHunk.bodyLines.push(line);
404
+ }
405
+ }
406
+ if (currentHunk)
407
+ hunks.push(currentHunk);
408
+ return { fileHeaders, hunks };
409
+ }
410
+ /**
411
+ * Build combined display rows from unstaged and staged diffs for the same file.
412
+ * Hunks are interleaved by file position (index line number) into a single
413
+ * unified view. Returns display rows and a mapping from combined hunk index
414
+ * to source (unstaged/staged) and original hunk index.
415
+ */
416
+ export function buildCombinedDiffDisplayRows(unstaged, staged) {
417
+ const u = extractHunks(unstaged, 'unstaged');
418
+ const s = extractHunks(staged, 'staged');
419
+ const allHunks = [...u.hunks, ...s.hunks];
420
+ allHunks.sort((a, b) => a.sortKey - b.sortKey);
421
+ // Build a merged DiffLine array: file headers from whichever has them, then sorted hunks
422
+ const fileHeaders = u.fileHeaders.length > 0 ? u.fileHeaders : s.fileHeaders;
423
+ const mergedLines = [...fileHeaders];
424
+ const hunkMapping = [];
425
+ for (const hunk of allHunks) {
426
+ mergedLines.push(hunk.headerLine, ...hunk.bodyLines);
427
+ hunkMapping.push({ source: hunk.source, hunkIndex: hunk.hunkIndex });
428
+ }
429
+ const rows = buildDiffDisplayRows({ raw: '', lines: mergedLines });
430
+ return { rows, hunkMapping };
431
+ }
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Build a deduplicated, alphabetically sorted flat file list.
3
+ * Files that appear in both staged and unstaged are merged into one entry
4
+ * with stagingState 'partial'.
5
+ */
6
+ export function buildFlatFileList(files, hunkCounts) {
7
+ // Group by path
8
+ const byPath = new Map();
9
+ for (const file of files) {
10
+ const existing = byPath.get(file.path) ?? { staged: null, unstaged: null };
11
+ if (file.staged) {
12
+ existing.staged = file;
13
+ }
14
+ else {
15
+ existing.unstaged = file;
16
+ }
17
+ byPath.set(file.path, existing);
18
+ }
19
+ const result = [];
20
+ for (const [filePath, { staged, unstaged }] of byPath) {
21
+ const stagedHunks = hunkCounts?.staged.get(filePath) ?? 0;
22
+ const unstagedHunks = hunkCounts?.unstaged.get(filePath) ?? 0;
23
+ const totalHunks = stagedHunks + unstagedHunks;
24
+ let stagingState;
25
+ if (staged && unstaged) {
26
+ stagingState = 'partial';
27
+ }
28
+ else if (staged) {
29
+ stagingState = 'staged';
30
+ }
31
+ else {
32
+ stagingState = 'unstaged';
33
+ }
34
+ // Use the unstaged entry as primary (for status), fall back to staged
35
+ const primary = unstaged ?? staged;
36
+ // Combine insertions/deletions from both entries
37
+ let insertions;
38
+ let deletions;
39
+ if (staged?.insertions !== undefined || unstaged?.insertions !== undefined) {
40
+ insertions = (staged?.insertions ?? 0) + (unstaged?.insertions ?? 0);
41
+ }
42
+ if (staged?.deletions !== undefined || unstaged?.deletions !== undefined) {
43
+ deletions = (staged?.deletions ?? 0) + (unstaged?.deletions ?? 0);
44
+ }
45
+ result.push({
46
+ path: filePath,
47
+ status: primary.status,
48
+ stagingState,
49
+ stagedHunks,
50
+ totalHunks,
51
+ insertions,
52
+ deletions,
53
+ originalPath: primary.originalPath,
54
+ stagedEntry: staged,
55
+ unstagedEntry: unstaged,
56
+ });
57
+ }
58
+ // Sort alphabetically by path
59
+ result.sort((a, b) => a.path.localeCompare(b.path));
60
+ return result;
61
+ }
62
+ export function getFlatFileAtIndex(flatFiles, index) {
63
+ return flatFiles[index] ?? null;
64
+ }
65
+ export function getFlatFileIndexByPath(flatFiles, path) {
66
+ return flatFiles.findIndex((f) => f.path === path);
67
+ }
@@ -32,11 +32,13 @@ export function getFileListTotalRows(files) {
32
32
  *
33
33
  * The top pane grows to fit files up to 40% of content height.
34
34
  * The bottom pane gets the remaining space.
35
+ *
36
+ * When flatRowCount is provided (flat view mode), uses that directly instead
37
+ * of computing row count from categorized file list.
35
38
  */
36
- export function calculatePaneHeights(files, contentHeight, maxTopRatio = 0.4) {
39
+ export function calculatePaneHeights(files, contentHeight, maxTopRatio = 0.4, flatRowCount) {
37
40
  // Calculate content rows needed for staging area
38
- // Uses getFileListTotalRows for consistency with FileList rendering
39
- const neededRows = getFileListTotalRows(files);
41
+ const neededRows = flatRowCount !== undefined ? flatRowCount : getFileListTotalRows(files);
40
42
  // Minimum height of 3 (header + 2 lines for empty state)
41
43
  const minHeight = 3;
42
44
  // Maximum is maxTopRatio of content area
@@ -0,0 +1,15 @@
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-lines-per-function': ['warn', { max: 1 }],
13
+ },
14
+ },
15
+ ];
File without changes