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.
Files changed (62) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bun.lock +72 -312
  3. package/dist/App.js +1136 -515
  4. package/dist/core/ExplorerStateManager.js +266 -0
  5. package/dist/core/FilePathWatcher.js +133 -0
  6. package/dist/core/GitStateManager.js +75 -16
  7. package/dist/git/ignoreUtils.js +30 -0
  8. package/dist/git/status.js +2 -34
  9. package/dist/index.js +67 -53
  10. package/dist/ipc/CommandClient.js +165 -0
  11. package/dist/ipc/CommandServer.js +152 -0
  12. package/dist/state/CommitFlowState.js +86 -0
  13. package/dist/state/UIState.js +182 -0
  14. package/dist/types/tabs.js +4 -0
  15. package/dist/ui/Layout.js +252 -0
  16. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  17. package/dist/ui/modals/DiscardConfirm.js +77 -0
  18. package/dist/ui/modals/HotkeysModal.js +209 -0
  19. package/dist/ui/modals/ThemePicker.js +107 -0
  20. package/dist/ui/widgets/CommitPanel.js +58 -0
  21. package/dist/ui/widgets/CompareListView.js +216 -0
  22. package/dist/ui/widgets/DiffView.js +279 -0
  23. package/dist/ui/widgets/ExplorerContent.js +102 -0
  24. package/dist/ui/widgets/ExplorerView.js +95 -0
  25. package/dist/ui/widgets/FileList.js +185 -0
  26. package/dist/ui/widgets/Footer.js +46 -0
  27. package/dist/ui/widgets/Header.js +111 -0
  28. package/dist/ui/widgets/HistoryView.js +69 -0
  29. package/dist/utils/ansiToBlessed.js +125 -0
  30. package/dist/utils/displayRows.js +185 -6
  31. package/dist/utils/explorerDisplayRows.js +1 -1
  32. package/dist/utils/languageDetection.js +56 -0
  33. package/dist/utils/pathUtils.js +27 -0
  34. package/dist/utils/rowCalculations.js +37 -0
  35. package/dist/utils/wordDiff.js +50 -0
  36. package/package.json +11 -12
  37. package/dist/components/BaseBranchPicker.js +0 -60
  38. package/dist/components/BottomPane.js +0 -101
  39. package/dist/components/CommitPanel.js +0 -58
  40. package/dist/components/CompareListView.js +0 -110
  41. package/dist/components/ExplorerContentView.js +0 -80
  42. package/dist/components/ExplorerView.js +0 -37
  43. package/dist/components/FileList.js +0 -131
  44. package/dist/components/Footer.js +0 -6
  45. package/dist/components/Header.js +0 -107
  46. package/dist/components/HistoryView.js +0 -21
  47. package/dist/components/HotkeysModal.js +0 -108
  48. package/dist/components/Modal.js +0 -19
  49. package/dist/components/ScrollableList.js +0 -125
  50. package/dist/components/ThemePicker.js +0 -42
  51. package/dist/components/TopPane.js +0 -14
  52. package/dist/components/UnifiedDiffView.js +0 -115
  53. package/dist/hooks/useCommitFlow.js +0 -66
  54. package/dist/hooks/useCompareState.js +0 -123
  55. package/dist/hooks/useExplorerState.js +0 -248
  56. package/dist/hooks/useGit.js +0 -156
  57. package/dist/hooks/useHistoryState.js +0 -62
  58. package/dist/hooks/useKeymap.js +0 -167
  59. package/dist/hooks/useLayout.js +0 -154
  60. package/dist/hooks/useMouse.js +0 -87
  61. package/dist/hooks/useTerminalSize.js +0 -20
  62. 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
- return line.content.slice(1);
14
+ content = line.content.slice(1);
12
15
  }
13
- if (line.type === 'context') {
16
+ else if (line.type === 'context') {
14
17
  // Context lines start with space
15
- return line.content.startsWith(' ') ? line.content.slice(1) : line.content;
18
+ content = line.content.startsWith(' ') ? line.content.slice(1) : line.content;
16
19
  }
17
- return line.content;
20
+ else {
21
+ content = line.content;
22
+ }
23
+ // Strip control characters that cause rendering artifacts
24
+ // and convert tabs to spaces for consistent width calculation
25
+ return content.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').replace(/\t/g, ' ');
18
26
  }
