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
package/dist/App.js
CHANGED
|
@@ -1,541 +1,1162 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { getFileAtIndex,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
import { LayoutManager, SPLIT_RATIO_STEP } from './ui/Layout.js';
|
|
3
|
+
import { formatHeader } from './ui/widgets/Header.js';
|
|
4
|
+
import { formatFooter } from './ui/widgets/Footer.js';
|
|
5
|
+
import { formatFileList, getFileAtIndex, getFileListTotalRows, getFileIndexFromRow, getRowFromFileIndex, } from './ui/widgets/FileList.js';
|
|
6
|
+
import { formatDiff, formatHistoryDiff } from './ui/widgets/DiffView.js';
|
|
7
|
+
import { formatCommitPanel } from './ui/widgets/CommitPanel.js';
|
|
8
|
+
import { formatHistoryView, getCommitAtIndex, } from './ui/widgets/HistoryView.js';
|
|
9
|
+
import { formatCompareListView, getCompareListTotalRows, getNextCompareSelection, getRowFromCompareSelection, getCompareSelectionFromRow, } from './ui/widgets/CompareListView.js';
|
|
10
|
+
import { formatExplorerView, getExplorerTotalRows, } from './ui/widgets/ExplorerView.js';
|
|
11
|
+
import { formatExplorerContent, getExplorerContentTotalRows, } from './ui/widgets/ExplorerContent.js';
|
|
12
|
+
import { ExplorerStateManager, } from './core/ExplorerStateManager.js';
|
|
13
|
+
import { ThemePicker } from './ui/modals/ThemePicker.js';
|
|
14
|
+
import { HotkeysModal } from './ui/modals/HotkeysModal.js';
|
|
15
|
+
import { BaseBranchPicker } from './ui/modals/BaseBranchPicker.js';
|
|
16
|
+
import { DiscardConfirm } from './ui/modals/DiscardConfirm.js';
|
|
17
|
+
import { CommitFlowState } from './state/CommitFlowState.js';
|
|
18
|
+
import { UIState } from './state/UIState.js';
|
|
19
|
+
import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
|
|
20
|
+
import { FilePathWatcher } from './core/FilePathWatcher.js';
|
|
20
21
|
import { saveConfig } from './config.js';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const { stagingPaneStart, fileListEnd, diffPaneStart, diffPaneEnd, footerRow } = paneBoundariesRef.current;
|
|
174
|
-
if (type === 'click') {
|
|
175
|
-
// Close modals on any click
|
|
176
|
-
if (activeModal !== null) {
|
|
177
|
-
setActiveModal(null);
|
|
22
|
+
/**
|
|
23
|
+
* Main application controller.
|
|
24
|
+
* Coordinates between GitStateManager, UIState, and blessed widgets.
|
|
25
|
+
*/
|
|
26
|
+
export class App {
|
|
27
|
+
screen;
|
|
28
|
+
layout;
|
|
29
|
+
uiState;
|
|
30
|
+
gitManager = null;
|
|
31
|
+
fileWatcher = null;
|
|
32
|
+
explorerManager = null;
|
|
33
|
+
config;
|
|
34
|
+
commandServer;
|
|
35
|
+
// Current state
|
|
36
|
+
repoPath;
|
|
37
|
+
watcherState = { enabled: false };
|
|
38
|
+
currentTheme;
|
|
39
|
+
// Commit flow state
|
|
40
|
+
commitFlowState;
|
|
41
|
+
commitTextarea = null;
|
|
42
|
+
// Active modals
|
|
43
|
+
activeModal = null;
|
|
44
|
+
// Cached total rows for scroll bounds (single source of truth from render)
|
|
45
|
+
bottomPaneTotalRows = 0;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.config = options.config;
|
|
48
|
+
this.commandServer = options.commandServer ?? null;
|
|
49
|
+
this.repoPath = options.initialPath ?? process.cwd();
|
|
50
|
+
this.currentTheme = options.config.theme;
|
|
51
|
+
// Initialize UI state with config values
|
|
52
|
+
this.uiState = new UIState({
|
|
53
|
+
splitRatio: options.config.splitRatio ?? 0.4,
|
|
54
|
+
});
|
|
55
|
+
// Create blessed screen
|
|
56
|
+
this.screen = blessed.screen({
|
|
57
|
+
smartCSR: true,
|
|
58
|
+
fullUnicode: true,
|
|
59
|
+
title: 'diffstalker',
|
|
60
|
+
mouse: true,
|
|
61
|
+
terminal: 'xterm-256color',
|
|
62
|
+
});
|
|
63
|
+
// Force 256-color support (terminfo detection can be unreliable)
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
const screenAny = this.screen;
|
|
66
|
+
if (screenAny.tput) {
|
|
67
|
+
screenAny.tput.colors = 256;
|
|
68
|
+
}
|
|
69
|
+
if (screenAny.program?.tput) {
|
|
70
|
+
screenAny.program.tput.colors = 256;
|
|
71
|
+
}
|
|
72
|
+
// Create layout
|
|
73
|
+
this.layout = new LayoutManager(this.screen, this.uiState.state.splitRatio);
|
|
74
|
+
// Handle screen resize - re-render content
|
|
75
|
+
// Use setImmediate to ensure screen dimensions are fully updated
|
|
76
|
+
this.screen.on('resize', () => {
|
|
77
|
+
setImmediate(() => this.render());
|
|
78
|
+
});
|
|
79
|
+
// Initialize commit flow state
|
|
80
|
+
this.commitFlowState = new CommitFlowState({
|
|
81
|
+
getHeadMessage: () => this.gitManager?.getHeadCommitMessage() ?? Promise.resolve(''),
|
|
82
|
+
onCommit: async (message, amend) => {
|
|
83
|
+
await this.gitManager?.commit(message, amend);
|
|
84
|
+
},
|
|
85
|
+
onSuccess: () => {
|
|
86
|
+
this.uiState.setTab('diff');
|
|
87
|
+
this.render();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
// Create commit textarea (hidden initially)
|
|
91
|
+
this.commitTextarea = blessed.textarea({
|
|
92
|
+
parent: this.layout.bottomPane,
|
|
93
|
+
top: 3,
|
|
94
|
+
left: 1,
|
|
95
|
+
width: '100%-4',
|
|
96
|
+
height: 1,
|
|
97
|
+
inputOnFocus: true,
|
|
98
|
+
hidden: true,
|
|
99
|
+
style: {
|
|
100
|
+
fg: 'white',
|
|
101
|
+
bg: 'default',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
// Handle textarea submission
|
|
105
|
+
this.commitTextarea.on('submit', () => {
|
|
106
|
+
this.commitFlowState.submit();
|
|
107
|
+
});
|
|
108
|
+
// Sync textarea value with commit state
|
|
109
|
+
this.commitTextarea.on('keypress', () => {
|
|
110
|
+
// Defer to next tick to get updated value
|
|
111
|
+
setImmediate(() => {
|
|
112
|
+
const value = this.commitTextarea?.getValue() ?? '';
|
|
113
|
+
this.commitFlowState.setMessage(value);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// Setup keyboard handlers
|
|
117
|
+
this.setupKeyboardHandlers();
|
|
118
|
+
// Setup mouse handlers
|
|
119
|
+
this.setupMouseHandlers();
|
|
120
|
+
// Setup state change listeners
|
|
121
|
+
this.setupStateListeners();
|
|
122
|
+
// Setup file watcher if enabled
|
|
123
|
+
if (this.config.watcherEnabled) {
|
|
124
|
+
this.setupFileWatcher();
|
|
125
|
+
}
|
|
126
|
+
// Setup IPC command handler if command server provided
|
|
127
|
+
if (this.commandServer) {
|
|
128
|
+
this.setupCommandHandler();
|
|
129
|
+
}
|
|
130
|
+
// Initialize git manager for current repo
|
|
131
|
+
this.initGitManager();
|
|
132
|
+
// Initial render
|
|
133
|
+
this.render();
|
|
134
|
+
}
|
|
135
|
+
setupKeyboardHandlers() {
|
|
136
|
+
// Quit
|
|
137
|
+
this.screen.key(['q', 'C-c'], () => {
|
|
138
|
+
this.exit();
|
|
139
|
+
});
|
|
140
|
+
// Navigation (skip if modal is open - modal handles its own keys)
|
|
141
|
+
this.screen.key(['j', 'down'], () => {
|
|
142
|
+
if (this.activeModal)
|
|
143
|
+
return;
|
|
144
|
+
this.navigateDown();
|
|
145
|
+
});
|
|
146
|
+
this.screen.key(['k', 'up'], () => {
|
|
147
|
+
if (this.activeModal)
|
|
148
|
+
return;
|
|
149
|
+
this.navigateUp();
|
|
150
|
+
});
|
|
151
|
+
// Tab switching (skip if modal is open)
|
|
152
|
+
this.screen.key(['1'], () => {
|
|
153
|
+
if (this.activeModal)
|
|
154
|
+
return;
|
|
155
|
+
this.uiState.setTab('diff');
|
|
156
|
+
});
|
|
157
|
+
this.screen.key(['2'], () => {
|
|
158
|
+
if (this.activeModal)
|
|
159
|
+
return;
|
|
160
|
+
this.uiState.setTab('commit');
|
|
161
|
+
});
|
|
162
|
+
this.screen.key(['3'], () => {
|
|
163
|
+
if (this.activeModal)
|
|
164
|
+
return;
|
|
165
|
+
this.uiState.setTab('history');
|
|
166
|
+
});
|
|
167
|
+
this.screen.key(['4'], () => {
|
|
168
|
+
if (this.activeModal)
|
|
169
|
+
return;
|
|
170
|
+
this.uiState.setTab('compare');
|
|
171
|
+
});
|
|
172
|
+
this.screen.key(['5'], () => {
|
|
173
|
+
if (this.activeModal)
|
|
178
174
|
return;
|
|
175
|
+
this.uiState.setTab('explorer');
|
|
176
|
+
});
|
|
177
|
+
// Pane toggle (skip if modal is open)
|
|
178
|
+
this.screen.key(['tab'], () => {
|
|
179
|
+
if (this.activeModal)
|
|
180
|
+
return;
|
|
181
|
+
this.uiState.togglePane();
|
|
182
|
+
});
|
|
183
|
+
// Staging operations (skip if modal is open)
|
|
184
|
+
this.screen.key(['s'], () => {
|
|
185
|
+
if (this.activeModal)
|
|
186
|
+
return;
|
|
187
|
+
this.stageSelected();
|
|
188
|
+
});
|
|
189
|
+
this.screen.key(['S-u'], () => {
|
|
190
|
+
if (this.activeModal)
|
|
191
|
+
return;
|
|
192
|
+
this.unstageSelected();
|
|
193
|
+
});
|
|
194
|
+
this.screen.key(['S-a'], () => {
|
|
195
|
+
if (this.activeModal)
|
|
196
|
+
return;
|
|
197
|
+
this.stageAll();
|
|
198
|
+
});
|
|
199
|
+
this.screen.key(['S-z'], () => {
|
|
200
|
+
if (this.activeModal)
|
|
201
|
+
return;
|
|
202
|
+
this.unstageAll();
|
|
203
|
+
});
|
|
204
|
+
// Select/toggle (skip if modal is open)
|
|
205
|
+
this.screen.key(['enter', 'space'], () => {
|
|
206
|
+
if (this.activeModal)
|
|
207
|
+
return;
|
|
208
|
+
const state = this.uiState.state;
|
|
209
|
+
if (state.bottomTab === 'explorer' && state.currentPane === 'explorer') {
|
|
210
|
+
this.enterExplorerDirectory();
|
|
179
211
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// Tab clicks on the right side
|
|
183
|
-
const tab = getClickedTab(x, terminalWidth);
|
|
184
|
-
if (tab) {
|
|
185
|
-
handleSwitchTab(tab);
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
// Indicator clicks on the left side
|
|
189
|
-
const leftClick = getFooterLeftClick(x);
|
|
190
|
-
if (leftClick === 'hotkeys') {
|
|
191
|
-
setActiveModal('hotkeys');
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
else if (leftClick === 'mouse-mode') {
|
|
195
|
-
toggleMouseRef.current();
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
else if (leftClick === 'auto-tab') {
|
|
199
|
-
setAutoTabEnabled((prev) => !prev);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
else if (leftClick === 'wrap') {
|
|
203
|
-
setWrapMode((prev) => !prev);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
212
|
+
else {
|
|
213
|
+
this.toggleSelected();
|
|
206
214
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const clickedIndex = getCommitIndexFromRow(visualRow, commits, terminalWidth, historyScrollOffset);
|
|
240
|
-
if (clickedIndex >= 0 && clickedIndex < commits.length) {
|
|
241
|
-
setHistorySelectedIndex(clickedIndex);
|
|
242
|
-
setCurrentPane('history');
|
|
243
|
-
setDiffScrollOffset(0);
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
215
|
+
});
|
|
216
|
+
// Explorer: go up directory (skip if modal is open)
|
|
217
|
+
this.screen.key(['backspace'], () => {
|
|
218
|
+
if (this.activeModal)
|
|
219
|
+
return;
|
|
220
|
+
const state = this.uiState.state;
|
|
221
|
+
if (state.bottomTab === 'explorer' && state.currentPane === 'explorer') {
|
|
222
|
+
this.goExplorerUp();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// Commit (skip if modal is open)
|
|
226
|
+
this.screen.key(['c'], () => {
|
|
227
|
+
if (this.activeModal)
|
|
228
|
+
return;
|
|
229
|
+
this.uiState.setTab('commit');
|
|
230
|
+
});
|
|
231
|
+
// Commit panel specific keys (only when on commit tab)
|
|
232
|
+
this.screen.key(['i'], () => {
|
|
233
|
+
if (this.uiState.state.bottomTab === 'commit' && !this.commitFlowState.state.inputFocused) {
|
|
234
|
+
this.focusCommitInput();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
this.screen.key(['a'], () => {
|
|
238
|
+
if (this.uiState.state.bottomTab === 'commit' && !this.commitFlowState.state.inputFocused) {
|
|
239
|
+
this.commitFlowState.toggleAmend();
|
|
240
|
+
this.render();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
this.screen.key(['escape'], () => {
|
|
244
|
+
if (this.uiState.state.bottomTab === 'commit') {
|
|
245
|
+
if (this.commitFlowState.state.inputFocused) {
|
|
246
|
+
this.unfocusCommitInput();
|
|
246
247
|
}
|
|
247
|
-
else
|
|
248
|
-
|
|
249
|
-
const visualRow = y - stagingPaneStart - 1 - offset + compareScrollOffset;
|
|
250
|
-
const itemIndex = getItemIndexFromRow(visualRow);
|
|
251
|
-
if (itemIndex >= 0 && itemIndex < compareTotalItems) {
|
|
252
|
-
markSelectionInitialized();
|
|
253
|
-
setCompareSelectedIndex(itemIndex);
|
|
254
|
-
setCurrentPane('compare');
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
248
|
+
else {
|
|
249
|
+
this.uiState.setTab('diff');
|
|
257
250
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
// Refresh
|
|
254
|
+
this.screen.key(['r'], () => this.refresh());
|
|
255
|
+
// Display toggles
|
|
256
|
+
this.screen.key(['w'], () => this.uiState.toggleWrapMode());
|
|
257
|
+
this.screen.key(['m'], () => this.toggleMouseMode());
|
|
258
|
+
this.screen.key(['S-t'], () => this.uiState.toggleAutoTab());
|
|
259
|
+
// Split ratio adjustments
|
|
260
|
+
this.screen.key(['-', '_'], () => {
|
|
261
|
+
this.uiState.adjustSplitRatio(-SPLIT_RATIO_STEP);
|
|
262
|
+
this.layout.setSplitRatio(this.uiState.state.splitRatio);
|
|
263
|
+
this.render();
|
|
264
|
+
});
|
|
265
|
+
this.screen.key(['=', '+'], () => {
|
|
266
|
+
this.uiState.adjustSplitRatio(SPLIT_RATIO_STEP);
|
|
267
|
+
this.layout.setSplitRatio(this.uiState.state.splitRatio);
|
|
268
|
+
this.render();
|
|
269
|
+
});
|
|
270
|
+
// Theme picker
|
|
271
|
+
this.screen.key(['t'], () => this.uiState.openModal('theme'));
|
|
272
|
+
// Hotkeys modal
|
|
273
|
+
this.screen.key(['?'], () => this.uiState.toggleModal('hotkeys'));
|
|
274
|
+
// Follow toggle
|
|
275
|
+
this.screen.key(['f'], () => this.toggleFollow());
|
|
276
|
+
// Compare view: base branch picker
|
|
277
|
+
this.screen.key(['b'], () => {
|
|
278
|
+
if (this.uiState.state.bottomTab === 'compare') {
|
|
279
|
+
this.uiState.openModal('baseBranch');
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
// Compare view: toggle uncommitted
|
|
283
|
+
this.screen.key(['u'], () => {
|
|
284
|
+
if (this.uiState.state.bottomTab === 'compare') {
|
|
285
|
+
this.uiState.toggleIncludeUncommitted();
|
|
286
|
+
const includeUncommitted = this.uiState.state.includeUncommitted;
|
|
287
|
+
this.gitManager?.refreshCompareDiff(includeUncommitted);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
// Discard changes (with confirmation)
|
|
291
|
+
this.screen.key(['d'], () => {
|
|
292
|
+
if (this.uiState.state.bottomTab === 'diff') {
|
|
293
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
294
|
+
const selectedIndex = this.uiState.state.selectedIndex;
|
|
295
|
+
const selectedFile = files[selectedIndex];
|
|
296
|
+
// Only allow discard for unstaged modified files
|
|
297
|
+
if (selectedFile && !selectedFile.staged && selectedFile.status !== 'untracked') {
|
|
298
|
+
this.showDiscardConfirm(selectedFile);
|
|
266
299
|
}
|
|
267
300
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
setupMouseHandlers() {
|
|
304
|
+
const SCROLL_AMOUNT = 3;
|
|
305
|
+
// Mouse wheel on top pane
|
|
306
|
+
this.layout.topPane.on('wheeldown', () => {
|
|
307
|
+
this.handleTopPaneScroll(SCROLL_AMOUNT);
|
|
308
|
+
});
|
|
309
|
+
this.layout.topPane.on('wheelup', () => {
|
|
310
|
+
this.handleTopPaneScroll(-SCROLL_AMOUNT);
|
|
311
|
+
});
|
|
312
|
+
// Mouse wheel on bottom pane
|
|
313
|
+
this.layout.bottomPane.on('wheeldown', () => {
|
|
314
|
+
this.handleBottomPaneScroll(SCROLL_AMOUNT);
|
|
315
|
+
});
|
|
316
|
+
this.layout.bottomPane.on('wheelup', () => {
|
|
317
|
+
this.handleBottomPaneScroll(-SCROLL_AMOUNT);
|
|
318
|
+
});
|
|
319
|
+
// Click on top pane to select item
|
|
320
|
+
this.layout.topPane.on('click', (mouse) => {
|
|
321
|
+
// Convert screen Y to pane-relative row (blessed click coords are screen-relative)
|
|
322
|
+
const clickedRow = this.layout.screenYToTopPaneRow(mouse.y);
|
|
323
|
+
if (clickedRow >= 0) {
|
|
324
|
+
this.handleTopPaneClick(clickedRow);
|
|
271
325
|
}
|
|
326
|
+
});
|
|
327
|
+
// Click on footer for tabs and toggles
|
|
328
|
+
this.layout.footerBox.on('click', (mouse) => {
|
|
329
|
+
this.handleFooterClick(mouse.x);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
handleTopPaneClick(row) {
|
|
333
|
+
const state = this.uiState.state;
|
|
334
|
+
if (state.bottomTab === 'history') {
|
|
335
|
+
const index = state.historyScrollOffset + row;
|
|
336
|
+
this.uiState.setHistorySelectedIndex(index);
|
|
337
|
+
this.selectHistoryCommitByIndex(index);
|
|
272
338
|
}
|
|
273
|
-
else if (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
339
|
+
else if (state.bottomTab === 'compare') {
|
|
340
|
+
// For compare view, need to map row to selection
|
|
341
|
+
const compareState = this.gitManager?.compareState;
|
|
342
|
+
const commits = compareState?.compareDiff?.commits ?? [];
|
|
343
|
+
const files = compareState?.compareDiff?.files ?? [];
|
|
344
|
+
const selection = getCompareSelectionFromRow(state.compareScrollOffset + row, commits, files);
|
|
345
|
+
if (selection) {
|
|
346
|
+
this.selectCompareItem(selection);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else if (state.bottomTab === 'explorer') {
|
|
350
|
+
const index = state.explorerScrollOffset + row;
|
|
351
|
+
this.explorerManager?.selectIndex(index);
|
|
352
|
+
this.uiState.setExplorerSelectedIndex(index);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
// Diff tab - select file
|
|
356
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
357
|
+
// Account for section headers in file list
|
|
358
|
+
const fileIndex = getFileIndexFromRow(row + state.fileListScrollOffset, files);
|
|
359
|
+
if (fileIndex !== null && fileIndex >= 0) {
|
|
360
|
+
this.uiState.setSelectedIndex(fileIndex);
|
|
361
|
+
this.selectFileByIndex(fileIndex);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
handleFooterClick(x) {
|
|
366
|
+
const width = this.screen.width || 80;
|
|
367
|
+
// Footer layout: left side has toggles, right side has tabs
|
|
368
|
+
// Tabs are right-aligned, so we calculate from the right
|
|
369
|
+
// Tab format: [1]Diff [2]Commit [3]History [4]Compare [5]Explorer
|
|
370
|
+
// Approximate positions from right edge
|
|
371
|
+
const tabPositions = [
|
|
372
|
+
{ tab: 'explorer', label: '[5]Explorer', width: 11 },
|
|
373
|
+
{ tab: 'compare', label: '[4]Compare', width: 10 },
|
|
374
|
+
{ tab: 'history', label: '[3]History', width: 10 },
|
|
375
|
+
{ tab: 'commit', label: '[2]Commit', width: 9 },
|
|
376
|
+
{ tab: 'diff', label: '[1]Diff', width: 7 },
|
|
377
|
+
];
|
|
378
|
+
let rightEdge = width;
|
|
379
|
+
for (const { tab, width: tabWidth } of tabPositions) {
|
|
380
|
+
const leftEdge = rightEdge - tabWidth - 1; // -1 for space
|
|
381
|
+
if (x >= leftEdge && x < rightEdge) {
|
|
382
|
+
this.uiState.setTab(tab);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
rightEdge = leftEdge;
|
|
386
|
+
}
|
|
387
|
+
// Left side toggles (approximate positions)
|
|
388
|
+
// Format: ? [scroll] [auto] [wrap] [dots]
|
|
389
|
+
if (x >= 2 && x <= 9) {
|
|
390
|
+
// [scroll] or m:[select]
|
|
391
|
+
this.toggleMouseMode();
|
|
392
|
+
}
|
|
393
|
+
else if (x >= 11 && x <= 16) {
|
|
394
|
+
// [auto]
|
|
395
|
+
this.uiState.toggleAutoTab();
|
|
396
|
+
}
|
|
397
|
+
else if (x >= 18 && x <= 23) {
|
|
398
|
+
// [wrap]
|
|
399
|
+
this.uiState.toggleWrapMode();
|
|
400
|
+
}
|
|
401
|
+
else if (x >= 25 && x <= 30 && this.uiState.state.bottomTab === 'explorer') {
|
|
402
|
+
// [dots] - only visible in explorer
|
|
403
|
+
this.uiState.toggleMiddleDots();
|
|
404
|
+
}
|
|
405
|
+
else if (x === 0) {
|
|
406
|
+
// ? - open hotkeys
|
|
407
|
+
this.uiState.openModal('hotkeys');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
handleTopPaneScroll(delta) {
|
|
411
|
+
const state = this.uiState.state;
|
|
412
|
+
const visibleHeight = this.layout.dimensions.topPaneHeight;
|
|
413
|
+
if (state.bottomTab === 'history') {
|
|
414
|
+
const totalRows = this.gitManager?.historyState.commits.length ?? 0;
|
|
415
|
+
const maxOffset = Math.max(0, totalRows - visibleHeight);
|
|
416
|
+
const newOffset = Math.min(maxOffset, Math.max(0, state.historyScrollOffset + delta));
|
|
417
|
+
this.uiState.setHistoryScrollOffset(newOffset);
|
|
418
|
+
}
|
|
419
|
+
else if (state.bottomTab === 'compare') {
|
|
420
|
+
const compareState = this.gitManager?.compareState;
|
|
421
|
+
const totalRows = getCompareListTotalRows(compareState?.compareDiff?.commits ?? [], compareState?.compareDiff?.files ?? []);
|
|
422
|
+
const maxOffset = Math.max(0, totalRows - visibleHeight);
|
|
423
|
+
const newOffset = Math.min(maxOffset, Math.max(0, state.compareScrollOffset + delta));
|
|
424
|
+
this.uiState.setCompareScrollOffset(newOffset);
|
|
425
|
+
}
|
|
426
|
+
else if (state.bottomTab === 'explorer') {
|
|
427
|
+
const totalRows = getExplorerTotalRows(this.explorerManager?.state.items ?? []);
|
|
428
|
+
const maxOffset = Math.max(0, totalRows - visibleHeight);
|
|
429
|
+
const newOffset = Math.min(maxOffset, Math.max(0, state.explorerScrollOffset + delta));
|
|
430
|
+
this.uiState.setExplorerScrollOffset(newOffset);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
434
|
+
const totalRows = getFileListTotalRows(files);
|
|
435
|
+
const maxOffset = Math.max(0, totalRows - visibleHeight);
|
|
436
|
+
const newOffset = Math.min(maxOffset, Math.max(0, state.fileListScrollOffset + delta));
|
|
437
|
+
this.uiState.setFileListScrollOffset(newOffset);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
handleBottomPaneScroll(delta) {
|
|
441
|
+
const state = this.uiState.state;
|
|
442
|
+
const visibleHeight = this.layout.dimensions.bottomPaneHeight;
|
|
443
|
+
const width = this.screen.width || 80;
|
|
444
|
+
if (state.bottomTab === 'explorer') {
|
|
445
|
+
const selectedFile = this.explorerManager?.state.selectedFile;
|
|
446
|
+
const totalRows = getExplorerContentTotalRows(selectedFile?.content ?? null, selectedFile?.path ?? null, selectedFile?.truncated ?? false, width, state.wrapMode);
|
|
447
|
+
const maxOffset = Math.max(0, totalRows - visibleHeight);
|
|
448
|
+
const newOffset = Math.min(maxOffset, Math.max(0, state.explorerFileScrollOffset + delta));
|
|
449
|
+
this.uiState.setExplorerFileScrollOffset(newOffset);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// Use cached totalRows from last render (single source of truth)
|
|
453
|
+
const maxOffset = Math.max(0, this.bottomPaneTotalRows - visibleHeight);
|
|
454
|
+
const newOffset = Math.min(maxOffset, Math.max(0, state.diffScrollOffset + delta));
|
|
455
|
+
this.uiState.setDiffScrollOffset(newOffset);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
setupStateListeners() {
|
|
459
|
+
// Update footer when UI state changes
|
|
460
|
+
this.uiState.on('change', () => {
|
|
461
|
+
this.render();
|
|
462
|
+
});
|
|
463
|
+
// Load data when switching tabs
|
|
464
|
+
this.uiState.on('tab-change', (tab) => {
|
|
465
|
+
if (tab === 'history') {
|
|
466
|
+
this.gitManager?.loadHistory();
|
|
467
|
+
}
|
|
468
|
+
else if (tab === 'compare') {
|
|
469
|
+
this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
|
|
470
|
+
}
|
|
471
|
+
else if (tab === 'explorer') {
|
|
472
|
+
// Explorer is already loaded on init, but refresh if needed
|
|
473
|
+
if (!this.explorerManager?.state.items.length) {
|
|
474
|
+
this.explorerManager?.loadDirectory('');
|
|
281
475
|
}
|
|
282
|
-
|
|
283
|
-
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
// Handle modal opening/closing
|
|
479
|
+
this.uiState.on('modal-change', (modal) => {
|
|
480
|
+
// Close any existing modal
|
|
481
|
+
if (this.activeModal) {
|
|
482
|
+
this.activeModal = null;
|
|
483
|
+
}
|
|
484
|
+
// Open new modal if requested
|
|
485
|
+
if (modal === 'theme') {
|
|
486
|
+
this.activeModal = new ThemePicker(this.screen, this.currentTheme, (theme) => {
|
|
487
|
+
this.currentTheme = theme;
|
|
488
|
+
saveConfig({ theme });
|
|
489
|
+
this.activeModal = null;
|
|
490
|
+
this.uiState.closeModal();
|
|
491
|
+
this.render();
|
|
492
|
+
}, () => {
|
|
493
|
+
this.activeModal = null;
|
|
494
|
+
this.uiState.closeModal();
|
|
495
|
+
});
|
|
496
|
+
this.activeModal.focus();
|
|
497
|
+
}
|
|
498
|
+
else if (modal === 'hotkeys') {
|
|
499
|
+
this.activeModal = new HotkeysModal(this.screen, () => {
|
|
500
|
+
this.activeModal = null;
|
|
501
|
+
this.uiState.closeModal();
|
|
502
|
+
});
|
|
503
|
+
this.activeModal.focus();
|
|
504
|
+
}
|
|
505
|
+
else if (modal === 'baseBranch') {
|
|
506
|
+
// Load candidate branches and show picker
|
|
507
|
+
this.gitManager?.getCandidateBaseBranches().then((branches) => {
|
|
508
|
+
const currentBranch = this.gitManager?.compareState.compareBaseBranch ?? null;
|
|
509
|
+
this.activeModal = new BaseBranchPicker(this.screen, branches, currentBranch, (branch) => {
|
|
510
|
+
this.activeModal = null;
|
|
511
|
+
this.uiState.closeModal();
|
|
512
|
+
// Set base branch and refresh compare view
|
|
513
|
+
const includeUncommitted = this.uiState.state.includeUncommitted;
|
|
514
|
+
this.gitManager?.setCompareBaseBranch(branch, includeUncommitted);
|
|
515
|
+
}, () => {
|
|
516
|
+
this.activeModal = null;
|
|
517
|
+
this.uiState.closeModal();
|
|
518
|
+
});
|
|
519
|
+
this.activeModal.focus();
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
// Save split ratio to config when it changes
|
|
524
|
+
let saveTimer = null;
|
|
525
|
+
this.uiState.on('change', (state) => {
|
|
526
|
+
if (saveTimer)
|
|
527
|
+
clearTimeout(saveTimer);
|
|
528
|
+
saveTimer = setTimeout(() => {
|
|
529
|
+
if (state.splitRatio !== this.config.splitRatio) {
|
|
530
|
+
saveConfig({ splitRatio: state.splitRatio });
|
|
284
531
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
532
|
+
}, 500);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
setupFileWatcher() {
|
|
536
|
+
this.fileWatcher = new FilePathWatcher(this.config.targetFile);
|
|
537
|
+
this.fileWatcher.on('path-change', (state) => {
|
|
538
|
+
if (state.path && state.path !== this.repoPath) {
|
|
539
|
+
this.repoPath = state.path;
|
|
540
|
+
this.watcherState = {
|
|
541
|
+
enabled: true,
|
|
542
|
+
sourceFile: state.sourceFile ?? this.config.targetFile,
|
|
543
|
+
rawContent: state.rawContent ?? undefined,
|
|
544
|
+
lastUpdate: state.lastUpdate ?? undefined,
|
|
545
|
+
};
|
|
546
|
+
this.initGitManager();
|
|
547
|
+
this.render();
|
|
548
|
+
}
|
|
549
|
+
// Navigate to the followed file if it's within the repo
|
|
550
|
+
if (state.rawContent) {
|
|
551
|
+
this.navigateToFile(state.rawContent);
|
|
552
|
+
this.render();
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
this.watcherState = {
|
|
556
|
+
enabled: true,
|
|
557
|
+
sourceFile: this.config.targetFile,
|
|
558
|
+
};
|
|
559
|
+
this.fileWatcher.start();
|
|
560
|
+
// Navigate to the initially followed file
|
|
561
|
+
const initialState = this.fileWatcher.state;
|
|
562
|
+
if (initialState.rawContent) {
|
|
563
|
+
this.watcherState.rawContent = initialState.rawContent;
|
|
564
|
+
this.navigateToFile(initialState.rawContent);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
initGitManager() {
|
|
568
|
+
// Clean up existing manager
|
|
569
|
+
if (this.gitManager) {
|
|
570
|
+
this.gitManager.removeAllListeners();
|
|
571
|
+
removeManagerForRepo(this.repoPath);
|
|
572
|
+
}
|
|
573
|
+
// Get or create manager for this repo
|
|
574
|
+
this.gitManager = getManagerForRepo(this.repoPath);
|
|
575
|
+
// Listen to state changes
|
|
576
|
+
this.gitManager.on('state-change', () => {
|
|
577
|
+
this.render();
|
|
578
|
+
});
|
|
579
|
+
this.gitManager.on('history-state-change', (historyState) => {
|
|
580
|
+
// Auto-select first commit when history loads
|
|
581
|
+
if (historyState.commits.length > 0 && !historyState.selectedCommit) {
|
|
582
|
+
const state = this.uiState.state;
|
|
583
|
+
if (state.bottomTab === 'history') {
|
|
584
|
+
this.selectHistoryCommitByIndex(state.historySelectedIndex);
|
|
290
585
|
}
|
|
291
586
|
}
|
|
587
|
+
this.render();
|
|
588
|
+
});
|
|
589
|
+
this.gitManager.on('compare-state-change', () => {
|
|
590
|
+
this.render();
|
|
591
|
+
});
|
|
592
|
+
this.gitManager.on('compare-selection-change', () => {
|
|
593
|
+
this.render();
|
|
594
|
+
});
|
|
595
|
+
// Start watching and do initial refresh
|
|
596
|
+
this.gitManager.startWatching();
|
|
597
|
+
this.gitManager.refresh();
|
|
598
|
+
// Initialize explorer manager
|
|
599
|
+
this.initExplorerManager();
|
|
600
|
+
}
|
|
601
|
+
initExplorerManager() {
|
|
602
|
+
// Clean up existing manager
|
|
603
|
+
if (this.explorerManager) {
|
|
604
|
+
this.explorerManager.dispose();
|
|
605
|
+
}
|
|
606
|
+
// Create new manager with options
|
|
607
|
+
const options = {
|
|
608
|
+
hideHidden: true,
|
|
609
|
+
hideGitignored: true,
|
|
610
|
+
};
|
|
611
|
+
this.explorerManager = new ExplorerStateManager(this.repoPath, options);
|
|
612
|
+
// Listen to state changes
|
|
613
|
+
this.explorerManager.on('state-change', () => {
|
|
614
|
+
this.render();
|
|
615
|
+
});
|
|
616
|
+
// Load root directory
|
|
617
|
+
this.explorerManager.loadDirectory('');
|
|
618
|
+
}
|
|
619
|
+
setupCommandHandler() {
|
|
620
|
+
if (!this.commandServer)
|
|
621
|
+
return;
|
|
622
|
+
const handler = {
|
|
623
|
+
navigateUp: () => this.navigateUp(),
|
|
624
|
+
navigateDown: () => this.navigateDown(),
|
|
625
|
+
switchTab: (tab) => this.uiState.setTab(tab),
|
|
626
|
+
togglePane: () => this.uiState.togglePane(),
|
|
627
|
+
stage: async () => this.stageSelected(),
|
|
628
|
+
unstage: async () => this.unstageSelected(),
|
|
629
|
+
stageAll: async () => this.stageAll(),
|
|
630
|
+
unstageAll: async () => this.unstageAll(),
|
|
631
|
+
commit: async (message) => this.commit(message),
|
|
632
|
+
refresh: async () => this.refresh(),
|
|
633
|
+
getState: () => this.getAppState(),
|
|
634
|
+
quit: () => this.exit(),
|
|
635
|
+
};
|
|
636
|
+
this.commandServer.setHandler(handler);
|
|
637
|
+
this.commandServer.notifyReady();
|
|
638
|
+
}
|
|
639
|
+
getAppState() {
|
|
640
|
+
const state = this.uiState.state;
|
|
641
|
+
const gitState = this.gitManager?.state;
|
|
642
|
+
const historyState = this.gitManager?.historyState;
|
|
643
|
+
const files = gitState?.status?.files ?? [];
|
|
644
|
+
const commits = historyState?.commits ?? [];
|
|
645
|
+
return {
|
|
646
|
+
currentTab: state.bottomTab,
|
|
647
|
+
currentPane: state.currentPane,
|
|
648
|
+
selectedIndex: state.selectedIndex,
|
|
649
|
+
totalFiles: files.length,
|
|
650
|
+
stagedCount: files.filter((f) => f.staged).length,
|
|
651
|
+
files: files.map((f) => ({
|
|
652
|
+
path: f.path,
|
|
653
|
+
status: f.status,
|
|
654
|
+
staged: f.staged,
|
|
655
|
+
})),
|
|
656
|
+
historySelectedIndex: state.historySelectedIndex,
|
|
657
|
+
historyCommitCount: commits.length,
|
|
658
|
+
compareSelectedIndex: state.compareSelectedIndex,
|
|
659
|
+
compareTotalItems: 0,
|
|
660
|
+
includeUncommitted: state.includeUncommitted,
|
|
661
|
+
explorerPath: this.repoPath,
|
|
662
|
+
explorerSelectedIndex: state.explorerSelectedIndex,
|
|
663
|
+
explorerItemCount: 0,
|
|
664
|
+
wrapMode: state.wrapMode,
|
|
665
|
+
mouseEnabled: state.mouseEnabled,
|
|
666
|
+
autoTabEnabled: state.autoTabEnabled,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
// Navigation methods
|
|
670
|
+
navigateUp() {
|
|
671
|
+
const state = this.uiState.state;
|
|
672
|
+
if (state.bottomTab === 'history') {
|
|
673
|
+
if (state.currentPane === 'history') {
|
|
674
|
+
this.navigateHistoryUp();
|
|
675
|
+
}
|
|
676
|
+
else if (state.currentPane === 'diff') {
|
|
677
|
+
this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
|
|
678
|
+
}
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (state.bottomTab === 'compare') {
|
|
682
|
+
if (state.currentPane === 'compare') {
|
|
683
|
+
this.navigateCompareUp();
|
|
684
|
+
}
|
|
685
|
+
else if (state.currentPane === 'diff') {
|
|
686
|
+
this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (state.bottomTab === 'explorer') {
|
|
691
|
+
if (state.currentPane === 'explorer') {
|
|
692
|
+
this.navigateExplorerUp();
|
|
693
|
+
}
|
|
694
|
+
else if (state.currentPane === 'diff') {
|
|
695
|
+
this.uiState.setExplorerFileScrollOffset(Math.max(0, state.explorerFileScrollOffset - 3));
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (state.currentPane === 'files') {
|
|
700
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
701
|
+
const newIndex = Math.max(0, state.selectedIndex - 1);
|
|
702
|
+
this.uiState.setSelectedIndex(newIndex);
|
|
703
|
+
this.selectFileByIndex(newIndex);
|
|
704
|
+
// Keep selection visible - scroll up if needed
|
|
705
|
+
const row = getRowFromFileIndex(newIndex, files);
|
|
706
|
+
if (row < state.fileListScrollOffset) {
|
|
707
|
+
this.uiState.setFileListScrollOffset(row);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
else if (state.currentPane === 'diff') {
|
|
711
|
+
this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
navigateDown() {
|
|
715
|
+
const state = this.uiState.state;
|
|
716
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
717
|
+
if (state.bottomTab === 'history') {
|
|
718
|
+
if (state.currentPane === 'history') {
|
|
719
|
+
this.navigateHistoryDown();
|
|
720
|
+
}
|
|
721
|
+
else if (state.currentPane === 'diff') {
|
|
722
|
+
this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (state.bottomTab === 'compare') {
|
|
727
|
+
if (state.currentPane === 'compare') {
|
|
728
|
+
this.navigateCompareDown();
|
|
729
|
+
}
|
|
730
|
+
else if (state.currentPane === 'diff') {
|
|
731
|
+
this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
|
|
732
|
+
}
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (state.bottomTab === 'explorer') {
|
|
736
|
+
if (state.currentPane === 'explorer') {
|
|
737
|
+
this.navigateExplorerDown();
|
|
738
|
+
}
|
|
739
|
+
else if (state.currentPane === 'diff') {
|
|
740
|
+
this.uiState.setExplorerFileScrollOffset(state.explorerFileScrollOffset + 3);
|
|
741
|
+
}
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (state.currentPane === 'files') {
|
|
745
|
+
const newIndex = Math.min(files.length - 1, state.selectedIndex + 1);
|
|
746
|
+
this.uiState.setSelectedIndex(newIndex);
|
|
747
|
+
this.selectFileByIndex(newIndex);
|
|
748
|
+
// Keep selection visible - scroll down if needed
|
|
749
|
+
const row = getRowFromFileIndex(newIndex, files);
|
|
750
|
+
const visibleEnd = state.fileListScrollOffset + this.layout.dimensions.topPaneHeight - 1;
|
|
751
|
+
if (row >= visibleEnd) {
|
|
752
|
+
this.uiState.setFileListScrollOffset(state.fileListScrollOffset + (row - visibleEnd + 1));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
else if (state.currentPane === 'diff') {
|
|
756
|
+
this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
navigateHistoryUp() {
|
|
760
|
+
const state = this.uiState.state;
|
|
761
|
+
const newIndex = Math.max(0, state.historySelectedIndex - 1);
|
|
762
|
+
if (newIndex !== state.historySelectedIndex) {
|
|
763
|
+
this.uiState.setHistorySelectedIndex(newIndex);
|
|
764
|
+
// Keep selection visible
|
|
765
|
+
if (newIndex < state.historyScrollOffset) {
|
|
766
|
+
this.uiState.setHistoryScrollOffset(newIndex);
|
|
767
|
+
}
|
|
768
|
+
this.selectHistoryCommitByIndex(newIndex);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
navigateHistoryDown() {
|
|
772
|
+
const state = this.uiState.state;
|
|
773
|
+
const commits = this.gitManager?.historyState.commits ?? [];
|
|
774
|
+
const newIndex = Math.min(commits.length - 1, state.historySelectedIndex + 1);
|
|
775
|
+
if (newIndex !== state.historySelectedIndex) {
|
|
776
|
+
this.uiState.setHistorySelectedIndex(newIndex);
|
|
777
|
+
// Keep selection visible
|
|
778
|
+
const visibleEnd = state.historyScrollOffset + this.layout.dimensions.topPaneHeight - 1;
|
|
779
|
+
if (newIndex >= visibleEnd) {
|
|
780
|
+
this.uiState.setHistoryScrollOffset(state.historyScrollOffset + 1);
|
|
781
|
+
}
|
|
782
|
+
this.selectHistoryCommitByIndex(newIndex);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
selectHistoryCommitByIndex(index) {
|
|
786
|
+
const commits = this.gitManager?.historyState.commits ?? [];
|
|
787
|
+
const commit = getCommitAtIndex(commits, index);
|
|
788
|
+
if (commit) {
|
|
789
|
+
this.uiState.setDiffScrollOffset(0);
|
|
790
|
+
this.gitManager?.selectHistoryCommit(commit);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// Compare navigation
|
|
794
|
+
compareSelection = null;
|
|
795
|
+
navigateCompareUp() {
|
|
796
|
+
const compareState = this.gitManager?.compareState;
|
|
797
|
+
const commits = compareState?.compareDiff?.commits ?? [];
|
|
798
|
+
const files = compareState?.compareDiff?.files ?? [];
|
|
799
|
+
if (commits.length === 0 && files.length === 0)
|
|
800
|
+
return;
|
|
801
|
+
const next = getNextCompareSelection(this.compareSelection, commits, files, 'up');
|
|
802
|
+
if (next &&
|
|
803
|
+
(next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
|
|
804
|
+
this.selectCompareItem(next);
|
|
805
|
+
// Keep selection visible - scroll up if needed
|
|
806
|
+
const state = this.uiState.state;
|
|
807
|
+
const row = getRowFromCompareSelection(next, commits, files);
|
|
808
|
+
if (row < state.compareScrollOffset) {
|
|
809
|
+
this.uiState.setCompareScrollOffset(row);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
navigateCompareDown() {
|
|
814
|
+
const compareState = this.gitManager?.compareState;
|
|
815
|
+
const commits = compareState?.compareDiff?.commits ?? [];
|
|
816
|
+
const files = compareState?.compareDiff?.files ?? [];
|
|
817
|
+
if (commits.length === 0 && files.length === 0)
|
|
818
|
+
return;
|
|
819
|
+
// Auto-select first item if nothing selected
|
|
820
|
+
if (!this.compareSelection) {
|
|
821
|
+
// Select first commit if available, otherwise first file
|
|
822
|
+
if (commits.length > 0) {
|
|
823
|
+
this.selectCompareItem({ type: 'commit', index: 0 });
|
|
824
|
+
}
|
|
825
|
+
else if (files.length > 0) {
|
|
826
|
+
this.selectCompareItem({ type: 'file', index: 0 });
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const next = getNextCompareSelection(this.compareSelection, commits, files, 'down');
|
|
831
|
+
if (next &&
|
|
832
|
+
(next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
|
|
833
|
+
this.selectCompareItem(next);
|
|
834
|
+
// Keep selection visible - scroll down if needed
|
|
835
|
+
const state = this.uiState.state;
|
|
836
|
+
const row = getRowFromCompareSelection(next, commits, files);
|
|
837
|
+
const visibleEnd = state.compareScrollOffset + this.layout.dimensions.topPaneHeight - 1;
|
|
838
|
+
if (row >= visibleEnd) {
|
|
839
|
+
this.uiState.setCompareScrollOffset(state.compareScrollOffset + (row - visibleEnd + 1));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
selectCompareItem(selection) {
|
|
844
|
+
this.compareSelection = selection;
|
|
845
|
+
this.uiState.setDiffScrollOffset(0);
|
|
846
|
+
if (selection.type === 'commit') {
|
|
847
|
+
this.gitManager?.selectCompareCommit(selection.index);
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
this.gitManager?.selectCompareFile(selection.index);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// Explorer navigation
|
|
854
|
+
navigateExplorerUp() {
|
|
855
|
+
const state = this.uiState.state;
|
|
856
|
+
const items = this.explorerManager?.state.items ?? [];
|
|
857
|
+
if (items.length === 0)
|
|
858
|
+
return;
|
|
859
|
+
const newScrollOffset = this.explorerManager?.navigateUp(state.explorerScrollOffset);
|
|
860
|
+
if (newScrollOffset !== null && newScrollOffset !== undefined) {
|
|
861
|
+
this.uiState.setExplorerScrollOffset(newScrollOffset);
|
|
862
|
+
}
|
|
863
|
+
this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
|
|
864
|
+
}
|
|
865
|
+
navigateExplorerDown() {
|
|
866
|
+
const state = this.uiState.state;
|
|
867
|
+
const items = this.explorerManager?.state.items ?? [];
|
|
868
|
+
if (items.length === 0)
|
|
869
|
+
return;
|
|
870
|
+
const visibleHeight = this.layout.dimensions.topPaneHeight;
|
|
871
|
+
const newScrollOffset = this.explorerManager?.navigateDown(state.explorerScrollOffset, visibleHeight);
|
|
872
|
+
if (newScrollOffset !== null && newScrollOffset !== undefined) {
|
|
873
|
+
this.uiState.setExplorerScrollOffset(newScrollOffset);
|
|
874
|
+
}
|
|
875
|
+
this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
|
|
876
|
+
}
|
|
877
|
+
async enterExplorerDirectory() {
|
|
878
|
+
await this.explorerManager?.enterDirectory();
|
|
879
|
+
this.uiState.setExplorerScrollOffset(0);
|
|
880
|
+
this.uiState.setExplorerFileScrollOffset(0);
|
|
881
|
+
this.uiState.setExplorerSelectedIndex(0);
|
|
882
|
+
}
|
|
883
|
+
async goExplorerUp() {
|
|
884
|
+
await this.explorerManager?.goUp();
|
|
885
|
+
this.uiState.setExplorerScrollOffset(0);
|
|
886
|
+
this.uiState.setExplorerFileScrollOffset(0);
|
|
887
|
+
this.uiState.setExplorerSelectedIndex(0);
|
|
888
|
+
}
|
|
889
|
+
selectFileByIndex(index) {
|
|
890
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
891
|
+
const file = getFileAtIndex(files, index);
|
|
892
|
+
if (file) {
|
|
893
|
+
// Reset diff scroll when changing files
|
|
894
|
+
this.uiState.setDiffScrollOffset(0);
|
|
895
|
+
this.gitManager?.selectFile(file);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Navigate to a file given its absolute path.
|
|
900
|
+
* Extracts the relative path and finds the file in the current file list.
|
|
901
|
+
*/
|
|
902
|
+
navigateToFile(absolutePath) {
|
|
903
|
+
if (!absolutePath || !this.repoPath)
|
|
904
|
+
return;
|
|
905
|
+
// Check if the path is within the current repo
|
|
906
|
+
const repoPrefix = this.repoPath.endsWith('/') ? this.repoPath : this.repoPath + '/';
|
|
907
|
+
if (!absolutePath.startsWith(repoPrefix))
|
|
908
|
+
return;
|
|
909
|
+
// Extract relative path
|
|
910
|
+
const relativePath = absolutePath.slice(repoPrefix.length);
|
|
911
|
+
if (!relativePath)
|
|
912
|
+
return;
|
|
913
|
+
// Find the file in the list
|
|
914
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
915
|
+
const fileIndex = files.findIndex((f) => f.path === relativePath);
|
|
916
|
+
if (fileIndex >= 0) {
|
|
917
|
+
this.uiState.setSelectedIndex(fileIndex);
|
|
918
|
+
this.selectFileByIndex(fileIndex);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// Git operations
|
|
922
|
+
async stageSelected() {
|
|
923
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
924
|
+
const selectedFile = files[this.uiState.state.selectedIndex];
|
|
925
|
+
if (selectedFile && !selectedFile.staged) {
|
|
926
|
+
await this.gitManager?.stage(selectedFile);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async unstageSelected() {
|
|
930
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
931
|
+
const selectedFile = files[this.uiState.state.selectedIndex];
|
|
932
|
+
if (selectedFile?.staged) {
|
|
933
|
+
await this.gitManager?.unstage(selectedFile);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
async toggleSelected() {
|
|
937
|
+
const files = this.gitManager?.state.status?.files ?? [];
|
|
938
|
+
const selectedFile = files[this.uiState.state.selectedIndex];
|
|
939
|
+
if (selectedFile) {
|
|
940
|
+
if (selectedFile.staged) {
|
|
941
|
+
await this.gitManager?.unstage(selectedFile);
|
|
942
|
+
}
|
|
292
943
|
else {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
944
|
+
await this.gitManager?.stage(selectedFile);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
async stageAll() {
|
|
949
|
+
await this.gitManager?.stageAll();
|
|
950
|
+
}
|
|
951
|
+
async unstageAll() {
|
|
952
|
+
await this.gitManager?.unstageAll();
|
|
953
|
+
}
|
|
954
|
+
showDiscardConfirm(file) {
|
|
955
|
+
this.activeModal = new DiscardConfirm(this.screen, file.path, async () => {
|
|
956
|
+
this.activeModal = null;
|
|
957
|
+
await this.gitManager?.discard(file);
|
|
958
|
+
}, () => {
|
|
959
|
+
this.activeModal = null;
|
|
960
|
+
});
|
|
961
|
+
this.activeModal.focus();
|
|
962
|
+
}
|
|
963
|
+
async commit(message) {
|
|
964
|
+
await this.gitManager?.commit(message);
|
|
965
|
+
}
|
|
966
|
+
async refresh() {
|
|
967
|
+
await this.gitManager?.refresh();
|
|
968
|
+
}
|
|
969
|
+
toggleMouseMode() {
|
|
970
|
+
const willEnable = !this.uiState.state.mouseEnabled;
|
|
971
|
+
this.uiState.toggleMouse();
|
|
972
|
+
// Access program for terminal mouse control (not on screen's TS types)
|
|
973
|
+
const program = this.screen.program;
|
|
974
|
+
if (willEnable) {
|
|
975
|
+
program.enableMouse();
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
program.disableMouse();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
toggleFollow() {
|
|
982
|
+
if (this.fileWatcher) {
|
|
983
|
+
this.fileWatcher.stop();
|
|
984
|
+
this.fileWatcher = null;
|
|
985
|
+
this.watcherState = { enabled: false };
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
this.setupFileWatcher();
|
|
989
|
+
}
|
|
990
|
+
this.render();
|
|
991
|
+
}
|
|
992
|
+
focusCommitInput() {
|
|
993
|
+
if (this.commitTextarea) {
|
|
994
|
+
this.commitTextarea.show();
|
|
995
|
+
this.commitTextarea.focus();
|
|
996
|
+
this.commitTextarea.setValue(this.commitFlowState.state.message);
|
|
997
|
+
this.commitFlowState.setInputFocused(true);
|
|
998
|
+
this.render();
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
unfocusCommitInput() {
|
|
1002
|
+
if (this.commitTextarea) {
|
|
1003
|
+
const value = this.commitTextarea.getValue() ?? '';
|
|
1004
|
+
this.commitFlowState.setMessage(value);
|
|
1005
|
+
this.commitTextarea.hide();
|
|
1006
|
+
this.commitFlowState.setInputFocused(false);
|
|
1007
|
+
this.screen.focusPush(this.layout.bottomPane);
|
|
1008
|
+
this.render();
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// Render methods
|
|
1012
|
+
render() {
|
|
1013
|
+
this.updateHeader();
|
|
1014
|
+
this.updateTopPane();
|
|
1015
|
+
this.updateBottomPane();
|
|
1016
|
+
this.updateFooter();
|
|
1017
|
+
this.screen.render();
|
|
1018
|
+
}
|
|
1019
|
+
updateHeader() {
|
|
1020
|
+
const gitState = this.gitManager?.state;
|
|
1021
|
+
const width = this.screen.width || 80;
|
|
1022
|
+
const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, this.watcherState, width);
|
|
1023
|
+
this.layout.headerBox.setContent(content);
|
|
1024
|
+
}
|
|
1025
|
+
updateTopPane() {
|
|
1026
|
+
const gitState = this.gitManager?.state;
|
|
1027
|
+
const historyState = this.gitManager?.historyState;
|
|
1028
|
+
const compareState = this.gitManager?.compareState;
|
|
1029
|
+
const files = gitState?.status?.files ?? [];
|
|
1030
|
+
const state = this.uiState.state;
|
|
1031
|
+
const width = this.screen.width || 80;
|
|
1032
|
+
let content;
|
|
1033
|
+
if (state.bottomTab === 'history') {
|
|
1034
|
+
const commits = historyState?.commits ?? [];
|
|
1035
|
+
content = formatHistoryView(commits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, this.layout.dimensions.topPaneHeight);
|
|
1036
|
+
}
|
|
1037
|
+
else if (state.bottomTab === 'compare') {
|
|
1038
|
+
const compareDiff = compareState?.compareDiff;
|
|
1039
|
+
const commits = compareDiff?.commits ?? [];
|
|
1040
|
+
const compareFiles = compareDiff?.files ?? [];
|
|
1041
|
+
content = formatCompareListView(commits, compareFiles, this.compareSelection, state.currentPane === 'compare', width, state.compareScrollOffset, this.layout.dimensions.topPaneHeight);
|
|
1042
|
+
}
|
|
1043
|
+
else if (state.bottomTab === 'explorer') {
|
|
1044
|
+
const explorerState = this.explorerManager?.state;
|
|
1045
|
+
const items = explorerState?.items ?? [];
|
|
1046
|
+
content = formatExplorerView(items, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, this.layout.dimensions.topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
content = formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, this.layout.dimensions.topPaneHeight);
|
|
1050
|
+
}
|
|
1051
|
+
this.layout.topPane.setContent(content);
|
|
1052
|
+
}
|
|
1053
|
+
updateBottomPane() {
|
|
1054
|
+
const gitState = this.gitManager?.state;
|
|
1055
|
+
const historyState = this.gitManager?.historyState;
|
|
1056
|
+
const diff = gitState?.diff ?? null;
|
|
1057
|
+
const state = this.uiState.state;
|
|
1058
|
+
const width = this.screen.width || 80;
|
|
1059
|
+
const files = gitState?.status?.files ?? [];
|
|
1060
|
+
const stagedCount = files.filter((f) => f.staged).length;
|
|
1061
|
+
// Update staged count for commit validation
|
|
1062
|
+
this.commitFlowState.setStagedCount(stagedCount);
|
|
1063
|
+
// Show appropriate content based on tab
|
|
1064
|
+
if (state.bottomTab === 'commit') {
|
|
1065
|
+
const commitContent = formatCommitPanel(this.commitFlowState.state, stagedCount, width);
|
|
1066
|
+
this.layout.bottomPane.setContent(commitContent);
|
|
1067
|
+
// Show/hide textarea based on focus
|
|
1068
|
+
if (this.commitTextarea) {
|
|
1069
|
+
if (this.commitFlowState.state.inputFocused) {
|
|
1070
|
+
this.commitTextarea.show();
|
|
298
1071
|
}
|
|
299
1072
|
else {
|
|
300
|
-
|
|
301
|
-
if (bottomTab === 'compare' && compareListSelection?.type !== 'commit') {
|
|
302
|
-
maxRows = compareDiffTotalRows;
|
|
303
|
-
}
|
|
304
|
-
else if (bottomTab === 'history') {
|
|
305
|
-
maxRows = historyDiffTotalRows;
|
|
306
|
-
}
|
|
307
|
-
else if (bottomTab === 'diff') {
|
|
308
|
-
maxRows = diffTotalRows;
|
|
309
|
-
}
|
|
310
|
-
scrollDiff(direction, 3, maxRows);
|
|
1073
|
+
this.commitTextarea.hide();
|
|
311
1074
|
}
|
|
312
1075
|
}
|
|
313
1076
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
unstage,
|
|
325
|
-
scrollDiff,
|
|
326
|
-
scrollFileList,
|
|
327
|
-
scrollHistory,
|
|
328
|
-
scrollCompare,
|
|
329
|
-
historyScrollOffset,
|
|
330
|
-
compareScrollOffset,
|
|
331
|
-
setDiffScrollOffset,
|
|
332
|
-
setHistorySelectedIndex,
|
|
333
|
-
setCompareSelectedIndex,
|
|
334
|
-
markSelectionInitialized,
|
|
335
|
-
getItemIndexFromRow,
|
|
336
|
-
compareListSelection?.type,
|
|
337
|
-
compareDiffTotalRows,
|
|
338
|
-
diffTotalRows,
|
|
339
|
-
historyDiffTotalRows,
|
|
340
|
-
historyTotalRows,
|
|
341
|
-
activeModal,
|
|
342
|
-
explorerItems,
|
|
343
|
-
explorerScrollOffset,
|
|
344
|
-
explorerTotalRows,
|
|
345
|
-
explorerContentTotalRows,
|
|
346
|
-
topPaneHeight,
|
|
347
|
-
bottomPaneHeight,
|
|
348
|
-
setExplorerSelectedIndex,
|
|
349
|
-
setExplorerScrollOffset,
|
|
350
|
-
setExplorerFileScrollOffset,
|
|
351
|
-
]);
|
|
352
|
-
// Disable mouse when inputs are focused
|
|
353
|
-
const mouseDisabled = commitInputFocused || showBaseBranchPicker;
|
|
354
|
-
const { mouseEnabled, toggleMouse } = useMouse(handleMouseEvent, mouseDisabled);
|
|
355
|
-
toggleMouseRef.current = toggleMouse;
|
|
356
|
-
// Auto-tab mode: switch tabs based on file count transitions
|
|
357
|
-
const prevTotalFilesRef = useRef(totalFiles);
|
|
358
|
-
useEffect(() => {
|
|
359
|
-
if (!autoTabEnabled) {
|
|
360
|
-
prevTotalFilesRef.current = totalFiles;
|
|
361
|
-
return;
|
|
1077
|
+
else if (state.bottomTab === 'history') {
|
|
1078
|
+
// Hide commit textarea when not on commit tab
|
|
1079
|
+
if (this.commitTextarea) {
|
|
1080
|
+
this.commitTextarea.hide();
|
|
1081
|
+
}
|
|
1082
|
+
const selectedCommit = historyState?.selectedCommit ?? null;
|
|
1083
|
+
const commitDiff = historyState?.commitDiff ?? null;
|
|
1084
|
+
const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, this.layout.dimensions.bottomPaneHeight, this.currentTheme, state.wrapMode);
|
|
1085
|
+
this.bottomPaneTotalRows = totalRows;
|
|
1086
|
+
this.layout.bottomPane.setContent(content);
|
|
362
1087
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (bottomTab === 'compare' && compareListSelection?.type !== 'commit') {
|
|
391
|
-
maxRows = compareDiffTotalRows;
|
|
392
|
-
}
|
|
393
|
-
else if (bottomTab === 'diff') {
|
|
394
|
-
maxRows = diffTotalRows;
|
|
395
|
-
}
|
|
396
|
-
scrollDiff('up', 3, maxRows);
|
|
397
|
-
}
|
|
398
|
-
else if (currentPane === 'history') {
|
|
399
|
-
navigateHistoryUp();
|
|
400
|
-
}
|
|
401
|
-
else if (currentPane === 'compare') {
|
|
402
|
-
navigateCompareUp();
|
|
403
|
-
}
|
|
404
|
-
else if (currentPane === 'explorer') {
|
|
405
|
-
navigateExplorerUp();
|
|
406
|
-
}
|
|
407
|
-
}, [
|
|
408
|
-
currentPane,
|
|
409
|
-
bottomTab,
|
|
410
|
-
compareListSelection?.type,
|
|
411
|
-
compareDiffTotalRows,
|
|
412
|
-
diffTotalRows,
|
|
413
|
-
scrollDiff,
|
|
414
|
-
navigateHistoryUp,
|
|
415
|
-
navigateCompareUp,
|
|
416
|
-
navigateExplorerUp,
|
|
417
|
-
]);
|
|
418
|
-
const handleNavigateDown = useCallback(() => {
|
|
419
|
-
if (currentPane === 'files') {
|
|
420
|
-
setSelectedIndex((prev) => Math.min(totalFiles - 1, prev + 1));
|
|
421
|
-
}
|
|
422
|
-
else if (currentPane === 'diff') {
|
|
423
|
-
let maxRows;
|
|
424
|
-
if (bottomTab === 'compare' && compareListSelection?.type !== 'commit') {
|
|
425
|
-
maxRows = compareDiffTotalRows;
|
|
426
|
-
}
|
|
427
|
-
else if (bottomTab === 'diff') {
|
|
428
|
-
maxRows = diffTotalRows;
|
|
429
|
-
}
|
|
430
|
-
scrollDiff('down', 3, maxRows);
|
|
431
|
-
}
|
|
432
|
-
else if (currentPane === 'history') {
|
|
433
|
-
navigateHistoryDown();
|
|
434
|
-
}
|
|
435
|
-
else if (currentPane === 'compare') {
|
|
436
|
-
navigateCompareDown();
|
|
437
|
-
}
|
|
438
|
-
else if (currentPane === 'explorer') {
|
|
439
|
-
navigateExplorerDown();
|
|
440
|
-
}
|
|
441
|
-
}, [
|
|
442
|
-
currentPane,
|
|
443
|
-
bottomTab,
|
|
444
|
-
compareListSelection?.type,
|
|
445
|
-
compareDiffTotalRows,
|
|
446
|
-
diffTotalRows,
|
|
447
|
-
totalFiles,
|
|
448
|
-
scrollDiff,
|
|
449
|
-
navigateHistoryDown,
|
|
450
|
-
navigateCompareDown,
|
|
451
|
-
navigateExplorerDown,
|
|
452
|
-
]);
|
|
453
|
-
const handleTogglePane = useCallback(() => {
|
|
454
|
-
if (bottomTab === 'diff' || bottomTab === 'commit') {
|
|
455
|
-
setCurrentPane((prev) => (prev === 'files' ? 'diff' : 'files'));
|
|
456
|
-
}
|
|
457
|
-
else if (bottomTab === 'history') {
|
|
458
|
-
setCurrentPane((prev) => (prev === 'history' ? 'diff' : 'history'));
|
|
459
|
-
}
|
|
460
|
-
else if (bottomTab === 'compare') {
|
|
461
|
-
setCurrentPane((prev) => (prev === 'compare' ? 'diff' : 'compare'));
|
|
462
|
-
}
|
|
463
|
-
else if (bottomTab === 'explorer') {
|
|
464
|
-
setCurrentPane((prev) => (prev === 'explorer' ? 'diff' : 'explorer'));
|
|
465
|
-
}
|
|
466
|
-
}, [bottomTab]);
|
|
467
|
-
// File operations
|
|
468
|
-
const handleStage = useCallback(async () => {
|
|
469
|
-
if (currentFile && !currentFile.staged)
|
|
470
|
-
await stage(currentFile);
|
|
471
|
-
}, [currentFile, stage]);
|
|
472
|
-
const handleUnstage = useCallback(async () => {
|
|
473
|
-
if (currentFile?.staged)
|
|
474
|
-
await unstage(currentFile);
|
|
475
|
-
}, [currentFile, unstage]);
|
|
476
|
-
const handleSelect = useCallback(async () => {
|
|
477
|
-
if (!currentFile)
|
|
478
|
-
return;
|
|
479
|
-
if (currentFile.staged) {
|
|
480
|
-
await unstage(currentFile);
|
|
1088
|
+
else if (state.bottomTab === 'compare') {
|
|
1089
|
+
// Hide commit textarea when not on commit tab
|
|
1090
|
+
if (this.commitTextarea) {
|
|
1091
|
+
this.commitTextarea.hide();
|
|
1092
|
+
}
|
|
1093
|
+
const compareSelectionState = this.gitManager?.compareSelectionState;
|
|
1094
|
+
const compareDiff = compareSelectionState?.diff ?? null;
|
|
1095
|
+
if (compareDiff) {
|
|
1096
|
+
const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, this.layout.dimensions.bottomPaneHeight, this.currentTheme, state.wrapMode);
|
|
1097
|
+
this.bottomPaneTotalRows = totalRows;
|
|
1098
|
+
this.layout.bottomPane.setContent(content);
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
this.bottomPaneTotalRows = 0;
|
|
1102
|
+
this.layout.bottomPane.setContent('{gray-fg}Select a commit or file to view diff{/gray-fg}');
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
else if (state.bottomTab === 'explorer') {
|
|
1106
|
+
// Hide commit textarea when not on commit tab
|
|
1107
|
+
if (this.commitTextarea) {
|
|
1108
|
+
this.commitTextarea.hide();
|
|
1109
|
+
}
|
|
1110
|
+
const explorerState = this.explorerManager?.state;
|
|
1111
|
+
const selectedFile = explorerState?.selectedFile ?? null;
|
|
1112
|
+
const content = formatExplorerContent(selectedFile?.path ?? null, selectedFile?.content ?? null, width, state.explorerFileScrollOffset, this.layout.dimensions.bottomPaneHeight, selectedFile?.truncated ?? false, state.wrapMode, state.showMiddleDots);
|
|
1113
|
+
// TODO: formatExplorerContent should also return totalRows
|
|
1114
|
+
this.layout.bottomPane.setContent(content);
|
|
481
1115
|
}
|
|
482
1116
|
else {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
onToggleIncludeUncommitted: toggleIncludeUncommitted,
|
|
512
|
-
onCycleBaseBranch: openBaseBranchPicker,
|
|
513
|
-
onOpenThemePicker: () => setActiveModal('theme'),
|
|
514
|
-
onShrinkTopPane: () => adjustSplitRatio(-SPLIT_RATIO_STEP),
|
|
515
|
-
onGrowTopPane: () => adjustSplitRatio(SPLIT_RATIO_STEP),
|
|
516
|
-
onOpenHotkeysModal: () => setActiveModal('hotkeys'),
|
|
517
|
-
onToggleMouse: toggleMouse,
|
|
518
|
-
onToggleFollow: () => setWatcherEnabled((prev) => !prev),
|
|
519
|
-
onToggleAutoTab: () => setAutoTabEnabled((prev) => !prev),
|
|
520
|
-
onToggleWrap: () => setWrapMode((prev) => !prev),
|
|
521
|
-
onToggleMiddleDots: bottomTab === 'explorer' ? () => setShowMiddleDots((prev) => !prev) : undefined,
|
|
522
|
-
onToggleHideHiddenFiles: bottomTab === 'explorer' ? () => setHideHiddenFiles((prev) => !prev) : undefined,
|
|
523
|
-
onToggleHideGitignored: bottomTab === 'explorer' ? () => setHideGitignored((prev) => !prev) : undefined,
|
|
524
|
-
onExplorerEnter: bottomTab === 'explorer' ? explorerEnterDirectory : undefined,
|
|
525
|
-
onExplorerBack: bottomTab === 'explorer' ? explorerGoUp : undefined,
|
|
526
|
-
}, currentPane, commitInputFocused || activeModal !== null || showBaseBranchPicker);
|
|
527
|
-
// Discard confirmation
|
|
528
|
-
useInput((input, key) => {
|
|
529
|
-
if (!pendingDiscard)
|
|
530
|
-
return;
|
|
531
|
-
if (input === 'y' || input === 'Y') {
|
|
532
|
-
discard(pendingDiscard);
|
|
533
|
-
setPendingDiscard(null);
|
|
1117
|
+
// Hide commit textarea when not on commit tab
|
|
1118
|
+
if (this.commitTextarea) {
|
|
1119
|
+
this.commitTextarea.hide();
|
|
1120
|
+
}
|
|
1121
|
+
const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, this.layout.dimensions.bottomPaneHeight, this.currentTheme, state.wrapMode);
|
|
1122
|
+
this.bottomPaneTotalRows = totalRows;
|
|
1123
|
+
this.layout.bottomPane.setContent(content);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
updateFooter() {
|
|
1127
|
+
const state = this.uiState.state;
|
|
1128
|
+
const width = this.screen.width || 80;
|
|
1129
|
+
const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, state.showMiddleDots, width);
|
|
1130
|
+
this.layout.footerBox.setContent(content);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Exit the application cleanly.
|
|
1134
|
+
*/
|
|
1135
|
+
exit() {
|
|
1136
|
+
// Clean up
|
|
1137
|
+
if (this.gitManager) {
|
|
1138
|
+
removeManagerForRepo(this.repoPath);
|
|
1139
|
+
}
|
|
1140
|
+
if (this.explorerManager) {
|
|
1141
|
+
this.explorerManager.dispose();
|
|
1142
|
+
}
|
|
1143
|
+
if (this.fileWatcher) {
|
|
1144
|
+
this.fileWatcher.stop();
|
|
534
1145
|
}
|
|
535
|
-
|
|
536
|
-
|
|
1146
|
+
if (this.commandServer) {
|
|
1147
|
+
this.commandServer.stop();
|
|
537
1148
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1149
|
+
// Destroy screen (this will clean up terminal)
|
|
1150
|
+
this.screen.destroy();
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Start the application (returns when app exits).
|
|
1154
|
+
*/
|
|
1155
|
+
start() {
|
|
1156
|
+
return new Promise((resolve) => {
|
|
1157
|
+
this.screen.on('destroy', () => {
|
|
1158
|
+
resolve();
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
541
1162
|
}
|