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.
- package/.github/workflows/release.yml +8 -0
- package/CHANGELOG.md +36 -0
- package/bun.lock +89 -306
- package/dist/App.js +895 -520
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +632 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +221 -86
- package/dist/git/diff.js +4 -0
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +68 -53
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +195 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/FileFinder.js +232 -0
- package/dist/ui/modals/HotkeysModal.js +209 -0
- package/dist/ui/modals/ThemePicker.js +107 -0
- package/dist/ui/widgets/CommitPanel.js +58 -0
- package/dist/ui/widgets/CompareListView.js +238 -0
- package/dist/ui/widgets/DiffView.js +281 -0
- package/dist/ui/widgets/ExplorerContent.js +89 -0
- package/dist/ui/widgets/ExplorerView.js +204 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +50 -0
- package/dist/ui/widgets/Header.js +68 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/wordDiff.js +50 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +14 -12
- package/dist/components/BaseBranchPicker.js +0 -60
- package/dist/components/BottomPane.js +0 -101
- package/dist/components/CommitPanel.js +0 -58
- package/dist/components/CompareListView.js +0 -110
- package/dist/components/ExplorerContentView.js +0 -80
- package/dist/components/ExplorerView.js +0 -37
- package/dist/components/FileList.js +0 -131
- package/dist/components/Footer.js +0 -6
- package/dist/components/Header.js +0 -107
- package/dist/components/HistoryView.js +0 -21
- package/dist/components/HotkeysModal.js +0 -108
- package/dist/components/Modal.js +0 -19
- package/dist/components/ScrollableList.js +0 -125
- package/dist/components/ThemePicker.js +0 -42
- package/dist/components/TopPane.js +0 -14
- package/dist/components/UnifiedDiffView.js +0 -115
- package/dist/hooks/useCommitFlow.js +0 -66
- package/dist/hooks/useCompareState.js +0 -123
- package/dist/hooks/useExplorerState.js +0 -248
- package/dist/hooks/useGit.js +0 -156
- package/dist/hooks/useHistoryState.js +0 -62
- package/dist/hooks/useKeymap.js +0 -167
- package/dist/hooks/useLayout.js +0 -154
- package/dist/hooks/useMouse.js +0 -87
- package/dist/hooks/useTerminalSize.js +0 -20
- package/dist/hooks/useWatcher.js +0 -137
- package/dist/utils/mouseCoordinates.js +0 -165
- 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
|
-
|
|
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
|
-
|
|
18
|
+
content = line.content.startsWith(' ') ? line.content.slice(1) : line.content;
|
|
16
19
|
}
|
|
17
|
-
|
|
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
|
-
|
|
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
|
+
];
|
package/metrics/.gitkeep
ADDED
|
File without changes
|