19
27
  /**
20
28
  * Convert a DiffLine to a DisplayRow
@@ -45,14 +53,185 @@ function convertDiffLineToDisplayRow(line) {
45
53
  };
46
54
  }
47
55
  }
56
+ /**
57
+ * Extract file path from a diff --git header line.
58
+ */
59
+ function extractFilePathFromHeader(content) {
60
+ const match = content.match(/^diff --git a\/.+ b\/(.+)$/);
61
+ return match ? match[1] : null;
62
+ }
48
63
  /**
49
64
  * Build display rows from a DiffResult.
50
65
  * Filters out non-displayable lines (index, ---, +++ headers).
66
+ * Pairs consecutive deletions/additions within hunks and computes word-level diffs.
67
+ * Applies block-based syntax highlighting to properly handle multi-line constructs.
51
68
  */
52
69
  export function buildDiffDisplayRows(diff) {
53
70
  if (!diff)
54
71
  return [];
55
- return diff.lines.filter(isDisplayableDiffLine).map(convertDiffLineToDisplayRow);
72
+ const filteredLines = diff.lines.filter(isDisplayableDiffLine);
73
+ const rows = [];
74
+ const fileSections = [];
75
+ let currentSection = null;
76
+ // Phase 1: Build display rows WITHOUT highlighting
77
+ // Also collect content for block highlighting
78
+ let i = 0;
79
+ while (i < filteredLines.length) {
80
+ const line = filteredLines[i];
81
+ // Headers - start new file section
82
+ if (line.type === 'header') {
83
+ const filePath = extractFilePathFromHeader(line.content);
84
+ if (filePath) {
85
+ // Save previous section if any
86
+ if (currentSection) {
87
+ fileSections.push(currentSection);
88
+ // Add spacer between files for visual separation
89
+ rows.push({ type: 'spacer' });
90
+ }
91
+ // Start new section
92
+ currentSection = {
93
+ language: getLanguageFromPath(filePath),
94
+ startRowIndex: rows.length,
95
+ oldContent: [],
96
+ oldRowIndices: [],
97
+ newContent: [],
98
+ newRowIndices: [],
99
+ };
100
+ }
101
+ rows.push(convertDiffLineToDisplayRow(line));
102
+ i++;
103
+ continue;
104
+ }
105
+ // Hunks - just convert (don't highlight)
106
+ if (line.type === 'hunk') {
107
+ rows.push(convertDiffLineToDisplayRow(line));
108
+ i++;
109
+ continue;
110
+ }
111
+ // Context lines - add to both streams
112
+ if (line.type === 'context') {
113
+ const content = getLineContent(line);
114
+ const rowIndex = rows.length;
115
+ rows.push({
116
+ type: 'diff-context',
117
+ lineNum: line.oldLineNum ?? line.newLineNum,
118
+ content,
119
+ });
120
+ // Context appears in both old and new streams
121
+ if (currentSection && currentSection.language) {
122
+ currentSection.oldContent.push(content);
123
+ currentSection.oldRowIndices.push(rowIndex);
124
+ currentSection.newContent.push(content);
125
+ currentSection.newRowIndices.push(rowIndex);
126
+ }
127
+ i++;
128
+ continue;
129
+ }
130
+ // Collect consecutive deletions
131
+ const deletions = [];
132
+ while (i < filteredLines.length && filteredLines[i].type === 'deletion') {
133
+ deletions.push(filteredLines[i]);
134
+ i++;
135
+ }
136
+ // Collect consecutive additions (immediately following deletions)
137
+ const additions = [];
138
+ while (i < filteredLines.length && filteredLines[i].type === 'addition') {
139
+ additions.push(filteredLines[i]);
140
+ i++;
141
+ }
142
+ // Build arrays to store word diff segments for paired lines
143
+ const delSegmentsMap = new Map();
144
+ const addSegmentsMap = new Map();
145
+ // Pair deletions with additions for word-level diff (only if similar enough)
146
+ const pairCount = Math.min(deletions.length, additions.length);
147
+ for (let j = 0; j < pairCount; j++) {
148
+ const delContent = getLineContent(deletions[j]);
149
+ const addContent = getLineContent(additions[j]);
150
+ // Only compute word diff if lines are similar enough
151
+ if (areSimilarEnough(delContent, addContent)) {
152
+ const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
153
+ delSegmentsMap.set(j, oldSegments);
154
+ addSegmentsMap.set(j, newSegments);
155
+ }
156
+ }
157
+ // Output deletions first (preserving original diff order)
158
+ for (let j = 0; j < deletions.length; j++) {
159
+ const delLine = deletions[j];
160
+ const delContent = getLineContent(delLine);
161
+ const segments = delSegmentsMap.get(j);
162
+ const rowIndex = rows.length;
163
+ rows.push({
164
+ type: 'diff-del',
165
+ lineNum: delLine.oldLineNum,
166
+ content: delContent,
167
+ ...(segments && { wordDiffSegments: segments }),
168
+ });
169
+ // Add to old stream (only if no word-diff, as word-diff takes priority)
170
+ if (currentSection && currentSection.language && !segments) {
171
+ currentSection.oldContent.push(delContent);
172
+ currentSection.oldRowIndices.push(rowIndex);
173
+ }
174
+ }
175
+ // Then output additions
176
+ for (let j = 0; j < additions.length; j++) {
177
+ const addLine = additions[j];
178
+ const addContent = getLineContent(addLine);
179
+ const segments = addSegmentsMap.get(j);
180
+ const rowIndex = rows.length;
181
+ rows.push({
182
+ type: 'diff-add',
183
+ lineNum: addLine.newLineNum,
184
+ content: addContent,
185
+ ...(segments && { wordDiffSegments: segments }),
186
+ });
187
+ // Add to new stream (only if no word-diff, as word-diff takes priority)
188
+ if (currentSection && currentSection.language && !segments) {
189
+ currentSection.newContent.push(addContent);
190
+ currentSection.newRowIndices.push(rowIndex);
191
+ }
192
+ }
193
+ }
194
+ // Save final section
195
+ if (currentSection) {
196
+ fileSections.push(currentSection);
197
+ }
198
+ // Phase 2: Apply block highlighting for each file section
199
+ for (const section of fileSections) {
200
+ if (!section.language)
201
+ continue;
202
+ // Highlight old stream (context + deletions)
203
+ if (section.oldContent.length > 0) {
204
+ const oldHighlighted = highlightBlockPreserveBg(section.oldContent, section.language);
205
+ for (let j = 0; j < section.oldRowIndices.length; j++) {
206
+ const rowIndex = section.oldRowIndices[j];
207
+ const row = rows[rowIndex];
208
+ const highlighted = oldHighlighted[j];
209
+ // Only set highlighted if it's different from content
210
+ if (highlighted &&
211
+ highlighted !== row.content &&
212
+ (row.type === 'diff-del' || row.type === 'diff-context')) {
213
+ row.highlighted = highlighted;
214
+ }
215
+ }
216
+ }
217
+ // Highlight new stream (context + additions)
218
+ if (section.newContent.length > 0) {
219
+ const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
220
+ for (let j = 0; j < section.newRowIndices.length; j++) {
221
+ const rowIndex = section.newRowIndices[j];
222
+ const row = rows[rowIndex];
223
+ const highlighted = newHighlighted[j];
224
+ // Only set highlighted if it's different from content
225
+ // Note: context lines appear in both streams, but result should be same
226
+ if (highlighted &&
227
+ highlighted !== row.content &&
228
+ (row.type === 'diff-add' || row.type === 'diff-context')) {
229
+ row.highlighted = highlighted;
230
+ }
231
+ }
232
+ }
233
+ }
234
+ return rows;
56
235
  }
57
236
  /**
58
237
  * Build display rows from commit + diff (for History tab).
@@ -17,8 +17,8 @@ import { getLanguageFromPath, highlightLine } from './languageDetection.js';
17
17
  export function buildExplorerContentRows(content, filePath, truncated) {
18
18
  if (!content)
19
19
  return [];
20
- const rows = [];
21
20
  const lines = content.split('\n');
21
+ const rows = [];
22
22
  // Detect language for highlighting
23
23
  const language = filePath ? getLanguageFromPath(filePath) : null;
24
24
  for (let i = 0; i < lines.length; i++) {
@@ -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.1.7",
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
- "dev": "bun --watch src/index.tsx",
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 react-devtools-core",
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": "vitest run",
28
- "test:watch": "vitest",
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
- "ink": "^6.6.0",
50
- "ink-text-input": "^6.0.0",
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
- "eslint-plugin-react-hooks": "^7.0.1",
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
- }