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
package/dist/App.js CHANGED
@@ -1,541 +1,1162 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
3
- import { Box, Text, useApp, useInput } from 'ink';
4
- import { Header, getHeaderHeight } from './components/Header.js';
5
- import { getFileAtIndex, getTotalFileCount } from './components/FileList.js';
6
- import { getCommitIndexFromRow } from './components/HistoryView.js';
7
- import { Footer } from './components/Footer.js';
8
- import { TopPane } from './components/TopPane.js';
9
- import { BottomPane } from './components/BottomPane.js';
10
- import { useWatcher } from './hooks/useWatcher.js';
11
- import { useGit } from './hooks/useGit.js';
12
- import { useKeymap } from './hooks/useKeymap.js';
13
- import { useMouse } from './hooks/useMouse.js';
14
- import { useTerminalSize } from './hooks/useTerminalSize.js';
15
- import { useLayout, SPLIT_RATIO_STEP } from './hooks/useLayout.js';
16
- import { useHistoryState } from './hooks/useHistoryState.js';
17
- import { useCompareState } from './hooks/useCompareState.js';
18
- import { useExplorerState } from './hooks/useExplorerState.js';
19
- import { getClickedFileIndex, getClickedTab, getFooterLeftClick, isButtonAreaClick, isInPane, } from './utils/mouseCoordinates.js';
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
- import { ThemePicker } from './components/ThemePicker.js';
22
- import { HotkeysModal } from './components/HotkeysModal.js';
23
- import { BaseBranchPicker } from './components/BaseBranchPicker.js';
24
- import { buildDiffDisplayRows, getDisplayRowsLineNumWidth, getWrappedRowCount, } from './utils/displayRows.js';
25
- import { getExplorerContentTotalRows } from './components/ExplorerContentView.js';
26
- import { getMaxScrollOffset } from './components/ScrollableList.js';
27
- export function App({ config, initialPath }) {
28
- const { exit } = useApp();
29
- // Terminal dimensions
30
- const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
31
- // File watcher
32
- const { state: watcherState, setEnabled: setWatcherEnabled } = useWatcher(config.watcherEnabled, config.targetFile, config.debug);
33
- // Determine repo path
34
- const repoPath = initialPath ?? watcherState.path ?? process.cwd();
35
- // Git state
36
- const { status, diff, selectedFile, isLoading, error, selectFile, stage, unstage, discard, stageAll, unstageAll, commit, refresh, getHeadCommitMessage, compareDiff, compareLoading, compareError, refreshCompareDiff, getCandidateBaseBranches, setCompareBaseBranch, historySelectedCommit, historyCommitDiff, selectHistoryCommit, compareSelectionDiff, selectCompareCommit, } = useGit(repoPath);
37
- // File list data
38
- const files = status?.files ?? [];
39
- const totalFiles = getTotalFileCount(files);
40
- const stagedCount = files.filter((f) => f.staged).length;
41
- // UI state
42
- const [currentPane, setCurrentPane] = useState('files');
43
- const [bottomTab, setBottomTab] = useState('diff');
44
- const [selectedIndex, setSelectedIndex] = useState(0);
45
- const [pendingDiscard, setPendingDiscard] = useState(null);
46
- const [commitInputFocused, setCommitInputFocused] = useState(false);
47
- const [currentTheme, setCurrentTheme] = useState(config.theme);
48
- const [activeModal, setActiveModal] = useState(null);
49
- const [autoTabEnabled, setAutoTabEnabled] = useState(false);
50
- const [wrapMode, setWrapMode] = useState(false);
51
- // Explorer scroll state
52
- const [explorerScrollOffset, setExplorerScrollOffset] = useState(0);
53
- const [explorerFileScrollOffset, setExplorerFileScrollOffset] = useState(0);
54
- // Explorer display options
55
- const [showMiddleDots, setShowMiddleDots] = useState(false);
56
- const [hideHiddenFiles, setHideHiddenFiles] = useState(true);
57
- const [hideGitignored, setHideGitignored] = useState(true);
58
- // Header height calculation
59
- const headerHeight = getHeaderHeight(repoPath, status?.branch ?? null, watcherState, terminalWidth, error, isLoading);
60
- const extraOverhead = headerHeight - 1;
61
- // Layout and scroll state
62
- const { topPaneHeight, bottomPaneHeight, paneBoundaries, splitRatio, adjustSplitRatio, fileListScrollOffset, diffScrollOffset, historyScrollOffset, compareScrollOffset, setDiffScrollOffset, setHistoryScrollOffset, setCompareScrollOffset, scrollDiff, scrollFileList, scrollHistory, scrollCompare, } = useLayout(terminalHeight, terminalWidth, files, selectedIndex, diff, bottomTab, undefined, config.splitRatio, extraOverhead);
63
- // Calculate display row counts for scroll calculations
64
- // When wrap mode is enabled, account for wrapped lines
65
- const diffTotalRows = useMemo(() => {
66
- const displayRows = buildDiffDisplayRows(diff);
67
- if (!wrapMode)
68
- return displayRows.length;
69
- const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
70
- const contentWidth = terminalWidth - lineNumWidth - 5;
71
- return getWrappedRowCount(displayRows, contentWidth, true);
72
- }, [diff, wrapMode, terminalWidth]);
73
- // History state
74
- const { commits, historySelectedIndex, setHistorySelectedIndex, historyDiffTotalRows, navigateHistoryUp, navigateHistoryDown, historyTotalRows, } = useHistoryState({
75
- repoPath,
76
- isActive: bottomTab === 'history',
77
- selectHistoryCommit,
78
- historyCommitDiff,
79
- historySelectedCommit,
80
- topPaneHeight,
81
- historyScrollOffset,
82
- setHistoryScrollOffset,
83
- setDiffScrollOffset,
84
- status,
85
- wrapMode,
86
- terminalWidth,
87
- });
88
- // Compare state
89
- const { includeUncommitted, compareListSelection, baseBranchCandidates, showBaseBranchPicker, compareTotalItems, compareDiffTotalRows, setCompareSelectedIndex, toggleIncludeUncommitted, openBaseBranchPicker, closeBaseBranchPicker, selectBaseBranch, navigateCompareUp, navigateCompareDown, markSelectionInitialized, getItemIndexFromRow, } = useCompareState({
90
- repoPath,
91
- isActive: bottomTab === 'compare',
92
- compareDiff,
93
- refreshCompareDiff,
94
- getCandidateBaseBranches,
95
- setCompareBaseBranch,
96
- selectCompareCommit,
97
- topPaneHeight,
98
- compareScrollOffset,
99
- setCompareScrollOffset,
100
- setDiffScrollOffset,
101
- status,
102
- wrapMode,
103
- terminalWidth,
104
- });
105
- // Explorer state
106
- const { currentPath: explorerCurrentPath, items: explorerItems, selectedIndex: explorerSelectedIndex, setSelectedIndex: setExplorerSelectedIndex, selectedFile: explorerSelectedFile, navigateUp: navigateExplorerUp, navigateDown: navigateExplorerDown, enterDirectory: explorerEnterDirectory, goUp: explorerGoUp, isLoading: explorerIsLoading, error: explorerError, explorerTotalRows, } = useExplorerState({
107
- repoPath,
108
- isActive: bottomTab === 'explorer',
109
- topPaneHeight,
110
- explorerScrollOffset,
111
- setExplorerScrollOffset,
112
- fileScrollOffset: explorerFileScrollOffset,
113
- setFileScrollOffset: setExplorerFileScrollOffset,
114
- hideHiddenFiles,
115
- hideGitignored,
116
- });
117
- // Calculate explorer content total rows for scroll bounds
118
- const explorerContentTotalRows = useMemo(() => {
119
- if (!explorerSelectedFile)
120
- return 0;
121
- return getExplorerContentTotalRows(explorerSelectedFile.content, explorerSelectedFile.path, explorerSelectedFile.truncated ?? false, terminalWidth, wrapMode);
122
- }, [explorerSelectedFile, terminalWidth, wrapMode]);
123
- // Keep a ref to paneBoundaries for use in callbacks
124
- const paneBoundariesRef = useRef(paneBoundaries);
125
- paneBoundariesRef.current = paneBoundaries;
126
- // Save split ratio to config when it changes
127
- const initialSplitRatioRef = useRef(config.splitRatio);
128
- useEffect(() => {
129
- if (splitRatio !== initialSplitRatioRef.current) {
130
- const timer = setTimeout(() => saveConfig({ splitRatio }), 500);
131
- return () => clearTimeout(timer);
132
- }
133
- }, [splitRatio]);
134
- // Currently selected file
135
- const currentFile = useMemo(() => getFileAtIndex(files, selectedIndex), [files, selectedIndex]);
136
- // Auto-select when files change
137
- useEffect(() => {
138
- if (totalFiles > 0 && selectedIndex >= totalFiles) {
139
- setSelectedIndex(Math.max(0, totalFiles - 1));
140
- }
141
- }, [totalFiles, selectedIndex]);
142
- // Update selected file in useGit
143
- useEffect(() => {
144
- selectFile(currentFile);
145
- }, [currentFile, selectFile]);
146
- // Reset diff scroll when file selection changes
147
- useEffect(() => {
148
- if (bottomTab === 'diff' || bottomTab === 'commit') {
149
- setDiffScrollOffset(0);
150
- }
151
- }, [selectedIndex, bottomTab, setDiffScrollOffset]);
152
- // Reset diff scroll when wrap mode changes
153
- useEffect(() => {
154
- setDiffScrollOffset(0);
155
- }, [wrapMode, setDiffScrollOffset]);
156
- // Tab switching (defined early so handleMouseEvent can use it)
157
- const handleSwitchTab = useCallback((tab) => {
158
- setBottomTab(tab);
159
- const paneMap = {
160
- diff: 'files',
161
- commit: 'commit',
162
- history: 'history',
163
- compare: 'compare',
164
- explorer: 'explorer',
165
- };
166
- setCurrentPane(paneMap[tab]);
167
- }, []);
168
- // Ref for toggleMouse (set after useMouse, used in handleMouseEvent)
169
- const toggleMouseRef = useRef(() => { });
170
- // Mouse handler
171
- const handleMouseEvent = useCallback((event) => {
172
- const { x, y, type, button } = event;
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
- // Footer clicks
181
- if (y === footerRow && button === 'left') {
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
- // Top pane clicks
208
- if (isInPane(y, stagingPaneStart + 1, fileListEnd)) {
209
- // ScrollableList shows scroll indicators when content exceeds maxHeight.
210
- // This takes 1 row at top, so we need to offset click calculations.
211
- // FileList doesn't use ScrollableList, so no offset needed for diff/commit tabs.
212
- const listMaxHeight = topPaneHeight - 1;
213
- const getScrollIndicatorOffset = (itemCount) => itemCount > listMaxHeight ? 1 : 0;
214
- if (bottomTab === 'diff' || bottomTab === 'commit') {
215
- const clickedIndex = getClickedFileIndex(y, fileListScrollOffset, files, stagingPaneStart, fileListEnd);
216
- if (clickedIndex >= 0 && clickedIndex < totalFiles) {
217
- setSelectedIndex(clickedIndex);
218
- setCurrentPane('files');
219
- const file = getFileAtIndex(files, clickedIndex);
220
- if (file) {
221
- if (button === 'right' && !file.staged && file.status !== 'untracked') {
222
- setPendingDiscard(file);
223
- }
224
- else if (button === 'left' && isButtonAreaClick(x)) {
225
- if (file.staged) {
226
- unstage(file);
227
- }
228
- else {
229
- stage(file);
230
- }
231
- }
232
- }
233
- return;
234
- }
235
- }
236
- else if (bottomTab === 'history') {
237
- const offset = getScrollIndicatorOffset(commits.length);
238
- const visualRow = y - stagingPaneStart - 1 - offset;
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 if (bottomTab === 'compare' && compareDiff) {
248
- const offset = getScrollIndicatorOffset(compareTotalItems);
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
- else if (bottomTab === 'explorer') {
259
- const offset = getScrollIndicatorOffset(explorerItems.length);
260
- const visualRow = y - stagingPaneStart - 1 - offset + explorerScrollOffset;
261
- if (visualRow >= 0 && visualRow < explorerItems.length) {
262
- setExplorerSelectedIndex(visualRow);
263
- setCurrentPane('explorer');
264
- return;
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
- // Bottom pane clicks
269
- if (isInPane(y, diffPaneStart, diffPaneEnd)) {
270
- setCurrentPane(bottomTab);
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 (type === 'scroll-up' || type === 'scroll-down') {
274
- const direction = type === 'scroll-up' ? 'up' : 'down';
275
- if (isInPane(y, stagingPaneStart, fileListEnd)) {
276
- if (bottomTab === 'diff' || bottomTab === 'commit') {
277
- scrollFileList(direction);
278
- }
279
- else if (bottomTab === 'history') {
280
- scrollHistory(direction, historyTotalRows);
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
- else if (bottomTab === 'compare') {
283
- scrollCompare(direction, compareTotalItems);
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
- else if (bottomTab === 'explorer') {
286
- // Scroll explorer list (maxHeight is topPaneHeight - 1 for "EXPLORER" header)
287
- const scrollAmount = direction === 'up' ? -3 : 3;
288
- const maxOffset = getMaxScrollOffset(explorerTotalRows, topPaneHeight - 1);
289
- setExplorerScrollOffset((prev) => Math.max(0, Math.min(prev + scrollAmount, maxOffset)));
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
- if (bottomTab === 'explorer') {
294
- // Scroll file content with proper bounds
295
- const scrollAmount = direction === 'up' ? -3 : 3;
296
- const maxOffset = getMaxScrollOffset(explorerContentTotalRows, bottomPaneHeight - 1);
297
- setExplorerFileScrollOffset((prev) => Math.max(0, Math.min(prev + scrollAmount, maxOffset)));
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
- let maxRows;
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
- terminalWidth,
316
- fileListScrollOffset,
317
- files,
318
- totalFiles,
319
- bottomTab,
320
- commits,
321
- compareDiff,
322
- compareTotalItems,
323
- stage,
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
- const prevCount = prevTotalFilesRef.current;
364
- // Only trigger on transitions, not on current state
365
- if (prevCount === 0 && totalFiles > 0) {
366
- // Files appeared: switch to diff view
367
- handleSwitchTab('diff');
368
- }
369
- else if (prevCount > 0 && totalFiles === 0) {
370
- // Files disappeared: switch to history view and select newest commit
371
- setHistorySelectedIndex(0);
372
- setHistoryScrollOffset(0);
373
- handleSwitchTab('history');
374
- }
375
- prevTotalFilesRef.current = totalFiles;
376
- }, [
377
- totalFiles,
378
- autoTabEnabled,
379
- handleSwitchTab,
380
- setHistorySelectedIndex,
381
- setHistoryScrollOffset,
382
- ]);
383
- // Navigation handlers
384
- const handleNavigateUp = useCallback(() => {
385
- if (currentPane === 'files') {
386
- setSelectedIndex((prev) => Math.max(0, prev - 1));
387
- }
388
- else if (currentPane === 'diff') {
389
- let maxRows;
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
- await stage(currentFile);
484
- }
485
- }, [currentFile, stage, unstage]);
486
- const handleCommit = useCallback(() => handleSwitchTab('commit'), [handleSwitchTab]);
487
- const handleCommitCancel = useCallback(() => {
488
- setBottomTab('diff');
489
- setCurrentPane('files');
490
- }, []);
491
- // Modal handlers
492
- const handleThemeSelect = useCallback((theme) => {
493
- setCurrentTheme(theme);
494
- setActiveModal(null);
495
- saveConfig({ theme });
496
- }, []);
497
- // Keymap
498
- useKeymap({
499
- onStage: handleStage,
500
- onUnstage: handleUnstage,
501
- onStageAll: stageAll,
502
- onUnstageAll: unstageAll,
503
- onCommit: handleCommit,
504
- onQuit: exit,
505
- onRefresh: refresh,
506
- onNavigateUp: handleNavigateUp,
507
- onNavigateDown: handleNavigateDown,
508
- onTogglePane: handleTogglePane,
509
- onSwitchTab: handleSwitchTab,
510
- onSelect: handleSelect,
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
- else if (input === 'n' || input === 'N' || key.escape) {
536
- setPendingDiscard(null);
1146
+ if (this.commandServer) {
1147
+ this.commandServer.stop();
537
1148
  }
538
- }, { isActive: !!pendingDiscard });
539
- const Separator = () => _jsx(Text, { dimColor: true, children: '─'.repeat(terminalWidth) });
540
- return (_jsxs(Box, { flexDirection: "column", height: terminalHeight, width: terminalWidth, overflowX: "hidden", children: [_jsx(Box, { height: headerHeight, width: terminalWidth, children: _jsx(Header, { repoPath: repoPath, branch: status?.branch ?? null, isLoading: isLoading, error: error, debug: config.debug, watcherState: watcherState, width: terminalWidth }) }), _jsx(Separator, {}), _jsx(TopPane, { bottomTab: bottomTab, currentPane: currentPane, terminalWidth: terminalWidth, topPaneHeight: topPaneHeight, files: files, selectedIndex: selectedIndex, fileListScrollOffset: fileListScrollOffset, stagedCount: stagedCount, onStage: stage, onUnstage: unstage, commits: commits, historySelectedIndex: historySelectedIndex, historyScrollOffset: historyScrollOffset, onSelectHistoryCommit: (_, idx) => setHistorySelectedIndex(idx), compareDiff: compareDiff, compareListSelection: compareListSelection, compareScrollOffset: compareScrollOffset, includeUncommitted: includeUncommitted, explorerCurrentPath: explorerCurrentPath, explorerItems: explorerItems, explorerSelectedIndex: explorerSelectedIndex, explorerScrollOffset: explorerScrollOffset, explorerIsLoading: explorerIsLoading, explorerError: explorerError, hideHiddenFiles: hideHiddenFiles, hideGitignored: hideGitignored }), _jsx(Separator, {}), _jsx(BottomPane, { bottomTab: bottomTab, currentPane: currentPane, terminalWidth: terminalWidth, bottomPaneHeight: bottomPaneHeight, diffScrollOffset: diffScrollOffset, currentTheme: currentTheme, diff: diff, selectedFile: selectedFile, stagedCount: stagedCount, onCommit: commit, onCommitCancel: handleCommitCancel, getHeadCommitMessage: getHeadCommitMessage, onCommitInputFocusChange: setCommitInputFocused, historySelectedCommit: historySelectedCommit, historyCommitDiff: historyCommitDiff, compareDiff: compareDiff, compareLoading: compareLoading, compareError: compareError, compareListSelection: compareListSelection, compareSelectionDiff: compareSelectionDiff, wrapMode: wrapMode, explorerSelectedFile: explorerSelectedFile, explorerFileScrollOffset: explorerFileScrollOffset, showMiddleDots: showMiddleDots }), _jsx(Separator, {}), pendingDiscard ? (_jsxs(Box, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Discard changes to", ' '] }), _jsx(Text, { color: "cyan", children: pendingDiscard.path }), _jsxs(Text, { color: "yellow", bold: true, children: ["?", ' '] }), _jsx(Text, { dimColor: true, children: "(y/n)" })] })) : (_jsx(Footer, { activeTab: bottomTab, mouseEnabled: mouseEnabled, autoTabEnabled: autoTabEnabled, wrapMode: wrapMode, showMiddleDots: showMiddleDots })), activeModal === 'theme' && (_jsx(Box, { position: "absolute", marginTop: 0, marginLeft: 0, children: _jsx(ThemePicker, { currentTheme: currentTheme, onSelect: handleThemeSelect, onCancel: () => setActiveModal(null), width: terminalWidth, height: terminalHeight }) })), activeModal === 'hotkeys' && (_jsx(Box, { position: "absolute", marginTop: 0, marginLeft: 0, children: _jsx(HotkeysModal, { onClose: () => setActiveModal(null), width: terminalWidth, height: terminalHeight }) })), showBaseBranchPicker && (_jsx(Box, { position: "absolute", marginTop: 0, marginLeft: 0, children: _jsx(BaseBranchPicker, { candidates: baseBranchCandidates, currentBranch: compareDiff?.baseBranch ?? null, onSelect: selectBaseBranch, onCancel: closeBaseBranchPicker, width: terminalWidth, height: terminalHeight }) }))] }));
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
  }