diffstalker 0.2.0 → 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.
package/dist/App.js CHANGED
@@ -1,24 +1,25 @@
1
1
  import blessed from 'neo-blessed';
2
- import { LayoutManager, SPLIT_RATIO_STEP } from './ui/Layout.js';
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';
3
7
  import { formatHeader } from './ui/widgets/Header.js';
4
8
  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';
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
12
  import { ExplorerStateManager, } from './core/ExplorerStateManager.js';
13
13
  import { ThemePicker } from './ui/modals/ThemePicker.js';
14
14
  import { HotkeysModal } from './ui/modals/HotkeysModal.js';
15
15
  import { BaseBranchPicker } from './ui/modals/BaseBranchPicker.js';
16
16
  import { DiscardConfirm } from './ui/modals/DiscardConfirm.js';
17
+ import { FileFinder } from './ui/modals/FileFinder.js';
17
18
  import { CommitFlowState } from './state/CommitFlowState.js';
18
19
  import { UIState } from './state/UIState.js';
19
20
  import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
20
- import { FilePathWatcher } from './core/FilePathWatcher.js';
21
21
  import { saveConfig } from './config.js';
22
+ import { getCategoryForIndex, getIndexForCategoryPosition, } from './utils/fileCategories.js';
22
23
  /**
23
24
  * Main application controller.
24
25
  * Coordinates between GitStateManager, UIState, and blessed widgets.
@@ -28,13 +29,12 @@ export class App {
28
29
  layout;
29
30
  uiState;
30
31
  gitManager = null;
31
- fileWatcher = null;
32
+ followMode = null;
32
33
  explorerManager = null;
33
34
  config;
34
35
  commandServer;
35
36
  // Current state
36
37
  repoPath;
37
- watcherState = { enabled: false };
38
38
  currentTheme;
39
39
  // Commit flow state
40
40
  commitFlowState;
@@ -43,6 +43,8 @@ export class App {
43
43
  activeModal = null;
44
44
  // Cached total rows for scroll bounds (single source of truth from render)
45
45
  bottomPaneTotalRows = 0;
46
+ // Selection anchor: remembers category + position before stage/unstage
47
+ pendingSelectionAnchor = null;
46
48
  constructor(options) {
47
49
  this.config = options.config;
48
50
  this.commandServer = options.commandServer ?? null;
@@ -116,12 +118,16 @@ export class App {
116
118
  // Setup keyboard handlers
117
119
  this.setupKeyboardHandlers();
118
120
  // Setup mouse handlers
119
- this.setupMouseHandlers();
121
+ this.setupMouseEventHandlers();
120
122
  // Setup state change listeners
121
123
  this.setupStateListeners();
122
- // Setup file watcher if enabled
124
+ // Setup follow mode if enabled
123
125
  if (this.config.watcherEnabled) {
124
- this.setupFileWatcher();
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();
125
131
  }
126
132
  // Setup IPC command handler if command server provided
127
133
  if (this.commandServer) {
@@ -133,328 +139,72 @@ export class App {
133
139
  this.render();
134
140
  }
135
141
  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)
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();
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);
211
202
  }
212
203
  else {
213
- this.toggleSelected();
214
- }
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();
247
- }
248
- else {
249
- this.uiState.setTab('diff');
250
- }
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);
299
- }
300
- }
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);
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);
338
- }
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);
204
+ await this.gitManager?.stage(file);
362
205
  }
363
206
  }
364
207
  }
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
208
  setupStateListeners() {
459
209
  // Update footer when UI state changes
460
210
  this.uiState.on('change', () => {
@@ -470,7 +220,7 @@ export class App {
470
220
  }
471
221
  else if (tab === 'explorer') {
472
222
  // Explorer is already loaded on init, but refresh if needed
473
- if (!this.explorerManager?.state.items.length) {
223
+ if (!this.explorerManager?.state.displayRows.length) {
474
224
  this.explorerManager?.loadDirectory('');
475
225
  }
476
226
  }
@@ -532,48 +282,47 @@ export class App {
532
282
  }, 500);
533
283
  });
534
284
  }
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
- }
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();
566
296
  }
567
- initGitManager() {
297
+ initGitManager(oldRepoPath) {
568
298
  // Clean up existing manager
569
299
  if (this.gitManager) {
570
300
  this.gitManager.removeAllListeners();
571
- removeManagerForRepo(this.repoPath);
301
+ // Use oldRepoPath if provided (when switching repos), otherwise use current path
302
+ removeManagerForRepo(oldRepoPath ?? this.repoPath);
572
303
  }
573
304
  // Get or create manager for this repo
574
305
  this.gitManager = getManagerForRepo(this.repoPath);
575
306
  // Listen to state changes
576
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);
322
+ }
323
+ }
324
+ // Update explorer git status when git state changes
325
+ this.updateExplorerGitStatus();
577
326
  this.render();
578
327
  });
579
328
  this.gitManager.on('history-state-change', (historyState) => {
@@ -607,6 +356,7 @@ export class App {
607
356
  const options = {
608
357
  hideHidden: true,
609
358
  hideGitignored: true,
359
+ showOnlyChanges: false,
610
360
  };
611
361
  this.explorerManager = new ExplorerStateManager(this.repoPath, options);
612
362
  // Listen to state changes
@@ -615,6 +365,58 @@ export class App {
615
365
  });
616
366
  // Load root directory
617
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()
618
420
  }
619
421
  setupCommandHandler() {
620
422
  if (!this.commandServer)
@@ -853,8 +655,8 @@ export class App {
853
655
  // Explorer navigation
854
656
  navigateExplorerUp() {
855
657
  const state = this.uiState.state;
856
- const items = this.explorerManager?.state.items ?? [];
857
- if (items.length === 0)
658
+ const rows = this.explorerManager?.state.displayRows ?? [];
659
+ if (rows.length === 0)
858
660
  return;
859
661
  const newScrollOffset = this.explorerManager?.navigateUp(state.explorerScrollOffset);
860
662
  if (newScrollOffset !== null && newScrollOffset !== undefined) {
@@ -864,8 +666,8 @@ export class App {
864
666
  }
865
667
  navigateExplorerDown() {
866
668
  const state = this.uiState.state;
867
- const items = this.explorerManager?.state.items ?? [];
868
- if (items.length === 0)
669
+ const rows = this.explorerManager?.state.displayRows ?? [];
670
+ if (rows.length === 0)
869
671
  return;
870
672
  const visibleHeight = this.layout.dimensions.topPaneHeight;
871
673
  const newScrollOffset = this.explorerManager?.navigateDown(state.explorerScrollOffset, visibleHeight);
@@ -876,15 +678,17 @@ export class App {
876
678
  }
877
679
  async enterExplorerDirectory() {
878
680
  await this.explorerManager?.enterDirectory();
879
- this.uiState.setExplorerScrollOffset(0);
681
+ // Reset file content scroll when expanding/collapsing
880
682
  this.uiState.setExplorerFileScrollOffset(0);
881
- this.uiState.setExplorerSelectedIndex(0);
683
+ // Sync selected index from explorer manager (it maintains selection by path)
684
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
882
685
  }
883
686
  async goExplorerUp() {
884
687
  await this.explorerManager?.goUp();
885
- this.uiState.setExplorerScrollOffset(0);
688
+ // Reset file content scroll when collapsing
886
689
  this.uiState.setExplorerFileScrollOffset(0);
887
- this.uiState.setExplorerSelectedIndex(0);
690
+ // Sync selected index from explorer manager
691
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
888
692
  }
889
693
  selectFileByIndex(index) {
890
694
  const files = this.gitManager?.state.status?.files ?? [];
@@ -921,22 +725,28 @@ export class App {
921
725
  // Git operations
922
726
  async stageSelected() {
923
727
  const files = this.gitManager?.state.status?.files ?? [];
924
- const selectedFile = files[this.uiState.state.selectedIndex];
728
+ const index = this.uiState.state.selectedIndex;
729
+ const selectedFile = getFileAtIndex(files, index);
925
730
  if (selectedFile && !selectedFile.staged) {
731
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
926
732
  await this.gitManager?.stage(selectedFile);
927
733
  }
928
734
  }
929
735
  async unstageSelected() {
930
736
  const files = this.gitManager?.state.status?.files ?? [];
931
- const selectedFile = files[this.uiState.state.selectedIndex];
737
+ const index = this.uiState.state.selectedIndex;
738
+ const selectedFile = getFileAtIndex(files, index);
932
739
  if (selectedFile?.staged) {
740
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
933
741
  await this.gitManager?.unstage(selectedFile);
934
742
  }
935
743
  }
936
744
  async toggleSelected() {
937
745
  const files = this.gitManager?.state.status?.files ?? [];
938
- const selectedFile = files[this.uiState.state.selectedIndex];
746
+ const index = this.uiState.state.selectedIndex;
747
+ const selectedFile = getFileAtIndex(files, index);
939
748
  if (selectedFile) {
749
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
940
750
  if (selectedFile.staged) {
941
751
  await this.gitManager?.unstage(selectedFile);
942
752
  }
@@ -960,6 +770,26 @@ export class App {
960
770
  });
961
771
  this.activeModal.focus();
962
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
+ }
963
793
  async commit(message) {
964
794
  await this.gitManager?.commit(message);
965
795
  }
@@ -979,14 +809,13 @@ export class App {
979
809
  }
980
810
  }
981
811
  toggleFollow() {
982
- if (this.fileWatcher) {
983
- this.fileWatcher.stop();
984
- this.fileWatcher = null;
985
- this.watcherState = { enabled: false };
986
- }
987
- else {
988
- this.setupFileWatcher();
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
+ });
989
817
  }
818
+ this.followMode.toggle();
990
819
  this.render();
991
820
  }
992
821
  focusCommitInput() {
@@ -1019,114 +848,39 @@ export class App {
1019
848
  updateHeader() {
1020
849
  const gitState = this.gitManager?.state;
1021
850
  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);
851
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width);
1023
852
  this.layout.headerBox.setContent(content);
1024
853
  }
1025
854
  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
855
  const state = this.uiState.state;
1031
856
  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
- }
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);
1051
858
  this.layout.topPane.setContent(content);
1052
859
  }
1053
860
  updateBottomPane() {
1054
- const gitState = this.gitManager?.state;
1055
- const historyState = this.gitManager?.historyState;
1056
- const diff = gitState?.diff ?? null;
1057
861
  const state = this.uiState.state;
1058
862
  const width = this.screen.width || 80;
1059
- const files = gitState?.status?.files ?? [];
863
+ const files = this.gitManager?.state.status?.files ?? [];
1060
864
  const stagedCount = files.filter((f) => f.staged).length;
1061
865
  // Update staged count for commit validation
1062
866
  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();
1071
- }
1072
- else {
1073
- this.commitTextarea.hide();
1074
- }
1075
- }
1076
- }
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);
1087
- }
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);
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();
1099
874
  }
1100
875
  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);
1115
- }
1116
- else {
1117
- // Hide commit textarea when not on commit tab
1118
- if (this.commitTextarea) {
1119
876
  this.commitTextarea.hide();
1120
877
  }
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
878
  }
1125
879
  }
1126
880
  updateFooter() {
1127
881
  const state = this.uiState.state;
1128
882
  const width = this.screen.width || 80;
1129
- const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, state.showMiddleDots, width);
883
+ const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, this.followMode?.isEnabled ?? false, this.explorerManager?.showOnlyChanges ?? false, width);
1130
884
  this.layout.footerBox.setContent(content);
1131
885
  }
1132
886
  /**
@@ -1140,8 +894,8 @@ export class App {
1140
894
  if (this.explorerManager) {
1141
895
  this.explorerManager.dispose();
1142
896
  }
1143
- if (this.fileWatcher) {
1144
- this.fileWatcher.stop();
897
+ if (this.followMode) {
898
+ this.followMode.stop();
1145
899
  }
1146
900
  if (this.commandServer) {
1147
901
  this.commandServer.stop();