diffstalker 0.1.6 → 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 (79) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +378 -0
  4. package/dist/App.js +1162 -1
  5. package/dist/config.js +83 -2
  6. package/dist/core/ExplorerStateManager.js +266 -0
  7. package/dist/core/FilePathWatcher.js +133 -0
  8. package/dist/core/GitOperationQueue.js +109 -1
  9. package/dist/core/GitStateManager.js +525 -1
  10. package/dist/git/diff.js +471 -10
  11. package/dist/git/ignoreUtils.js +30 -0
  12. package/dist/git/status.js +237 -5
  13. package/dist/index.js +70 -16
  14. package/dist/ipc/CommandClient.js +165 -0
  15. package/dist/ipc/CommandServer.js +152 -0
  16. package/dist/services/commitService.js +22 -1
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +182 -0
  19. package/dist/themes.js +127 -1
  20. package/dist/types/tabs.js +4 -0
  21. package/dist/ui/Layout.js +252 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/HotkeysModal.js +209 -0
  25. package/dist/ui/modals/ThemePicker.js +107 -0
  26. package/dist/ui/widgets/CommitPanel.js +58 -0
  27. package/dist/ui/widgets/CompareListView.js +216 -0
  28. package/dist/ui/widgets/DiffView.js +279 -0
  29. package/dist/ui/widgets/ExplorerContent.js +102 -0
  30. package/dist/ui/widgets/ExplorerView.js +95 -0
  31. package/dist/ui/widgets/FileList.js +185 -0
  32. package/dist/ui/widgets/Footer.js +46 -0
  33. package/dist/ui/widgets/Header.js +111 -0
  34. package/dist/ui/widgets/HistoryView.js +69 -0
  35. package/dist/utils/ansiToBlessed.js +125 -0
  36. package/dist/utils/ansiTruncate.js +108 -0
  37. package/dist/utils/baseBranchCache.js +44 -2
  38. package/dist/utils/commitFormat.js +38 -1
  39. package/dist/utils/diffFilters.js +21 -1
  40. package/dist/utils/diffRowCalculations.js +113 -1
  41. package/dist/utils/displayRows.js +351 -2
  42. package/dist/utils/explorerDisplayRows.js +169 -0
  43. package/dist/utils/fileCategories.js +26 -1
  44. package/dist/utils/formatDate.js +39 -1
  45. package/dist/utils/formatPath.js +58 -1
  46. package/dist/utils/languageDetection.js +236 -0
  47. package/dist/utils/layoutCalculations.js +98 -1
  48. package/dist/utils/lineBreaking.js +88 -5
  49. package/dist/utils/mouseCoordinates.js +165 -1
  50. package/dist/utils/pathUtils.js +27 -0
  51. package/dist/utils/rowCalculations.js +246 -4
  52. package/dist/utils/wordDiff.js +50 -0
  53. package/package.json +15 -19
  54. package/dist/components/BaseBranchPicker.js +0 -1
  55. package/dist/components/BottomPane.js +0 -1
  56. package/dist/components/CommitPanel.js +0 -1
  57. package/dist/components/CompareListView.js +0 -1
  58. package/dist/components/ExplorerContentView.js +0 -3
  59. package/dist/components/ExplorerView.js +0 -1
  60. package/dist/components/FileList.js +0 -1
  61. package/dist/components/Footer.js +0 -1
  62. package/dist/components/Header.js +0 -1
  63. package/dist/components/HistoryView.js +0 -1
  64. package/dist/components/HotkeysModal.js +0 -1
  65. package/dist/components/Modal.js +0 -1
  66. package/dist/components/ScrollableList.js +0 -1
  67. package/dist/components/ThemePicker.js +0 -1
  68. package/dist/components/TopPane.js +0 -1
  69. package/dist/components/UnifiedDiffView.js +0 -1
  70. package/dist/hooks/useCommitFlow.js +0 -1
  71. package/dist/hooks/useCompareState.js +0 -1
  72. package/dist/hooks/useExplorerState.js +0 -9
  73. package/dist/hooks/useGit.js +0 -1
  74. package/dist/hooks/useHistoryState.js +0 -1
  75. package/dist/hooks/useKeymap.js +0 -1
  76. package/dist/hooks/useLayout.js +0 -1
  77. package/dist/hooks/useMouse.js +0 -1
  78. package/dist/hooks/useTerminalSize.js +0 -1
  79. package/dist/hooks/useWatcher.js +0 -11
