diffstalker 0.1.7 → 0.2.1

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 (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
package/dist/App.js CHANGED
@@ -1,541 +1,916 @@
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 } from './ui/Layout.js';
3
+ import { setupKeyBindings } from './KeyBindings.js';
4
+ import { renderTopPane, renderBottomPane } from './ui/PaneRenderers.js';
5
+ import { setupMouseHandlers } from './MouseHandlers.js';
6
+ import { FollowMode } from './FollowMode.js';
7
+ import { formatHeader } from './ui/widgets/Header.js';
8
+ import { formatFooter } from './ui/widgets/Footer.js';
9
+ import { getFileAtIndex, getRowFromFileIndex } from './ui/widgets/FileList.js';
10
+ import { getCommitAtIndex } from './ui/widgets/HistoryView.js';
11
+ import { getNextCompareSelection, getRowFromCompareSelection, } from './ui/widgets/CompareListView.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 { FileFinder } from './ui/modals/FileFinder.js';
18
+ import { CommitFlowState } from './state/CommitFlowState.js';
19
+ import { UIState } from './state/UIState.js';
20
+ import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.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);
178
- return;
179
- }
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
- }
22
+ import { getCategoryForIndex, getIndexForCategoryPosition, } from './utils/fileCategories.js';
23
+ /**
24
+ * Main application controller.
25
+ * Coordinates between GitStateManager, UIState, and blessed widgets.
26
+ */
27
+ export class App {
28
+ screen;
29
+ layout;
30
+ uiState;
31
+ gitManager = null;
32
+ followMode = null;
33
+ explorerManager = null;
34
+ config;
35
+ commandServer;
36
+ // Current state
37
+ repoPath;
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
+ // Selection anchor: remembers category + position before stage/unstage
47
+ pendingSelectionAnchor = null;
48
+ constructor(options) {
49
+ this.config = options.config;
50
+ this.commandServer = options.commandServer ?? null;
51
+ this.repoPath = options.initialPath ?? process.cwd();
52
+ this.currentTheme = options.config.theme;
53
+ // Initialize UI state with config values
54
+ this.uiState = new UIState({
55
+ splitRatio: options.config.splitRatio ?? 0.4,
56
+ });
57
+ // Create blessed screen
58
+ this.screen = blessed.screen({
59
+ smartCSR: true,
60
+ fullUnicode: true,
61
+ title: 'diffstalker',
62
+ mouse: true,
63
+ terminal: 'xterm-256color',
64
+ });
65
+ // Force 256-color support (terminfo detection can be unreliable)
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ const screenAny = this.screen;
68
+ if (screenAny.tput) {
69
+ screenAny.tput.colors = 256;
70
+ }
71
+ if (screenAny.program?.tput) {
72
+ screenAny.program.tput.colors = 256;
73
+ }
74
+ // Create layout
75
+ this.layout = new LayoutManager(this.screen, this.uiState.state.splitRatio);
76
+ // Handle screen resize - re-render content
77
+ // Use setImmediate to ensure screen dimensions are fully updated
78
+ this.screen.on('resize', () => {
79
+ setImmediate(() => this.render());
80
+ });
81
+ // Initialize commit flow state
82
+ this.commitFlowState = new CommitFlowState({
83
+ getHeadMessage: () => this.gitManager?.getHeadCommitMessage() ?? Promise.resolve(''),
84
+ onCommit: async (message, amend) => {
85
+ await this.gitManager?.commit(message, amend);
86
+ },
87
+ onSuccess: () => {
88
+ this.uiState.setTab('diff');
89
+ this.render();
90
+ },
91
+ });
92
+ // Create commit textarea (hidden initially)
93
+ this.commitTextarea = blessed.textarea({
94
+ parent: this.layout.bottomPane,
95
+ top: 3,
96
+ left: 1,
97
+ width: '100%-4',
98
+ height: 1,
99
+ inputOnFocus: true,
100
+ hidden: true,
101
+ style: {
102
+ fg: 'white',
103
+ bg: 'default',
104
+ },
105
+ });
106
+ // Handle textarea submission
107
+ this.commitTextarea.on('submit', () => {
108
+ this.commitFlowState.submit();
109
+ });
110
+ // Sync textarea value with commit state
111
+ this.commitTextarea.on('keypress', () => {
112
+ // Defer to next tick to get updated value
113
+ setImmediate(() => {
114
+ const value = this.commitTextarea?.getValue() ?? '';
115
+ this.commitFlowState.setMessage(value);
116
+ });
117
+ });
118
+ // Setup keyboard handlers
119
+ this.setupKeyboardHandlers();
120
+ // Setup mouse handlers
121
+ this.setupMouseEventHandlers();
122
+ // Setup state change listeners
123
+ this.setupStateListeners();
124
+ // Setup follow mode if enabled
125
+ if (this.config.watcherEnabled) {
126
+ this.followMode = new FollowMode(this.config.targetFile, () => this.repoPath, {
127
+ onRepoChange: (newPath, state) => this.handleFollowRepoChange(newPath, state),
128
+ onFileNavigate: (rawContent) => this.handleFollowFileNavigate(rawContent),
129
+ });
130
+ this.followMode.start();
131
+ }
132
+ // Setup IPC command handler if command server provided
133
+ if (this.commandServer) {
134
+ this.setupCommandHandler();
135
+ }
136
+ // Initialize git manager for current repo
137
+ this.initGitManager();
138
+ // Initial render
139
+ this.render();
140
+ }
141
+ setupKeyboardHandlers() {
142
+ setupKeyBindings(this.screen, {
143
+ exit: () => this.exit(),
144
+ navigateDown: () => this.navigateDown(),
145
+ navigateUp: () => this.navigateUp(),
146
+ stageSelected: () => this.stageSelected(),
147
+ unstageSelected: () => this.unstageSelected(),
148
+ stageAll: () => this.stageAll(),
149
+ unstageAll: () => this.unstageAll(),
150
+ toggleSelected: () => this.toggleSelected(),
151
+ enterExplorerDirectory: () => this.enterExplorerDirectory(),
152
+ goExplorerUp: () => this.goExplorerUp(),
153
+ openFileFinder: () => this.openFileFinder(),
154
+ focusCommitInput: () => this.focusCommitInput(),
155
+ unfocusCommitInput: () => this.unfocusCommitInput(),
156
+ refresh: () => this.refresh(),
157
+ toggleMouseMode: () => this.toggleMouseMode(),
158
+ toggleFollow: () => this.toggleFollow(),
159
+ showDiscardConfirm: (file) => this.showDiscardConfirm(file),
160
+ render: () => this.render(),
161
+ }, {
162
+ hasActiveModal: () => this.activeModal !== null,
163
+ getBottomTab: () => this.uiState.state.bottomTab,
164
+ getCurrentPane: () => this.uiState.state.currentPane,
165
+ isCommitInputFocused: () => this.commitFlowState.state.inputFocused,
166
+ getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
167
+ getSelectedIndex: () => this.uiState.state.selectedIndex,
168
+ uiState: this.uiState,
169
+ explorerManager: this.explorerManager,
170
+ commitFlowState: this.commitFlowState,
171
+ gitManager: this.gitManager,
172
+ layout: this.layout,
173
+ });
174
+ }
175
+ setupMouseEventHandlers() {
176
+ setupMouseHandlers(this.layout, {
177
+ selectHistoryCommitByIndex: (index) => this.selectHistoryCommitByIndex(index),
178
+ selectCompareItem: (selection) => this.selectCompareItem(selection),
179
+ selectFileByIndex: (index) => this.selectFileByIndex(index),
180
+ toggleFileByIndex: (index) => this.toggleFileByIndex(index),
181
+ toggleMouseMode: () => this.toggleMouseMode(),
182
+ toggleFollow: () => this.toggleFollow(),
183
+ render: () => this.render(),
184
+ }, {
185
+ uiState: this.uiState,
186
+ explorerManager: this.explorerManager,
187
+ getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
188
+ getHistoryCommitCount: () => this.gitManager?.historyState.commits.length ?? 0,
189
+ getCompareCommits: () => this.gitManager?.compareState?.compareDiff?.commits ?? [],
190
+ getCompareFiles: () => this.gitManager?.compareState?.compareDiff?.files ?? [],
191
+ getBottomPaneTotalRows: () => this.bottomPaneTotalRows,
192
+ getScreenWidth: () => this.screen.width || 80,
193
+ });
194
+ }
195
+ async toggleFileByIndex(index) {
196
+ const files = this.gitManager?.state.status?.files ?? [];
197
+ const file = getFileAtIndex(files, index);
198
+ if (file) {
199
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
200
+ if (file.staged) {
201
+ await this.gitManager?.unstage(file);
202
+ }
203
+ else {
204
+ await this.gitManager?.stage(file);
205
+ }
206
+ }
207
+ }
208
+ setupStateListeners() {
209
+ // Update footer when UI state changes
210
+ this.uiState.on('change', () => {
211
+ this.render();
212
+ });
213
+ // Load data when switching tabs
214
+ this.uiState.on('tab-change', (tab) => {
215
+ if (tab === 'history') {
216
+ this.gitManager?.loadHistory();
206
217
  }
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
- }
218
+ else if (tab === 'compare') {
219
+ this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
220
+ }
221
+ else if (tab === 'explorer') {
222
+ // Explorer is already loaded on init, but refresh if needed
223
+ if (!this.explorerManager?.state.displayRows.length) {
224
+ this.explorerManager?.loadDirectory('');
235
225
  }
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
- }
226
+ }
227
+ });
228
+ // Handle modal opening/closing
229
+ this.uiState.on('modal-change', (modal) => {
230
+ // Close any existing modal
231
+ if (this.activeModal) {
232
+ this.activeModal = null;
233
+ }
234
+ // Open new modal if requested
235
+ if (modal === 'theme') {
236
+ this.activeModal = new ThemePicker(this.screen, this.currentTheme, (theme) => {
237
+ this.currentTheme = theme;
238
+ saveConfig({ theme });
239
+ this.activeModal = null;
240
+ this.uiState.closeModal();
241
+ this.render();
242
+ }, () => {
243
+ this.activeModal = null;
244
+ this.uiState.closeModal();
245
+ });
246
+ this.activeModal.focus();
247
+ }
248
+ else if (modal === 'hotkeys') {
249
+ this.activeModal = new HotkeysModal(this.screen, () => {
250
+ this.activeModal = null;
251
+ this.uiState.closeModal();
252
+ });
253
+ this.activeModal.focus();
254
+ }
255
+ else if (modal === 'baseBranch') {
256
+ // Load candidate branches and show picker
257
+ this.gitManager?.getCandidateBaseBranches().then((branches) => {
258
+ const currentBranch = this.gitManager?.compareState.compareBaseBranch ?? null;
259
+ this.activeModal = new BaseBranchPicker(this.screen, branches, currentBranch, (branch) => {
260
+ this.activeModal = null;
261
+ this.uiState.closeModal();
262
+ // Set base branch and refresh compare view
263
+ const includeUncommitted = this.uiState.state.includeUncommitted;
264
+ this.gitManager?.setCompareBaseBranch(branch, includeUncommitted);
265
+ }, () => {
266
+ this.activeModal = null;
267
+ this.uiState.closeModal();
268
+ });
269
+ this.activeModal.focus();
270
+ });
271
+ }
272
+ });
273
+ // Save split ratio to config when it changes
274
+ let saveTimer = null;
275
+ this.uiState.on('change', (state) => {
276
+ if (saveTimer)
277
+ clearTimeout(saveTimer);
278
+ saveTimer = setTimeout(() => {
279
+ if (state.splitRatio !== this.config.splitRatio) {
280
+ saveConfig({ splitRatio: state.splitRatio });
246
281
  }
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
- }
282
+ }, 500);
283
+ });
284
+ }
285
+ handleFollowRepoChange(newPath, _state) {
286
+ const oldRepoPath = this.repoPath;
287
+ this.repoPath = newPath;
288
+ this.initGitManager(oldRepoPath);
289
+ this.resetRepoSpecificState();
290
+ this.loadCurrentTabData();
291
+ this.render();
292
+ }
293
+ handleFollowFileNavigate(rawContent) {
294
+ this.navigateToFile(rawContent);
295
+ this.render();
296
+ }
297
+ initGitManager(oldRepoPath) {
298
+ // Clean up existing manager
299
+ if (this.gitManager) {
300
+ this.gitManager.removeAllListeners();
301
+ // Use oldRepoPath if provided (when switching repos), otherwise use current path
302
+ removeManagerForRepo(oldRepoPath ?? this.repoPath);
303
+ }
304
+ // Get or create manager for this repo
305
+ this.gitManager = getManagerForRepo(this.repoPath);
306
+ // Listen to state changes
307
+ this.gitManager.on('state-change', () => {
308
+ const files = this.gitManager?.state.status?.files ?? [];
309
+ if (this.pendingSelectionAnchor) {
310
+ // Restore selection to same category + position after stage/unstage
311
+ const anchor = this.pendingSelectionAnchor;
312
+ this.pendingSelectionAnchor = null;
313
+ const newIndex = getIndexForCategoryPosition(files, anchor.category, anchor.categoryIndex);
314
+ this.uiState.setSelectedIndex(newIndex);
315
+ this.selectFileByIndex(newIndex);
316
+ }
317
+ else if (files.length > 0) {
318
+ // Default: clamp selected index to valid range
319
+ const maxIndex = files.length - 1;
320
+ if (this.uiState.state.selectedIndex > maxIndex) {
321
+ this.uiState.setSelectedIndex(maxIndex);
257
322
  }
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
- }
323
+ }
324
+ // Update explorer git status when git state changes
325
+ this.updateExplorerGitStatus();
326
+ this.render();
327
+ });
328
+ this.gitManager.on('history-state-change', (historyState) => {
329
+ // Auto-select first commit when history loads
330
+ if (historyState.commits.length > 0 && !historyState.selectedCommit) {
331
+ const state = this.uiState.state;
332
+ if (state.bottomTab === 'history') {
333
+ this.selectHistoryCommitByIndex(state.historySelectedIndex);
266
334
  }
267
335
  }
268
- // Bottom pane clicks
269
- if (isInPane(y, diffPaneStart, diffPaneEnd)) {
270
- setCurrentPane(bottomTab);
336
+ this.render();
337
+ });
338
+ this.gitManager.on('compare-state-change', () => {
339
+ this.render();
340
+ });
341
+ this.gitManager.on('compare-selection-change', () => {
342
+ this.render();
343
+ });
344
+ // Start watching and do initial refresh
345
+ this.gitManager.startWatching();
346
+ this.gitManager.refresh();
347
+ // Initialize explorer manager
348
+ this.initExplorerManager();
349
+ }
350
+ initExplorerManager() {
351
+ // Clean up existing manager
352
+ if (this.explorerManager) {
353
+ this.explorerManager.dispose();
354
+ }
355
+ // Create new manager with options
356
+ const options = {
357
+ hideHidden: true,
358
+ hideGitignored: true,
359
+ showOnlyChanges: false,
360
+ };
361
+ this.explorerManager = new ExplorerStateManager(this.repoPath, options);
362
+ // Listen to state changes
363
+ this.explorerManager.on('state-change', () => {
364
+ this.render();
365
+ });
366
+ // Load root directory
367
+ this.explorerManager.loadDirectory('');
368
+ // Update git status after tree is loaded
369
+ this.updateExplorerGitStatus();
370
+ }
371
+ /**
372
+ * Build git status map and update explorer.
373
+ */
374
+ updateExplorerGitStatus() {
375
+ if (!this.explorerManager || !this.gitManager)
376
+ return;
377
+ const files = this.gitManager.state.status?.files ?? [];
378
+ const statusMap = {
379
+ files: new Map(),
380
+ directories: new Set(),
381
+ };
382
+ for (const file of files) {
383
+ statusMap.files.set(file.path, { status: file.status, staged: file.staged });
384
+ // Mark all parent directories as having changed children
385
+ const parts = file.path.split('/');
386
+ let dirPath = '';
387
+ for (let i = 0; i < parts.length - 1; i++) {
388
+ dirPath = dirPath ? `${dirPath}/${parts[i]}` : parts[i];
389
+ statusMap.directories.add(dirPath);
390
+ }
391
+ // Also mark root as having changes
392
+ statusMap.directories.add('');
393
+ }
394
+ this.explorerManager.setGitStatus(statusMap);
395
+ }
396
+ /**
397
+ * Reset UI state that's specific to a repository.
398
+ * Called when switching to a new repo via file watcher.
399
+ */
400
+ resetRepoSpecificState() {
401
+ // Reset compare selection (App-level state)
402
+ this.compareSelection = null;
403
+ // Reset UI state scroll offsets and selections
404
+ this.uiState.resetForNewRepo();
405
+ }
406
+ /**
407
+ * Load data for the current tab.
408
+ * Called after switching repos to refresh tab-specific data.
409
+ */
410
+ loadCurrentTabData() {
411
+ const tab = this.uiState.state.bottomTab;
412
+ if (tab === 'history') {
413
+ this.gitManager?.loadHistory();
414
+ }
415
+ else if (tab === 'compare') {
416
+ this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
417
+ }
418
+ // Diff tab data is loaded by gitManager.refresh() in initGitManager
419
+ // Explorer data is loaded by initExplorerManager()
420
+ }
421
+ setupCommandHandler() {
422
+ if (!this.commandServer)
423
+ return;
424
+ const handler = {
425
+ navigateUp: () => this.navigateUp(),
426
+ navigateDown: () => this.navigateDown(),
427
+ switchTab: (tab) => this.uiState.setTab(tab),
428
+ togglePane: () => this.uiState.togglePane(),
429
+ stage: async () => this.stageSelected(),
430
+ unstage: async () => this.unstageSelected(),
431
+ stageAll: async () => this.stageAll(),
432
+ unstageAll: async () => this.unstageAll(),
433
+ commit: async (message) => this.commit(message),
434
+ refresh: async () => this.refresh(),
435
+ getState: () => this.getAppState(),
436
+ quit: () => this.exit(),
437
+ };
438
+ this.commandServer.setHandler(handler);
439
+ this.commandServer.notifyReady();
440
+ }
441
+ getAppState() {
442
+ const state = this.uiState.state;
443
+ const gitState = this.gitManager?.state;
444
+ const historyState = this.gitManager?.historyState;
445
+ const files = gitState?.status?.files ?? [];
446
+ const commits = historyState?.commits ?? [];
447
+ return {
448
+ currentTab: state.bottomTab,
449
+ currentPane: state.currentPane,
450
+ selectedIndex: state.selectedIndex,
451
+ totalFiles: files.length,
452
+ stagedCount: files.filter((f) => f.staged).length,
453
+ files: files.map((f) => ({
454
+ path: f.path,
455
+ status: f.status,
456
+ staged: f.staged,
457
+ })),
458
+ historySelectedIndex: state.historySelectedIndex,
459
+ historyCommitCount: commits.length,
460
+ compareSelectedIndex: state.compareSelectedIndex,
461
+ compareTotalItems: 0,
462
+ includeUncommitted: state.includeUncommitted,
463
+ explorerPath: this.repoPath,
464
+ explorerSelectedIndex: state.explorerSelectedIndex,
465
+ explorerItemCount: 0,
466
+ wrapMode: state.wrapMode,
467
+ mouseEnabled: state.mouseEnabled,
468
+ autoTabEnabled: state.autoTabEnabled,
469
+ };
470
+ }
471
+ // Navigation methods
472
+ navigateUp() {
473
+ const state = this.uiState.state;
474
+ if (state.bottomTab === 'history') {
475
+ if (state.currentPane === 'history') {
476
+ this.navigateHistoryUp();
477
+ }
478
+ else if (state.currentPane === 'diff') {
479
+ this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
271
480
  }
481
+ return;
272
482
  }
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);
281
- }
282
- else if (bottomTab === 'compare') {
283
- scrollCompare(direction, compareTotalItems);
284
- }
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)));
290
- }
483
+ if (state.bottomTab === 'compare') {
484
+ if (state.currentPane === 'compare') {
485
+ this.navigateCompareUp();
291
486
  }
292
- 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)));
298
- }
299
- 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);
311
- }
487
+ else if (state.currentPane === 'diff') {
488
+ this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
312
489
  }
