diffstalker 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/bun.lock +72 -312
- package/dist/App.js +1136 -515
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +75 -16
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +67 -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 +182 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -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 +216 -0
- package/dist/ui/widgets/DiffView.js +279 -0
- package/dist/ui/widgets/ExplorerContent.js +102 -0
- package/dist/ui/widgets/ExplorerView.js +95 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +46 -0
- package/dist/ui/widgets/Header.js +111 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/ansiToBlessed.js +125 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +37 -0
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +11 -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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert ANSI escape codes to blessed tags.
|
|
3
|
+
* Supports basic foreground colors and styles.
|
|
4
|
+
*/
|
|
5
|
+
// ANSI color code to blessed color name mapping
|
|
6
|
+
const ANSI_FG_COLORS = {
|
|
7
|
+
30: 'black',
|
|
8
|
+
31: 'red',
|
|
9
|
+
32: 'green',
|
|
10
|
+
33: 'yellow',
|
|
11
|
+
34: 'blue',
|
|
12
|
+
35: 'magenta',
|
|
13
|
+
36: 'cyan',
|
|
14
|
+
37: 'white',
|
|
15
|
+
90: 'gray',
|
|
16
|
+
91: 'red',
|
|
17
|
+
92: 'green',
|
|
18
|
+
93: 'yellow',
|
|
19
|
+
94: 'blue',
|
|
20
|
+
95: 'magenta',
|
|
21
|
+
96: 'cyan',
|
|
22
|
+
97: 'white',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Escape blessed tags in plain text.
|
|
26
|
+
*/
|
|
27
|
+
function escapeBlessed(text) {
|
|
28
|
+
return text.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Convert ANSI escape sequences to blessed tags.
|
|
32
|
+
*
|
|
33
|
+
* @param input - String containing ANSI escape codes
|
|
34
|
+
* @returns String with blessed tags
|
|
35
|
+
*/
|
|
36
|
+
export function ansiToBlessed(input) {
|
|
37
|
+
if (!input)
|
|
38
|
+
return '';
|
|
39
|
+
// Track current styles
|
|
40
|
+
const activeStyles = [];
|
|
41
|
+
let result = '';
|
|
42
|
+
let i = 0;
|
|
43
|
+
while (i < input.length) {
|
|
44
|
+
// Check for ANSI escape sequence
|
|
45
|
+
if (input[i] === '\x1b' && input[i + 1] === '[') {
|
|
46
|
+
// Find the end of the sequence (look for 'm')
|
|
47
|
+
let j = i + 2;
|
|
48
|
+
while (j < input.length && input[j] !== 'm') {
|
|
49
|
+
j++;
|
|
50
|
+
}
|
|
51
|
+
if (input[j] === 'm') {
|
|
52
|
+
// Parse the codes
|
|
53
|
+
const codes = input
|
|
54
|
+
.slice(i + 2, j)
|
|
55
|
+
.split(';')
|
|
56
|
+
.map(Number);
|
|
57
|
+
for (const code of codes) {
|
|
58
|
+
if (code === 0) {
|
|
59
|
+
// Reset - close all active styles
|
|
60
|
+
while (activeStyles.length > 0) {
|
|
61
|
+
const style = activeStyles.pop();
|
|
62
|
+
if (style) {
|
|
63
|
+
result += `{/${style}}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (code === 1) {
|
|
68
|
+
// Bold
|
|
69
|
+
activeStyles.push('bold');
|
|
70
|
+
result += '{bold}';
|
|
71
|
+
}
|
|
72
|
+
else if (code === 2) {
|
|
73
|
+
// Dim/faint - blessed doesn't have direct support, use gray
|
|
74
|
+
activeStyles.push('gray-fg');
|
|
75
|
+
result += '{gray-fg}';
|
|
76
|
+
}
|
|
77
|
+
else if (code === 3) {
|
|
78
|
+
// Italic - not well supported in terminals, skip
|
|
79
|
+
}
|
|
80
|
+
else if (code === 4) {
|
|
81
|
+
// Underline
|
|
82
|
+
activeStyles.push('underline');
|
|
83
|
+
result += '{underline}';
|
|
84
|
+
}
|
|
85
|
+
else if (code >= 30 && code <= 37) {
|
|
86
|
+
// Standard foreground colors
|
|
87
|
+
const color = ANSI_FG_COLORS[code];
|
|
88
|
+
if (color) {
|
|
89
|
+
activeStyles.push(`${color}-fg`);
|
|
90
|
+
result += `{${color}-fg}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (code >= 90 && code <= 97) {
|
|
94
|
+
// Bright foreground colors
|
|
95
|
+
const color = ANSI_FG_COLORS[code];
|
|
96
|
+
if (color) {
|
|
97
|
+
activeStyles.push(`${color}-fg`);
|
|
98
|
+
result += `{${color}-fg}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Note: We ignore background colors (40-47, 100-107) for simplicity
|
|
102
|
+
}
|
|
103
|
+
i = j + 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Regular character - escape if needed and append
|
|
108
|
+
const char = input[i];
|
|
109
|
+
if (char === '{' || char === '}') {
|
|
110
|
+
result += char + char;
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
result += char;
|
|
114
|
+
}
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
// Close any remaining active styles
|
|
118
|
+
while (activeStyles.length > 0) {
|
|
119
|
+
const style = activeStyles.pop();
|
|
120
|
+
if (style) {
|
|
121
|
+
result += `{/${style}}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
@@ -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++) {
|
|
@@ -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
|
+
}
|
|
@@ -207,3 +207,40 @@ export function getCompareItemIndexFromRow(row, commitCount, fileCount, commitsE
|
|
|
207
207
|
}
|
|
208
208
|
return -1; // Out of bounds
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Map an item index to its row position in CompareListView.
|
|
212
|
+
* This is the inverse of getCompareItemIndexFromRow.
|
|
213
|
+
*
|
|
214
|
+
* For itemIndex in 0..(commitCount-1): row = 1 + itemIndex
|
|
215
|
+
* For itemIndex in commitCount..(commitCount+fileCount-1): row = 1 + commitCount + 1 + 1 + (itemIndex - commitCount)
|
|
216
|
+
*/
|
|
217
|
+
export function getCompareRowFromItemIndex(itemIndex, commitCount, fileCount, commitsExpanded = true, filesExpanded = true) {
|
|
218
|
+
if (itemIndex < 0)
|
|
219
|
+
return 0;
|
|
220
|
+
// Handle commit items
|
|
221
|
+
if (itemIndex < commitCount) {
|
|
222
|
+
// Row 0 is commits header, so commit i is at row 1 + i
|
|
223
|
+
return commitsExpanded ? 1 + itemIndex : 0;
|
|
224
|
+
}
|
|
225
|
+
// Handle file items
|
|
226
|
+
const fileIndex = itemIndex - commitCount;
|
|
227
|
+
if (fileIndex >= fileCount)
|
|
228
|
+
return 0;
|
|
229
|
+
// Calculate row for file:
|
|
230
|
+
// - commits header (1 row if commits exist)
|
|
231
|
+
// - commits (commitCount rows if expanded)
|
|
232
|
+
// - spacer (1 row if commits exist)
|
|
233
|
+
// - files header (1 row)
|
|
234
|
+
// - file items
|
|
235
|
+
let row = 0;
|
|
236
|
+
if (commitCount > 0) {
|
|
237
|
+
row += 1; // commits header
|
|
238
|
+
if (commitsExpanded)
|
|
239
|
+
row += commitCount;
|
|
240
|
+
row += 1; // spacer
|
|
241
|
+
}
|
|
242
|
+
row += 1; // files header
|
|
243
|
+
if (filesExpanded)
|
|
244
|
+
row += fileIndex;
|
|
245
|
+
return row;
|
|
246
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "diffstalker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Terminal application that displays git diff/status for directories",
|
|
5
5
|
"author": "yogh-io",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,14 +18,15 @@
|
|
|
18
18
|
"diffstalker": "bin/diffstalker"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"
|
|
21
|
+
"postinstall": "node scripts/patch-neo-blessed.cjs",
|
|
22
|
+
"dev": "bun --watch src/index.ts",
|
|
22
23
|
"build": "tsc",
|
|
23
24
|
"build:prod": "tsc && bun build dist/index.js --outfile dist/index.js --minify --target node --packages external",
|
|
24
|
-
"bundle": "bun run build:prod && bun build dist/index.js --outdir dist/bundle --minify --target node --external
|
|
25
|
+
"bundle": "bun run build:prod && bun build dist/index.js --outdir dist/bundle --minify --target node --external neo-blessed",
|
|
25
26
|
"start": "bun dist/index.js",
|
|
26
27
|
"start:bundle": "bun dist/bundle/index.js",
|
|
27
|
-
"test": "
|
|
28
|
-
"test:watch": "
|
|
28
|
+
"test": "bun test src/*.test.ts src/**/*.test.ts",
|
|
29
|
+
"test:watch": "bun test --watch src/*.test.ts src/**/*.test.ts",
|
|
29
30
|
"lint": "eslint src/",
|
|
30
31
|
"lint:fix": "eslint src/ --fix",
|
|
31
32
|
"format": "prettier --write src/",
|
|
@@ -46,22 +47,20 @@
|
|
|
46
47
|
"chokidar": "^4.0.3",
|
|
47
48
|
"emphasize": "^7.0.0",
|
|
48
49
|
"fast-diff": "^1.3.0",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"react": "^19.2.0",
|
|
50
|
+
"ignore": "^7.0.5",
|
|
51
|
+
"neo-blessed": "^0.2.0",
|
|
52
52
|
"simple-git": "^3.27.0",
|
|
53
53
|
"string-width": "^8.1.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@eslint/js": "^9.39.2",
|
|
57
|
+
"@types/blessed": "^0.1.27",
|
|
57
58
|
"@types/node": "^22.10.7",
|
|
58
|
-
"@types/react": "^19.2.0",
|
|
59
59
|
"eslint": "^9.39.2",
|
|
60
60
|
"eslint-config-prettier": "^10.1.8",
|
|
61
|
-
"
|
|
61
|
+
"patch-package": "^8.0.1",
|
|
62
62
|
"prettier": "^3.8.0",
|
|
63
63
|
"typescript": "^5.7.3",
|
|
64
|
-
"typescript-eslint": "^8.53.1"
|
|
65
|
-
"vitest": "^2.1.0"
|
|
64
|
+
"typescript-eslint": "^8.53.1"
|
|
66
65
|
}
|
|
67
66
|
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useMemo } from 'react';
|
|
3
|
-
import { Box, Text, useInput } from 'ink';
|
|
4
|
-
import { Modal, centerModal } from './Modal.js';
|
|
5
|
-
export function BaseBranchPicker({ candidates, currentBranch, onSelect, onCancel, width, height, }) {
|
|
6
|
-
const [inputValue, setInputValue] = useState('');
|
|
7
|
-
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
8
|
-
// Filter candidates based on input
|
|
9
|
-
const filteredCandidates = useMemo(() => {
|
|
10
|
-
if (!inputValue)
|
|
11
|
-
return candidates;
|
|
12
|
-
const lower = inputValue.toLowerCase();
|
|
13
|
-
return candidates.filter((c) => c.toLowerCase().includes(lower));
|
|
14
|
-
}, [candidates, inputValue]);
|
|
15
|
-
// Clamp selected index to valid range
|
|
16
|
-
const clampedIndex = Math.min(selectedIndex, Math.max(0, filteredCandidates.length - 1));
|
|
17
|
-
useInput((input, key) => {
|
|
18
|
-
if (key.escape) {
|
|
19
|
-
onCancel();
|
|
20
|
-
}
|
|
21
|
-
else if (key.return) {
|
|
22
|
-
// If input matches no candidates but has value, use the input as custom branch
|
|
23
|
-
if (filteredCandidates.length === 0 && inputValue) {
|
|
24
|
-
onSelect(inputValue);
|
|
25
|
-
}
|
|
26
|
-
else if (filteredCandidates.length > 0) {
|
|
27
|
-
onSelect(filteredCandidates[clampedIndex]);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
else if (key.upArrow) {
|
|
31
|
-
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
32
|
-
}
|
|
33
|
-
else if (key.downArrow) {
|
|
34
|
-
setSelectedIndex((prev) => Math.min(filteredCandidates.length - 1, prev + 1));
|
|
35
|
-
}
|
|
36
|
-
else if (key.backspace || key.delete) {
|
|
37
|
-
setInputValue((prev) => prev.slice(0, -1));
|
|
38
|
-
setSelectedIndex(0);
|
|
39
|
-
}
|
|
40
|
-
else if (input && !key.ctrl && !key.meta) {
|
|
41
|
-
setInputValue((prev) => prev + input);
|
|
42
|
-
setSelectedIndex(0);
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
// Calculate box dimensions
|
|
46
|
-
const boxWidth = Math.min(60, width - 4);
|
|
47
|
-
const maxListHeight = Math.min(10, height - 10);
|
|
48
|
-
const boxHeight = Math.min(maxListHeight + 9, height - 4); // +9 for header, input, footer, borders
|
|
49
|
-
// Center the modal
|
|
50
|
-
const { x, y } = centerModal(boxWidth, boxHeight, width, height);
|
|
51
|
-
// Visible candidates (with scroll)
|
|
52
|
-
const scrollOffset = Math.max(0, clampedIndex - maxListHeight + 1);
|
|
53
|
-
const visibleCandidates = filteredCandidates.slice(scrollOffset, scrollOffset + maxListHeight);
|
|
54
|
-
return (_jsx(Modal, { x: x, y: y, width: boxWidth, height: boxHeight, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", width: boxWidth, children: [_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: [' ', "Select Base Branch", ' '] }) }), _jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Filter: " }), _jsx(Text, { color: "cyan", children: inputValue }), _jsx(Text, { color: "cyan", children: "\u258C" })] }), _jsx(Box, { flexDirection: "column", height: maxListHeight, children: visibleCandidates.length > 0 ? (visibleCandidates.map((branch, index) => {
|
|
55
|
-
const actualIndex = scrollOffset + index;
|
|
56
|
-
const isSelected = actualIndex === clampedIndex;
|
|
57
|
-
const isCurrent = branch === currentBranch;
|
|
58
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? 'cyan' : undefined, children: isSelected ? '▸ ' : ' ' }), _jsx(Text, { bold: isSelected, color: isSelected ? 'cyan' : undefined, children: branch }), isCurrent && _jsx(Text, { dimColor: true, children: " (current)" })] }, branch));
|
|
59
|
-
})) : inputValue ? (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " No matches. Press Enter to use: " }), _jsx(Text, { color: "yellow", children: inputValue })] })) : (_jsx(Text, { dimColor: true, children: " No candidates found" })) }), filteredCandidates.length > maxListHeight && (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [scrollOffset > 0 ? '↑ ' : ' ', scrollOffset + maxListHeight < filteredCandidates.length ? '↓ more' : ''] }) })), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u2022 Enter select \u2022 Esc cancel" }) })] }) }));
|
|
60
|
-
}
|