package/dist/themes.js CHANGED
@@ -1 +1,127 @@
1
- const i={name:"dark",displayName:"Dark",colors:{addBg:"#022800",delBg:"#3D0100",addHighlight:"#044700",delHighlight:"#5C0200",text:"white",addLineNum:"#368F35",delLineNum:"#A14040",contextLineNum:"gray",addSymbol:"greenBright",delSymbol:"redBright"}},l={name:"light",displayName:"Light",colors:{addBg:"#69db7c",delBg:"#ffa8b4",addHighlight:"#2f9d44",delHighlight:"#d1454b",text:"black",addLineNum:"#2f9d44",delLineNum:"#d1454b",contextLineNum:"#6c757d",addSymbol:"green",delSymbol:"red"}},t={name:"dark-colorblind",displayName:"Dark (colorblind)",colors:{addBg:"#004466",delBg:"#660000",addHighlight:"#0077b3",delHighlight:"#b30000",text:"white",addLineNum:"#0077b3",delLineNum:"#b30000",contextLineNum:"gray",addSymbol:"cyanBright",delSymbol:"redBright"}},r={name:"light-colorblind",displayName:"Light (colorblind)",colors:{addBg:"#99ccff",delBg:"#ffcccc",addHighlight:"#3366cc",delHighlight:"#993333",text:"black",addLineNum:"#3366cc",delLineNum:"#993333",contextLineNum:"#6c757d",addSymbol:"blue",delSymbol:"red"}},n={name:"dark-ansi",displayName:"Dark (ANSI)",colors:{addBg:"green",delBg:"red",addHighlight:"greenBright",delHighlight:"redBright",text:"white",addLineNum:"greenBright",delLineNum:"redBright",contextLineNum:"gray",addSymbol:"greenBright",delSymbol:"redBright"}},g={name:"light-ansi",displayName:"Light (ANSI)",colors:{addBg:"green",delBg:"red",addHighlight:"greenBright",delHighlight:"redBright",text:"black",addLineNum:"green",delLineNum:"red",contextLineNum:"gray",addSymbol:"green",delSymbol:"red"}};export const themes={dark:i,light:l,"dark-colorblind":t,"light-colorblind":r,"dark-ansi":n,"light-ansi":g},themeOrder=["dark","light","dark-colorblind","light-colorblind","dark-ansi","light-ansi"];export function getTheme(e){return themes[e]??themes.dark}export function getNextTheme(e){const d=(themeOrder.indexOf(e)+1)%themeOrder.length;return themeOrder[d]}
1
+ // Theme definitions for diffstalker
2
+ // Dark theme - sampled from Claude Code's dark mode
3
+ const darkTheme = {
4
+ name: 'dark',
5
+ displayName: 'Dark',
6
+ colors: {
7
+ addBg: '#022800', // sampled: rgb(2,40,0)
8
+ delBg: '#3D0100', // sampled: rgb(61,1,0)
9
+ addHighlight: '#044700', // sampled: rgb(4,71,0)
10
+ delHighlight: '#5C0200', // sampled: rgb(92,2,0)
11
+ text: 'white',
12
+ addLineNum: '#368F35', // sampled: rgb(54,143,53)
13
+ delLineNum: '#A14040', // sampled: rgb(161,64,64)
14
+ contextLineNum: 'gray',
15
+ addSymbol: 'greenBright',
16
+ delSymbol: 'redBright',
17
+ },
18
+ };
19
+ // Light theme - matches Claude Code's light mode colors
20
+ const lightTheme = {
21
+ name: 'light',
22
+ displayName: 'Light',
23
+ colors: {
24
+ addBg: '#69db7c', // rgb(105,219,124)
25
+ delBg: '#ffa8b4', // rgb(255,168,180)
26
+ addHighlight: '#2f9d44', // rgb(47,157,68)
27
+ delHighlight: '#d1454b', // rgb(209,69,75)
28
+ text: 'black',
29
+ addLineNum: '#2f9d44',
30
+ delLineNum: '#d1454b',
31
+ contextLineNum: '#6c757d',
32
+ addSymbol: 'green',
33
+ delSymbol: 'red',
34
+ },
35
+ };
36
+ // Dark colorblind theme - matches Claude Code's dark-daltonized colors
37
+ const darkColorblindTheme = {
38
+ name: 'dark-colorblind',
39
+ displayName: 'Dark (colorblind)',
40
+ colors: {
41
+ addBg: '#004466', // rgb(0,68,102)
42
+ delBg: '#660000', // rgb(102,0,0)
43
+ addHighlight: '#0077b3', // rgb(0,119,179)
44
+ delHighlight: '#b30000', // rgb(179,0,0)
45
+ text: 'white',
46
+ addLineNum: '#0077b3',
47
+ delLineNum: '#b30000',
48
+ contextLineNum: 'gray',
49
+ addSymbol: 'cyanBright',
50
+ delSymbol: 'redBright',
51
+ },
52
+ };
53
+ // Light colorblind theme - matches Claude Code's light-daltonized colors
54
+ const lightColorblindTheme = {
55
+ name: 'light-colorblind',
56
+ displayName: 'Light (colorblind)',
57
+ colors: {
58
+ addBg: '#99ccff', // rgb(153,204,255)
59
+ delBg: '#ffcccc', // rgb(255,204,204)
60
+ addHighlight: '#3366cc', // rgb(51,102,204)
61
+ delHighlight: '#993333', // rgb(153,51,51)
62
+ text: 'black',
63
+ addLineNum: '#3366cc',
64
+ delLineNum: '#993333',
65
+ contextLineNum: '#6c757d',
66
+ addSymbol: 'blue',
67
+ delSymbol: 'red',
68
+ },
69
+ };
70
+ // Dark ANSI theme - uses terminal's native 16 ANSI colors
71
+ const darkAnsiTheme = {
72
+ name: 'dark-ansi',
73
+ displayName: 'Dark (ANSI)',
74
+ colors: {
75
+ addBg: 'green',
76
+ delBg: 'red',
77
+ addHighlight: 'greenBright',
78
+ delHighlight: 'redBright',
79
+ text: 'white',
80
+ addLineNum: 'greenBright',
81
+ delLineNum: 'redBright',
82
+ contextLineNum: 'gray',
83
+ addSymbol: 'greenBright',
84
+ delSymbol: 'redBright',
85
+ },
86
+ };
87
+ // Light ANSI theme - uses terminal's native 16 ANSI colors
88
+ const lightAnsiTheme = {
89
+ name: 'light-ansi',
90
+ displayName: 'Light (ANSI)',
91
+ colors: {
92
+ addBg: 'green',
93
+ delBg: 'red',
94
+ addHighlight: 'greenBright',
95
+ delHighlight: 'redBright',
96
+ text: 'black',
97
+ addLineNum: 'green',
98
+ delLineNum: 'red',
99
+ contextLineNum: 'gray',
100
+ addSymbol: 'green',
101
+ delSymbol: 'red',
102
+ },
103
+ };
104
+ export const themes = {
105
+ dark: darkTheme,
106
+ light: lightTheme,
107
+ 'dark-colorblind': darkColorblindTheme,
108
+ 'light-colorblind': lightColorblindTheme,
109
+ 'dark-ansi': darkAnsiTheme,
110
+ 'light-ansi': lightAnsiTheme,
111
+ };
112
+ export const themeOrder = [
113
+ 'dark',
114
+ 'light',
115
+ 'dark-colorblind',
116
+ 'light-colorblind',
117
+ 'dark-ansi',
118
+ 'light-ansi',
119
+ ];
120
+ export function getTheme(name) {
121
+ return themes[name] ?? themes['dark'];
122
+ }
123
+ export function getNextTheme(current) {
124
+ const currentIndex = themeOrder.indexOf(current);
125
+ const nextIndex = (currentIndex + 1) % themeOrder.length;
126
+ return themeOrder[nextIndex];
127
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared type definitions for tabs and panes.
3
+ */
4
+ export {};
@@ -0,0 +1,252 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * Layout constants matching the React/Ink implementation.
4
+ */
5
+ export const LAYOUT_OVERHEAD = 5; // Header (1-2) + 3 separators + footer (1)
6
+ export const SPLIT_RATIO_STEP = 0.05;
7
+ /**
8
+ * Calculate layout dimensions based on terminal size and split ratio.
9
+ */
10
+ export function calculateLayout(terminalHeight, terminalWidth, splitRatio, headerHeight = 1) {
11
+ // Total overhead: header + 3 separators + footer
12
+ const overhead = headerHeight + 4; // 3 separators + 1 footer
13
+ const availableHeight = terminalHeight - overhead;
14
+ const topPaneHeight = Math.floor(availableHeight * splitRatio);
15
+ const bottomPaneHeight = availableHeight - topPaneHeight;
16
+ return {
17
+ width: terminalWidth,
18
+ height: terminalHeight,
19
+ headerHeight,
20
+ topPaneHeight,
21
+ bottomPaneHeight,
22
+ footerRow: terminalHeight - 1,
23
+ };
24
+ }
25
+ /**
26
+ * Calculate pane boundaries for mouse click detection.
27
+ */
28
+ export function calculatePaneBoundaries(terminalHeight, headerHeight, topPaneHeight, bottomPaneHeight) {
29
+ const stagingPaneStart = headerHeight + 1; // After header + separator
30
+ const fileListEnd = stagingPaneStart + topPaneHeight;
31
+ const diffPaneStart = fileListEnd + 1; // After separator
32
+ const diffPaneEnd = diffPaneStart + bottomPaneHeight;
33
+ const footerRow = terminalHeight - 1;
34
+ return {
35
+ stagingPaneStart,
36
+ fileListEnd,
37
+ diffPaneStart,
38
+ diffPaneEnd,
39
+ footerRow,
40
+ };
41
+ }
42
+ /**
43
+ * LayoutManager creates and manages blessed boxes for the two-pane layout.
44
+ */
45
+ export class LayoutManager {
46
+ screen;
47
+ headerBox;
48
+ topSeparator;
49
+ topPane;
50
+ middleSeparator;
51
+ bottomPane;
52
+ bottomSeparator;
53
+ footerBox;
54
+ _dimensions;
55
+ _splitRatio;
56
+ constructor(screen, splitRatio = 0.4) {
57
+ this.screen = screen;
58
+ this._splitRatio = splitRatio;
59
+ this._dimensions = this.calculateDimensions();
60
+ // Create all layout boxes
61
+ this.headerBox = this.createHeaderBox();
62
+ this.topSeparator = this.createSeparator(this._dimensions.headerHeight);
63
+ this.topPane = this.createTopPane();
64
+ this.middleSeparator = this.createSeparator(this._dimensions.headerHeight + 1 + this._dimensions.topPaneHeight);
65
+ this.bottomPane = this.createBottomPane();
66
+ this.bottomSeparator = this.createSeparator(this._dimensions.headerHeight +
67
+ 2 +
68
+ this._dimensions.topPaneHeight +
69
+ this._dimensions.bottomPaneHeight);
70
+ this.footerBox = this.createFooterBox();
71
+ // Handle screen resize
72
+ screen.on('resize', () => this.onResize());
73
+ }
74
+ get dimensions() {
75
+ return this._dimensions;
76
+ }
77
+ get splitRatio() {
78
+ return this._splitRatio;
79
+ }
80
+ setSplitRatio(ratio) {
81
+ this._splitRatio = Math.min(0.85, Math.max(0.15, ratio));
82
+ this.updateLayout();
83
+ }
84
+ adjustSplitRatio(delta) {
85
+ this.setSplitRatio(this._splitRatio + delta);
86
+ }
87
+ calculateDimensions() {
88
+ const height = this.screen.height || 24;
89
+ const width = this.screen.width || 80;
90
+ return calculateLayout(height, width, this._splitRatio);
91
+ }
92
+ createHeaderBox() {
93
+ return blessed.box({
94
+ parent: this.screen,
95
+ top: 0,
96
+ left: 0,
97
+ width: '100%',
98
+ height: this._dimensions.headerHeight,
99
+ tags: true,
100
+ });
101
+ }
102
+ createSeparator(top) {
103
+ const width = this.screen.width || 80;
104
+ return blessed.box({
105
+ parent: this.screen,
106
+ top,
107
+ left: 0,
108
+ width: '100%',
109
+ height: 1,
110
+ content: '\u2500'.repeat(width),
111
+ style: {
112
+ fg: 'gray',
113
+ },
114
+ });
115
+ }
116
+ createTopPane() {
117
+ return blessed.box({
118
+ parent: this.screen,
119
+ top: this._dimensions.headerHeight + 1,
120
+ left: 0,
121
+ width: '100%',
122
+ height: this._dimensions.topPaneHeight,
123
+ tags: true,
124
+ scrollable: true,
125
+ alwaysScroll: true,
126
+ wrap: false, // Disable blessed's built-in wrapping - we handle wrapping ourselves
127
+ scrollbar: {
128
+ ch: ' ',
129
+ track: {
130
+ bg: 'gray',
131
+ },
132
+ style: {
133
+ inverse: true,
134
+ },
135
+ },
136
+ });
137
+ }
138
+ createBottomPane() {
139
+ return blessed.box({
140
+ parent: this.screen,
141
+ top: this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight,
142
+ left: 0,
143
+ width: '100%',
144
+ height: this._dimensions.bottomPaneHeight,
145
+ tags: true,
146
+ scrollable: true,
147
+ alwaysScroll: true,
148
+ wrap: false, // Disable blessed's built-in wrapping - we handle wrapping ourselves
149
+ scrollbar: {
150
+ ch: ' ',
151
+ track: {
152
+ bg: 'gray',
153
+ },
154
+ style: {
155
+ inverse: true,
156
+ },
157
+ },
158
+ });
159
+ }
160
+ createFooterBox() {
161
+ return blessed.box({
162
+ parent: this.screen,
163
+ top: this._dimensions.footerRow,
164
+ left: 0,
165
+ width: '100%',
166
+ height: 1,
167
+ tags: true,
168
+ });
169
+ }
170
+ onResize() {
171
+ this._dimensions = this.calculateDimensions();
172
+ this.updateLayout();
173
+ // Don't call screen.render() here - App's resize handler will render
174
+ // with properly recalculated content
175
+ }
176
+ updateLayout() {
177
+ this._dimensions = this.calculateDimensions();
178
+ const width = this.screen.width || 80;
179
+ // Update header
180
+ this.headerBox.height = this._dimensions.headerHeight;
181
+ this.headerBox.width = width;
182
+ // Update top separator
183
+ this.topSeparator.top = this._dimensions.headerHeight;
184
+ this.topSeparator.width = width;
185
+ this.topSeparator.setContent('\u2500'.repeat(width));
186
+ // Update top pane
187
+ this.topPane.top = this._dimensions.headerHeight + 1;
188
+ this.topPane.height = this._dimensions.topPaneHeight;
189
+ this.topPane.width = width;
190
+ // Update middle separator
191
+ this.middleSeparator.top = this._dimensions.headerHeight + 1 + this._dimensions.topPaneHeight;
192
+ this.middleSeparator.width = width;
193
+ this.middleSeparator.setContent('\u2500'.repeat(width));
194
+ // Update bottom pane
195
+ this.bottomPane.top = this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight;
196
+ this.bottomPane.height = this._dimensions.bottomPaneHeight;
197
+ this.bottomPane.width = width;
198
+ // Update bottom separator
199
+ this.bottomSeparator.top =
200
+ this._dimensions.headerHeight +
201
+ 2 +
202
+ this._dimensions.topPaneHeight +
203
+ this._dimensions.bottomPaneHeight;
204
+ this.bottomSeparator.width = width;
205
+ this.bottomSeparator.setContent('\u2500'.repeat(width));
206
+ // Update footer
207
+ this.footerBox.top = this._dimensions.footerRow;
208
+ this.footerBox.width = width;
209
+ }
210
+ /**
211
+ * Get pane boundaries for mouse click detection.
212
+ */
213
+ getPaneBoundaries() {
214
+ return calculatePaneBoundaries(this._dimensions.height, this._dimensions.headerHeight, this._dimensions.topPaneHeight, this._dimensions.bottomPaneHeight);
215
+ }
216
+ /**
217
+ * Convert screen Y coordinate to content row within the top pane.
218
+ * Returns the 0-based row index of the content, or -1 if outside the pane.
219
+ */
220
+ screenYToTopPaneRow(screenY) {
221
+ const paneTop = this._dimensions.headerHeight + 1; // header + separator
222
+ const paneBottom = paneTop + this._dimensions.topPaneHeight;
223
+ if (screenY < paneTop || screenY >= paneBottom) {
224
+ return -1;
225
+ }
226
+ return screenY - paneTop;
227
+ }
228
+ /**
229
+ * Convert screen Y coordinate to content row within the bottom pane.
230
+ * Returns the 0-based row index of the content, or -1 if outside the pane.
231
+ */
232
+ screenYToBottomPaneRow(screenY) {
233
+ const paneTop = this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight; // header + 2 separators + top pane
234
+ const paneBottom = paneTop + this._dimensions.bottomPaneHeight;
235
+ if (screenY < paneTop || screenY >= paneBottom) {
236
+ return -1;
237
+ }
238
+ return screenY - paneTop;
239
+ }
240
+ /**
241
+ * Get the top position of the top pane (for reference).
242
+ */
243
+ get topPaneTop() {
244
+ return this._dimensions.headerHeight + 1;
245
+ }
246
+ /**
247
+ * Get the top position of the bottom pane (for reference).
248
+ */
249
+ get bottomPaneTop() {
250
+ return this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight;
251
+ }
252
+ }
@@ -0,0 +1,110 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * BaseBranchPicker modal for selecting the base branch for PR comparison.
4
+ */
5
+ export class BaseBranchPicker {
6
+ box;
7
+ screen;
8
+ branches;
9
+ selectedIndex;
10
+ currentBranch;
11
+ onSelect;
12
+ onCancel;
13
+ constructor(screen, branches, currentBranch, onSelect, onCancel) {
14
+ this.screen = screen;
15
+ this.branches = branches;
16
+ this.currentBranch = currentBranch;
17
+ this.onSelect = onSelect;
18
+ this.onCancel = onCancel;
19
+ // Find current branch index
20
+ this.selectedIndex = currentBranch ? branches.indexOf(currentBranch) : 0;
21
+ if (this.selectedIndex < 0)
22
+ this.selectedIndex = 0;
23
+ // Create modal box
24
+ const width = 50;
25
+ const maxVisibleBranches = Math.min(branches.length, 15);
26
+ const height = maxVisibleBranches + 6; // branches + header + footer + borders + padding
27
+ this.box = blessed.box({
28
+ parent: screen,
29
+ top: 'center',
30
+ left: 'center',
31
+ width,
32
+ height,
33
+ border: {
34
+ type: 'line',
35
+ },
36
+ style: {
37
+ border: {
38
+ fg: 'cyan',
39
+ },
40
+ },
41
+ tags: true,
42
+ keys: true,
43
+ scrollable: true,
44
+ alwaysScroll: true,
45
+ });
46
+ // Setup key handlers
47
+ this.setupKeyHandlers();
48
+ // Initial render
49
+ this.render();
50
+ }
51
+ setupKeyHandlers() {
52
+ this.box.key(['escape', 'q'], () => {
53
+ this.close();
54
+ this.onCancel();
55
+ });
56
+ this.box.key(['enter', 'space'], () => {
57
+ const selected = this.branches[this.selectedIndex];
58
+ if (selected) {
59
+ this.close();
60
+ this.onSelect(selected);
61
+ }
62
+ });
63
+ this.box.key(['up', 'k'], () => {
64
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
65
+ this.render();
66
+ });
67
+ this.box.key(['down', 'j'], () => {
68
+ this.selectedIndex = Math.min(this.branches.length - 1, this.selectedIndex + 1);
69
+ this.render();
70
+ });
71
+ }
72
+ render() {
73
+ const lines = [];
74
+ // Header
75
+ lines.push('{bold}{cyan-fg} Select Base Branch{/cyan-fg}{/bold}');
76
+ lines.push('');
77
+ if (this.branches.length === 0) {
78
+ lines.push('{gray-fg}No branches found{/gray-fg}');
79
+ }
80
+ else {
81
+ // Branch list
82
+ for (let i = 0; i < this.branches.length; i++) {
83
+ const branch = this.branches[i];
84
+ const isSelected = i === this.selectedIndex;
85
+ const isCurrent = branch === this.currentBranch;
86
+ let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
87
+ line += branch;
88
+ if (isSelected)
89
+ line += '{/bold}{/cyan-fg}';
90
+ if (isCurrent)
91
+ line += ' {gray-fg}(current){/gray-fg}';
92
+ lines.push(line);
93
+ }
94
+ }
95
+ // Footer
96
+ lines.push('');
97
+ lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
98
+ this.box.setContent(lines.join('\n'));
99
+ this.screen.render();
100
+ }
101
+ close() {
102
+ this.box.destroy();
103
+ }
104
+ /**
105
+ * Focus the modal.
106
+ */
107
+ focus() {
108
+ this.box.focus();
109
+ }
110
+ }
@@ -0,0 +1,77 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * DiscardConfirm modal for confirming discard of file changes.
4
+ */
5
+ export class DiscardConfirm {
6
+ box;
7
+ screen;
8
+ filePath;
9
+ onConfirm;
10
+ onCancel;
11
+ constructor(screen, filePath, onConfirm, onCancel) {
12
+ this.screen = screen;
13
+ this.filePath = filePath;
14
+ this.onConfirm = onConfirm;
15
+ this.onCancel = onCancel;
16
+ // Create modal box - small confirmation dialog
17
+ const width = Math.min(60, Math.max(40, filePath.length + 20));
18
+ const height = 7;
19
+ this.box = blessed.box({
20
+ parent: screen,
21
+ top: 'center',
22
+ left: 'center',
23
+ width,
24
+ height,
25
+ border: {
26
+ type: 'line',
27
+ },
28
+ style: {
29
+ border: {
30
+ fg: 'yellow',
31
+ },
32
+ },
33
+ tags: true,
34
+ keys: true,
35
+ });
36
+ // Setup key handlers
37
+ this.setupKeyHandlers();
38
+ // Render content
39
+ this.render();
40
+ }
41
+ setupKeyHandlers() {
42
+ this.box.key(['y', 'Y'], () => {
43
+ this.close();
44
+ this.onConfirm();
45
+ });
46
+ this.box.key(['n', 'N', 'escape', 'q'], () => {
47
+ this.close();
48
+ this.onCancel();
49
+ });
50
+ }
51
+ render() {
52
+ const lines = [];
53
+ // Header
54
+ lines.push('{bold}{yellow-fg} Discard Changes?{/yellow-fg}{/bold}');
55
+ lines.push('');
56
+ // File path (truncate if needed)
57
+ const maxPathLen = this.box.width - 6;
58
+ const displayPath = this.filePath.length > maxPathLen
59
+ ? '...' + this.filePath.slice(-(maxPathLen - 3))
60
+ : this.filePath;
61
+ lines.push(`{white-fg}${displayPath}{/white-fg}`);
62
+ lines.push('');
63
+ // Prompt
64
+ lines.push('{gray-fg}Press {/gray-fg}{green-fg}y{/green-fg}{gray-fg} to confirm, {/gray-fg}{red-fg}n{/red-fg}{gray-fg} or Esc to cancel{/gray-fg}');
65
+ this.box.setContent(lines.join('\n'));
66
+ this.screen.render();
67
+ }
68
+ close() {
69
+ this.box.destroy();
70
+ }
71
+ /**
72
+ * Focus the modal.
73
+ */
74
+ focus() {
75
+ this.box.focus();
76
+ }
77
+ }