490
+ return;
313
491
  }
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;
492
+ if (state.bottomTab === 'explorer') {
493
+ if (state.currentPane === 'explorer') {
494
+ this.navigateExplorerUp();
495
+ }
496
+ else if (state.currentPane === 'diff') {
497
+ this.uiState.setExplorerFileScrollOffset(Math.max(0, state.explorerFileScrollOffset - 3));
498
+ }
361
499
  return;
362
500
  }
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)
501
+ if (state.currentPane === 'files') {
502
+ const files = this.gitManager?.state.status?.files ?? [];
503
+ const newIndex = Math.max(0, state.selectedIndex - 1);
504
+ this.uiState.setSelectedIndex(newIndex);
505
+ this.selectFileByIndex(newIndex);
506
+ // Keep selection visible - scroll up if needed
507
+ const row = getRowFromFileIndex(newIndex, files);
508
+ if (row < state.fileListScrollOffset) {
509
+ this.uiState.setFileListScrollOffset(row);
510
+ }
511
+ }
512
+ else if (state.currentPane === 'diff') {
513
+ this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
514
+ }
515
+ }
516
+ navigateDown() {
517
+ const state = this.uiState.state;
518
+ const files = this.gitManager?.state.status?.files ?? [];
519
+ if (state.bottomTab === 'history') {
520
+ if (state.currentPane === 'history') {
521
+ this.navigateHistoryDown();
522
+ }
523
+ else if (state.currentPane === 'diff') {
524
+ this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
525
+ }
478
526
  return;
479
- if (currentFile.staged) {
480
- await unstage(currentFile);
527
+ }
528
+ if (state.bottomTab === 'compare') {
529
+ if (state.currentPane === 'compare') {
530
+ this.navigateCompareDown();
531
+ }
532
+ else if (state.currentPane === 'diff') {
533
+ this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
534
+ }
535
+ return;
536
+ }
537
+ if (state.bottomTab === 'explorer') {
538
+ if (state.currentPane === 'explorer') {
539
+ this.navigateExplorerDown();
540
+ }
541
+ else if (state.currentPane === 'diff') {
542
+ this.uiState.setExplorerFileScrollOffset(state.explorerFileScrollOffset + 3);
543
+ }
544
+ return;
545
+ }
546
+ if (state.currentPane === 'files') {
547
+ const newIndex = Math.min(files.length - 1, state.selectedIndex + 1);
548
+ this.uiState.setSelectedIndex(newIndex);
549
+ this.selectFileByIndex(newIndex);
550
+ // Keep selection visible - scroll down if needed
551
+ const row = getRowFromFileIndex(newIndex, files);
552
+ const visibleEnd = state.fileListScrollOffset + this.layout.dimensions.topPaneHeight - 1;
553
+ if (row >= visibleEnd) {
554
+ this.uiState.setFileListScrollOffset(state.fileListScrollOffset + (row - visibleEnd + 1));
555
+ }
556
+ }
557
+ else if (state.currentPane === 'diff') {
558
+ this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
559
+ }
560
+ }
561
+ navigateHistoryUp() {
562
+ const state = this.uiState.state;
563
+ const newIndex = Math.max(0, state.historySelectedIndex - 1);
564
+ if (newIndex !== state.historySelectedIndex) {
565
+ this.uiState.setHistorySelectedIndex(newIndex);
566
+ // Keep selection visible
567
+ if (newIndex < state.historyScrollOffset) {
568
+ this.uiState.setHistoryScrollOffset(newIndex);
569
+ }
570
+ this.selectHistoryCommitByIndex(newIndex);
571
+ }
572
+ }
573
+ navigateHistoryDown() {
574
+ const state = this.uiState.state;
575
+ const commits = this.gitManager?.historyState.commits ?? [];
576
+ const newIndex = Math.min(commits.length - 1, state.historySelectedIndex + 1);
577
+ if (newIndex !== state.historySelectedIndex) {
578
+ this.uiState.setHistorySelectedIndex(newIndex);
579
+ // Keep selection visible
580
+ const visibleEnd = state.historyScrollOffset + this.layout.dimensions.topPaneHeight - 1;
581
+ if (newIndex >= visibleEnd) {
582
+ this.uiState.setHistoryScrollOffset(state.historyScrollOffset + 1);
583
+ }
584
+ this.selectHistoryCommitByIndex(newIndex);
585
+ }
586
+ }
587
+ selectHistoryCommitByIndex(index) {
588
+ const commits = this.gitManager?.historyState.commits ?? [];
589
+ const commit = getCommitAtIndex(commits, index);
590
+ if (commit) {
591
+ this.uiState.setDiffScrollOffset(0);
592
+ this.gitManager?.selectHistoryCommit(commit);
593
+ }
594
+ }
595
+ // Compare navigation
596
+ compareSelection = null;
597
+ navigateCompareUp() {
598
+ const compareState = this.gitManager?.compareState;
599
+ const commits = compareState?.compareDiff?.commits ?? [];
600
+ const files = compareState?.compareDiff?.files ?? [];
601
+ if (commits.length === 0 && files.length === 0)
602
+ return;
603
+ const next = getNextCompareSelection(this.compareSelection, commits, files, 'up');
604
+ if (next &&
605
+ (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
606
+ this.selectCompareItem(next);
607
+ // Keep selection visible - scroll up if needed
608
+ const state = this.uiState.state;
609
+ const row = getRowFromCompareSelection(next, commits, files);
610
+ if (row < state.compareScrollOffset) {
611
+ this.uiState.setCompareScrollOffset(row);
612
+ }
613
+ }
614
+ }
615
+ navigateCompareDown() {
616
+ const compareState = this.gitManager?.compareState;
617
+ const commits = compareState?.compareDiff?.commits ?? [];
618
+ const files = compareState?.compareDiff?.files ?? [];
619
+ if (commits.length === 0 && files.length === 0)
620
+ return;
621
+ // Auto-select first item if nothing selected
622
+ if (!this.compareSelection) {
623
+ // Select first commit if available, otherwise first file
624
+ if (commits.length > 0) {
625
+ this.selectCompareItem({ type: 'commit', index: 0 });
626
+ }
627
+ else if (files.length > 0) {
628
+ this.selectCompareItem({ type: 'file', index: 0 });
629
+ }
630
+ return;
631
+ }
632
+ const next = getNextCompareSelection(this.compareSelection, commits, files, 'down');
633
+ if (next &&
634
+ (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
635
+ this.selectCompareItem(next);
636
+ // Keep selection visible - scroll down if needed
637
+ const state = this.uiState.state;
638
+ const row = getRowFromCompareSelection(next, commits, files);
639
+ const visibleEnd = state.compareScrollOffset + this.layout.dimensions.topPaneHeight - 1;
640
+ if (row >= visibleEnd) {
641
+ this.uiState.setCompareScrollOffset(state.compareScrollOffset + (row - visibleEnd + 1));
642
+ }
643
+ }
644
+ }
645
+ selectCompareItem(selection) {
646
+ this.compareSelection = selection;
647
+ this.uiState.setDiffScrollOffset(0);
648
+ if (selection.type === 'commit') {
649
+ this.gitManager?.selectCompareCommit(selection.index);
481
650
  }
482
651
  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)
652
+ this.gitManager?.selectCompareFile(selection.index);
653
+ }
654
+ }
655
+ // Explorer navigation
656
+ navigateExplorerUp() {
657
+ const state = this.uiState.state;
658
+ const rows = this.explorerManager?.state.displayRows ?? [];
659
+ if (rows.length === 0)
660
+ return;
661
+ const newScrollOffset = this.explorerManager?.navigateUp(state.explorerScrollOffset);
662
+ if (newScrollOffset !== null && newScrollOffset !== undefined) {
663
+ this.uiState.setExplorerScrollOffset(newScrollOffset);
664
+ }
665
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
666
+ }
667
+ navigateExplorerDown() {
668
+ const state = this.uiState.state;
669
+ const rows = this.explorerManager?.state.displayRows ?? [];
670
+ if (rows.length === 0)
530
671
  return;
531
- if (input === 'y' || input === 'Y') {
532
- discard(pendingDiscard);
533
- setPendingDiscard(null);
672
+ const visibleHeight = this.layout.dimensions.topPaneHeight;
673
+ const newScrollOffset = this.explorerManager?.navigateDown(state.explorerScrollOffset, visibleHeight);
674
+ if (newScrollOffset !== null && newScrollOffset !== undefined) {
675
+ this.uiState.setExplorerScrollOffset(newScrollOffset);
676
+ }
677
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
678
+ }
679
+ async enterExplorerDirectory() {
680
+ await this.explorerManager?.enterDirectory();
681
+ // Reset file content scroll when expanding/collapsing
682
+ this.uiState.setExplorerFileScrollOffset(0);
683
+ // Sync selected index from explorer manager (it maintains selection by path)
684
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
685
+ }
686
+ async goExplorerUp() {
687
+ await this.explorerManager?.goUp();
688
+ // Reset file content scroll when collapsing
689
+ this.uiState.setExplorerFileScrollOffset(0);
690
+ // Sync selected index from explorer manager
691
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
692
+ }
693
+ selectFileByIndex(index) {
694
+ const files = this.gitManager?.state.status?.files ?? [];
695
+ const file = getFileAtIndex(files, index);
696
+ if (file) {
697
+ // Reset diff scroll when changing files
698
+ this.uiState.setDiffScrollOffset(0);
699
+ this.gitManager?.selectFile(file);
700
+ }
701
+ }
702
+ /**
703
+ * Navigate to a file given its absolute path.
704
+ * Extracts the relative path and finds the file in the current file list.
705
+ */
706
+ navigateToFile(absolutePath) {
707
+ if (!absolutePath || !this.repoPath)
708
+ return;
709
+ // Check if the path is within the current repo
710
+ const repoPrefix = this.repoPath.endsWith('/') ? this.repoPath : this.repoPath + '/';
711
+ if (!absolutePath.startsWith(repoPrefix))
712
+ return;
713
+ // Extract relative path
714
+ const relativePath = absolutePath.slice(repoPrefix.length);
715
+ if (!relativePath)
716
+ return;
717
+ // Find the file in the list
718
+ const files = this.gitManager?.state.status?.files ?? [];
719
+ const fileIndex = files.findIndex((f) => f.path === relativePath);
720
+ if (fileIndex >= 0) {
721
+ this.uiState.setSelectedIndex(fileIndex);
722
+ this.selectFileByIndex(fileIndex);
723
+ }
724
+ }
725
+ // Git operations
726
+ async stageSelected() {
727
+ const files = this.gitManager?.state.status?.files ?? [];
728
+ const index = this.uiState.state.selectedIndex;
729
+ const selectedFile = getFileAtIndex(files, index);
730
+ if (selectedFile && !selectedFile.staged) {
731
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
732
+ await this.gitManager?.stage(selectedFile);
733
+ }
734
+ }
735
+ async unstageSelected() {
736
+ const files = this.gitManager?.state.status?.files ?? [];
737
+ const index = this.uiState.state.selectedIndex;
738
+ const selectedFile = getFileAtIndex(files, index);
739
+ if (selectedFile?.staged) {
740
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
741
+ await this.gitManager?.unstage(selectedFile);
742
+ }
743
+ }
744
+ async toggleSelected() {
745
+ const files = this.gitManager?.state.status?.files ?? [];
746
+ const index = this.uiState.state.selectedIndex;
747
+ const selectedFile = getFileAtIndex(files, index);
748
+ if (selectedFile) {
749
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
750
+ if (selectedFile.staged) {
751
+ await this.gitManager?.unstage(selectedFile);
752
+ }
753
+ else {
754
+ await this.gitManager?.stage(selectedFile);
755
+ }
756
+ }
757
+ }
758
+ async stageAll() {
759
+ await this.gitManager?.stageAll();
760
+ }
761
+ async unstageAll() {
762
+ await this.gitManager?.unstageAll();
763
+ }
764
+ showDiscardConfirm(file) {
765
+ this.activeModal = new DiscardConfirm(this.screen, file.path, async () => {
766
+ this.activeModal = null;
767
+ await this.gitManager?.discard(file);
768
+ }, () => {
769
+ this.activeModal = null;
770
+ });
771
+ this.activeModal.focus();
772
+ }
773
+ async openFileFinder() {
774
+ const allPaths = (await this.explorerManager?.getAllFilePaths()) ?? [];
775
+ if (allPaths.length === 0)
776
+ return;
777
+ this.activeModal = new FileFinder(this.screen, allPaths, async (selectedPath) => {
778
+ this.activeModal = null;
779
+ // Navigate to the selected file in explorer
780
+ const success = await this.explorerManager?.navigateToPath(selectedPath);
781
+ if (success) {
782
+ // Reset scroll to show selected file
783
+ this.uiState.setExplorerScrollOffset(0);
784
+ this.uiState.setExplorerFileScrollOffset(0);
785
+ }
786
+ this.render();
787
+ }, () => {
788
+ this.activeModal = null;
789
+ this.render();
790
+ });
791
+ this.activeModal.focus();
792
+ }
793
+ async commit(message) {
794
+ await this.gitManager?.commit(message);
795
+ }
796
+ async refresh() {
797
+ await this.gitManager?.refresh();
798
+ }
799
+ toggleMouseMode() {
800
+ const willEnable = !this.uiState.state.mouseEnabled;
801
+ this.uiState.toggleMouse();
802
+ // Access program for terminal mouse control (not on screen's TS types)
803
+ const program = this.screen.program;
804
+ if (willEnable) {
805
+ program.enableMouse();
806
+ }
807
+ else {
808
+ program.disableMouse();
809
+ }
810
+ }
811
+ toggleFollow() {
812
+ if (!this.followMode) {
813
+ this.followMode = new FollowMode(this.config.targetFile, () => this.repoPath, {
814
+ onRepoChange: (newPath, state) => this.handleFollowRepoChange(newPath, state),
815
+ onFileNavigate: (rawContent) => this.handleFollowFileNavigate(rawContent),
816
+ });
817
+ }
818
+ this.followMode.toggle();
819
+ this.render();
820
+ }
821
+ focusCommitInput() {
822
+ if (this.commitTextarea) {
823
+ this.commitTextarea.show();
824
+ this.commitTextarea.focus();
825
+ this.commitTextarea.setValue(this.commitFlowState.state.message);
826
+ this.commitFlowState.setInputFocused(true);
827
+ this.render();
828
+ }
829
+ }
830
+ unfocusCommitInput() {
831
+ if (this.commitTextarea) {
832
+ const value = this.commitTextarea.getValue() ?? '';
833
+ this.commitFlowState.setMessage(value);
834
+ this.commitTextarea.hide();
835
+ this.commitFlowState.setInputFocused(false);
836
+ this.screen.focusPush(this.layout.bottomPane);
837
+ this.render();
838
+ }
839
+ }
840
+ // Render methods
841
+ render() {
842
+ this.updateHeader();
843
+ this.updateTopPane();
844
+ this.updateBottomPane();
845
+ this.updateFooter();
846
+ this.screen.render();
847
+ }
848
+ updateHeader() {
849
+ const gitState = this.gitManager?.state;
850
+ const width = this.screen.width || 80;
851
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width);
852
+ this.layout.headerBox.setContent(content);
853
+ }
854
+ updateTopPane() {
855
+ const state = this.uiState.state;
856
+ const width = this.screen.width || 80;
857
+ const content = renderTopPane(state, this.gitManager?.state.status?.files ?? [], this.gitManager?.historyState?.commits ?? [], this.gitManager?.compareState?.compareDiff ?? null, this.compareSelection, this.explorerManager?.state, width, this.layout.dimensions.topPaneHeight);
858
+ this.layout.topPane.setContent(content);
859
+ }
860
+ updateBottomPane() {
861
+ const state = this.uiState.state;
862
+ const width = this.screen.width || 80;
863
+ const files = this.gitManager?.state.status?.files ?? [];
864
+ const stagedCount = files.filter((f) => f.staged).length;
865
+ // Update staged count for commit validation
866
+ this.commitFlowState.setStagedCount(stagedCount);
867
+ const { content, totalRows } = renderBottomPane(state, this.gitManager?.state.diff ?? null, this.gitManager?.historyState, this.gitManager?.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight);
868
+ this.bottomPaneTotalRows = totalRows;
869
+ this.layout.bottomPane.setContent(content);
870
+ // Manage commit textarea visibility
871
+ if (this.commitTextarea) {
872
+ if (state.bottomTab === 'commit' && this.commitFlowState.state.inputFocused) {
873
+ this.commitTextarea.show();
874
+ }
875
+ else {
876
+ this.commitTextarea.hide();
877
+ }
878
+ }
879
+ }
880
+ updateFooter() {
881
+ const state = this.uiState.state;
882
+ const width = this.screen.width || 80;
883
+ const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, this.followMode?.isEnabled ?? false, this.explorerManager?.showOnlyChanges ?? false, width);
884
+ this.layout.footerBox.setContent(content);
885
+ }
886
+ /**
887
+ * Exit the application cleanly.
888
+ */
889
+ exit() {
890
+ // Clean up
891
+ if (this.gitManager) {
892
+ removeManagerForRepo(this.repoPath);
893
+ }
894
+ if (this.explorerManager) {
895
+ this.explorerManager.dispose();
896
+ }
897
+ if (this.followMode) {
898
+ this.followMode.stop();
534
899
  }
535
- else if (input === 'n' || input === 'N' || key.escape) {
536
- setPendingDiscard(null);
900
+ if (this.commandServer) {
901
+ this.commandServer.stop();
537
902
  }
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 }) }))] }));
903
+ // Destroy screen (this will clean up terminal)
904
+ this.screen.destroy();
905
+ }
906
+ /**
907
+ * Start the application (returns when app exits).
908
+ */
909
+ start() {
910
+ return new Promise((resolve) => {
911
+ this.screen.on('destroy', () => {
912
+ resolve();
913
+ });
914
+ });
915
+ }
541
916
  }