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.
- package/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/.github/workflows/release.yml +8 -0
- package/README.md +43 -35
- package/bun.lock +82 -3
- package/dist/App.js +555 -552
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +228 -0
- package/dist/MouseHandlers.js +192 -0
- package/dist/core/ExplorerStateManager.js +423 -78
- package/dist/core/GitStateManager.js +260 -119
- package/dist/git/diff.js +102 -17
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +60 -53
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +39 -4
- package/dist/ui/PaneRenderers.js +76 -0
- package/dist/ui/modals/FileFinder.js +193 -0
- package/dist/ui/modals/HotkeysModal.js +12 -3
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +1 -1
- package/dist/ui/widgets/CompareListView.js +123 -80
- package/dist/ui/widgets/DiffView.js +228 -180
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +148 -43
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -11
- package/dist/ui/widgets/Header.js +17 -52
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +15 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/metrics/v0.2.2.json +229 -0
- package/package.json +9 -2
- package/dist/utils/ansiToBlessed.js +0 -125
- package/dist/utils/mouseCoordinates.js +0 -165
- package/dist/utils/rowCalculations.js +0 -246
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
];
|
package/metrics/.gitkeep
ADDED
|
File without changes
|