diffstalker 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +378 -0
  4. package/dist/App.js +1162 -1
  5. package/dist/config.js +83 -2
  6. package/dist/core/ExplorerStateManager.js +266 -0
  7. package/dist/core/FilePathWatcher.js +133 -0
  8. package/dist/core/GitOperationQueue.js +109 -1
  9. package/dist/core/GitStateManager.js +525 -1
  10. package/dist/git/diff.js +471 -10
  11. package/dist/git/ignoreUtils.js +30 -0
  12. package/dist/git/status.js +237 -5
  13. package/dist/index.js +70 -16
  14. package/dist/ipc/CommandClient.js +165 -0
  15. package/dist/ipc/CommandServer.js +152 -0
  16. package/dist/services/commitService.js +22 -1
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +182 -0
  19. package/dist/themes.js +127 -1
  20. package/dist/types/tabs.js +4 -0
  21. package/dist/ui/Layout.js +252 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/HotkeysModal.js +209 -0
  25. package/dist/ui/modals/ThemePicker.js +107 -0
  26. package/dist/ui/widgets/CommitPanel.js +58 -0
  27. package/dist/ui/widgets/CompareListView.js +216 -0
  28. package/dist/ui/widgets/DiffView.js +279 -0
  29. package/dist/ui/widgets/ExplorerContent.js +102 -0
  30. package/dist/ui/widgets/ExplorerView.js +95 -0
  31. package/dist/ui/widgets/FileList.js +185 -0
  32. package/dist/ui/widgets/Footer.js +46 -0
  33. package/dist/ui/widgets/Header.js +111 -0
  34. package/dist/ui/widgets/HistoryView.js +69 -0
  35. package/dist/utils/ansiToBlessed.js +125 -0
  36. package/dist/utils/ansiTruncate.js +108 -0
  37. package/dist/utils/baseBranchCache.js +44 -2
  38. package/dist/utils/commitFormat.js +38 -1
  39. package/dist/utils/diffFilters.js +21 -1
  40. package/dist/utils/diffRowCalculations.js +113 -1
  41. package/dist/utils/displayRows.js +351 -2
  42. package/dist/utils/explorerDisplayRows.js +169 -0
  43. package/dist/utils/fileCategories.js +26 -1
  44. package/dist/utils/formatDate.js +39 -1
  45. package/dist/utils/formatPath.js +58 -1
  46. package/dist/utils/languageDetection.js +236 -0
  47. package/dist/utils/layoutCalculations.js +98 -1
  48. package/dist/utils/lineBreaking.js +88 -5
  49. package/dist/utils/mouseCoordinates.js +165 -1
  50. package/dist/utils/pathUtils.js +27 -0
  51. package/dist/utils/rowCalculations.js +246 -4
  52. package/dist/utils/wordDiff.js +50 -0
  53. package/package.json +15 -19
  54. package/dist/components/BaseBranchPicker.js +0 -1
  55. package/dist/components/BottomPane.js +0 -1
  56. package/dist/components/CommitPanel.js +0 -1
  57. package/dist/components/CompareListView.js +0 -1
  58. package/dist/components/ExplorerContentView.js +0 -3
  59. package/dist/components/ExplorerView.js +0 -1
  60. package/dist/components/FileList.js +0 -1
  61. package/dist/components/Footer.js +0 -1
  62. package/dist/components/Header.js +0 -1
  63. package/dist/components/HistoryView.js +0 -1
  64. package/dist/components/HotkeysModal.js +0 -1
  65. package/dist/components/Modal.js +0 -1
  66. package/dist/components/ScrollableList.js +0 -1
  67. package/dist/components/ThemePicker.js +0 -1
  68. package/dist/components/TopPane.js +0 -1
  69. package/dist/components/UnifiedDiffView.js +0 -1
  70. package/dist/hooks/useCommitFlow.js +0 -1
  71. package/dist/hooks/useCompareState.js +0 -1
  72. package/dist/hooks/useExplorerState.js +0 -9
  73. package/dist/hooks/useGit.js +0 -1
  74. package/dist/hooks/useHistoryState.js +0 -1
  75. package/dist/hooks/useKeymap.js +0 -1
  76. package/dist/hooks/useLayout.js +0 -1
  77. package/dist/hooks/useMouse.js +0 -1
  78. package/dist/hooks/useTerminalSize.js +0 -1
  79. package/dist/hooks/useWatcher.js +0 -11
package/dist/App.js CHANGED
@@ -1 +1,1162 @@
1
- import{jsx as l,jsxs as X}from"react/jsx-runtime";import{useState as f,useCallback as m,useMemo as Ke,useEffect as k,useRef as Y}from"react";import{Box as M,Text as W,useApp as qt,useInput as Jt}from"ink";import{Header as Vt,getHeaderHeight as Zt}from"./components/Header.js";import{getFileAtIndex as Qe,getTotalFileCount as $t}from"./components/FileList.js";import{getCommitIndexFromRow as eo}from"./components/HistoryView.js";import{Footer as to}from"./components/Footer.js";import{TopPane as oo}from"./components/TopPane.js";import{BottomPane as ro}from"./components/BottomPane.js";import{useWatcher as io}from"./hooks/useWatcher.js";import{useGit as lo}from"./hooks/useGit.js";import{useKeymap as so}from"./hooks/useKeymap.js";import{useMouse as no}from"./hooks/useMouse.js";import{useTerminalSize as ao}from"./hooks/useTerminalSize.js";import{useLayout as co,SPLIT_RATIO_STEP as Xe}from"./hooks/useLayout.js";import{useHistoryState as fo}from"./hooks/useHistoryState.js";import{useCompareState as mo}from"./hooks/useCompareState.js";import{useExplorerState as po}from"./hooks/useExplorerState.js";import{getClickedFileIndex as uo,getClickedTab as ho,getFooterLeftClick as go,isButtonAreaClick as xo,isInPane as fe}from"./utils/mouseCoordinates.js";import{saveConfig as Ye}from"./config.js";import{ThemePicker as So}from"./components/ThemePicker.js";import{HotkeysModal as yo}from"./components/HotkeysModal.js";import{BaseBranchPicker as wo}from"./components/BaseBranchPicker.js";import{buildDiffDisplayRows as Co,getDisplayRowsLineNumWidth as To,getWrappedRowCount as bo}from"./utils/displayRows.js";export function App({config:x,initialPath:qe}){const{exit:Je}=qt(),{rows:O,columns:i}=ao(),{state:q,setEnabled:Ve}=io(x.watcherEnabled,x.targetFile,x.debug),P=qe??q.path??process.cwd(),{status:H,diff:N,selectedFile:Ze,isLoading:me,error:de,selectFile:pe,stage:S,unstage:y,discard:$e,stageAll:et,unstageAll:tt,commit:ot,refresh:rt,getHeadCommitMessage:it,compareDiff:R,compareLoading:lt,compareError:st,refreshCompareDiff:nt,getCandidateBaseBranches:at,setCompareBaseBranch:ct,historySelectedCommit:ue,historyCommitDiff:he,selectHistoryCommit:ft,compareSelectionDiff:mt,selectCompareCommit:dt}=lo(P),u=H?.files??[],n=$t(u),ge=u.filter(e=>e.staged).length,[s,c]=f("files"),[t,xe]=f("diff"),[w,j]=f(0),[L,J]=f(null),[Se,pt]=f(!1),[ye,ut]=f(x.theme),[A,C]=f(null),[V,we]=f(!1),[T,Ce]=f(!1),[_,Z]=f(0),[Te,$]=f(0),be=Zt(P,H?.branch??null,q,i,de,me),ht=be-1,{topPaneHeight:v,bottomPaneHeight:gt,paneBoundaries:Ie,splitRatio:ee,adjustSplitRatio:Pe,fileListScrollOffset:te,diffScrollOffset:xt,historyScrollOffset:G,compareScrollOffset:z,setDiffScrollOffset:h,setHistoryScrollOffset:oe,setCompareScrollOffset:St,scrollDiff:D,scrollFileList:Re,scrollHistory:ve,scrollCompare:De}=co(O,i,u,w,N,t,void 0,x.splitRatio,ht),E=Ke(()=>{const e=Co(N);if(!T)return e.length;const d=To(e),p=i-d-5;return bo(e,p,!0)},[N,T,i]),{commits:K,historySelectedIndex:yt,setHistorySelectedIndex:U,historyDiffTotalRows:Ee,navigateHistoryUp:Be,navigateHistoryDown:Fe,historyTotalRows:ke}=fo({repoPath:P,isActive:t==="history",selectHistoryCommit:ft,historyCommitDiff:he,historySelectedCommit:ue,topPaneHeight:v,historyScrollOffset:G,setHistoryScrollOffset:oe,setDiffScrollOffset:h,status:H,wrapMode:T,terminalWidth:i}),{includeUncommitted:wt,compareListSelection:g,baseBranchCandidates:Ct,showBaseBranchPicker:re,compareTotalItems:ie,compareDiffTotalRows:B,setCompareSelectedIndex:Me,toggleIncludeUncommitted:Tt,openBaseBranchPicker:bt,closeBaseBranchPicker:It,selectBaseBranch:Pt,navigateCompareUp:Oe,navigateCompareDown:He,markSelectionInitialized:Le,getItemIndexFromRow:Ae}=mo({repoPath:P,isActive:t==="compare",compareDiff:R,refreshCompareDiff:nt,getCandidateBaseBranches:at,setCompareBaseBranch:ct,selectCompareCommit:dt,topPaneHeight:v,compareScrollOffset:z,setCompareScrollOffset:St,setDiffScrollOffset:h,status:H,wrapMode:T,terminalWidth:i}),{currentPath:Rt,items:le,selectedIndex:vt,setSelectedIndex:Ue,selectedFile:Dt,navigateUp:We,navigateDown:Ne,enterDirectory:Et,goUp:Bt,isLoading:Ft,error:kt,explorerTotalRows:je}=po({repoPath:P,isActive:t==="explorer",topPaneHeight:v,explorerScrollOffset:_,setExplorerScrollOffset:Z,fileScrollOffset:Te,setFileScrollOffset:$}),_e=Y(Ie);_e.current=Ie;const Mt=Y(x.splitRatio);k(()=>{if(ee!==Mt.current){const e=setTimeout(()=>Ye({splitRatio:ee}),500);return()=>clearTimeout(e)}},[ee]);const a=Ke(()=>Qe(u,w),[u,w]);k(()=>{n>0&&w>=n&&j(Math.max(0,n-1))},[n,w]),k(()=>{pe(a)},[a,pe]),k(()=>{(t==="diff"||t==="commit")&&h(0)},[w,t,h]),k(()=>{h(0)},[T,h]);const b=m(e=>{xe(e),c({diff:"files",commit:"commit",history:"history",compare:"compare",explorer:"explorer"}[e])},[]),Ge=Y(()=>{}),Ot=m(e=>{const{x:d,y:p,type:Q,button:ae}=e,{stagingPaneStart:F,fileListEnd:ce,diffPaneStart:Qt,diffPaneEnd:Xt,footerRow:Yt}=_e.current;if(Q==="click"){if(A!==null){C(null);return}if(p===Yt&&ae==="left"){const r=ho(d,i);if(r){b(r);return}const o=go(d);if(o==="hotkeys"){C("hotkeys");return}else if(o==="mouse-mode"){Ge.current();return}else if(o==="auto-tab"){we(I=>!I);return}else if(o==="wrap"){Ce(I=>!I);return}}if(fe(p,F+1,ce)){if(t==="diff"||t==="commit"){const r=uo(p,te,u,F,ce);if(r>=0&&r<n){j(r),c("files");const o=Qe(u,r);o&&(ae==="right"&&!o.staged&&o.status!=="untracked"?J(o):ae==="left"&&xo(d)&&(o.staged?y(o):S(o)));return}}else if(t==="history"){const r=p-F-1,o=eo(r,K,i,G);if(o>=0&&o<K.length){U(o),c("history"),h(0);return}}else if(t==="compare"&&R){const r=p-F-1+z,o=Ae(r);if(o>=0&&o<ie){Le(),Me(o),c("compare");return}}else if(t==="explorer"){const r=p-F-1+_;if(r>=0&&r<le.length){Ue(r),c("explorer");return}}}fe(p,Qt,Xt)&&c(t)}else if(Q==="scroll-up"||Q==="scroll-down"){const r=Q==="scroll-up"?"up":"down";if(fe(p,F,ce)){if(t==="diff"||t==="commit")Re(r);else if(t==="history")ve(r,ke);else if(t==="compare")De(r,ie);else if(t==="explorer"){const o=r==="up"?-3:3;Z(I=>Math.max(0,Math.min(I+o,Math.max(0,je-v+2))))}}else if(t==="explorer"){const o=r==="up"?-3:3;$(I=>Math.max(0,I+o))}else{let o;t==="compare"&&g?.type!=="commit"?o=B:t==="history"?o=Ee:t==="diff"&&(o=E),D(r,3,o)}}},[i,te,u,n,t,K,R,ie,S,y,D,Re,ve,De,G,z,h,U,Me,Le,Ae,g?.type,B,E,Ee,ke,A,le,_,je,v,Ue,Z,$]),Ht=Se||re,{mouseEnabled:Lt,toggleMouse:ze}=no(Ot,Ht);Ge.current=ze;const se=Y(n);k(()=>{if(!V){se.current=n;return}const e=se.current;e===0&&n>0?b("diff"):e>0&&n===0&&(U(0),oe(0),b("history")),se.current=n},[n,V,b,U,oe]);const At=m(()=>{if(s==="files")j(e=>Math.max(0,e-1));else if(s==="diff"){let e;t==="compare"&&g?.type!=="commit"?e=B:t==="diff"&&(e=E),D("up",3,e)}else s==="history"?Be():s==="compare"?Oe():s==="explorer"&&We()},[s,t,g?.type,B,E,D,Be,Oe,We]),Ut=m(()=>{if(s==="files")j(e=>Math.min(n-1,e+1));else if(s==="diff"){let e;t==="compare"&&g?.type!=="commit"?e=B:t==="diff"&&(e=E),D("down",3,e)}else s==="history"?Fe():s==="compare"?He():s==="explorer"&&Ne()},[s,t,g?.type,B,E,n,D,Fe,He,Ne]),Wt=m(()=>{t==="diff"||t==="commit"?c(e=>e==="files"?"diff":"files"):t==="history"?c(e=>e==="history"?"diff":"history"):t==="compare"?c(e=>e==="compare"?"diff":"compare"):t==="explorer"&&c(e=>e==="explorer"?"diff":"explorer")},[t]),Nt=m(async()=>{a&&!a.staged&&await S(a)},[a,S]),jt=m(async()=>{a?.staged&&await y(a)},[a,y]),_t=m(async()=>{a&&(a.staged?await y(a):await S(a))},[a,S,y]),Gt=m(()=>b("commit"),[b]),zt=m(()=>{xe("diff"),c("files")},[]),Kt=m(e=>{ut(e),C(null),Ye({theme:e})},[]);so({onStage:Nt,onUnstage:jt,onStageAll:et,onUnstageAll:tt,onCommit:Gt,onQuit:Je,onRefresh:rt,onNavigateUp:At,onNavigateDown:Ut,onTogglePane:Wt,onSwitchTab:b,onSelect:_t,onToggleIncludeUncommitted:Tt,onCycleBaseBranch:bt,onOpenThemePicker:()=>C("theme"),onShrinkTopPane:()=>Pe(-Xe),onGrowTopPane:()=>Pe(Xe),onOpenHotkeysModal:()=>C("hotkeys"),onToggleMouse:ze,onToggleFollow:()=>Ve(e=>!e),onToggleAutoTab:()=>we(e=>!e),onToggleWrap:()=>Ce(e=>!e),onExplorerEnter:t==="explorer"?Et:void 0,onExplorerBack:t==="explorer"?Bt:void 0},s,Se||A!==null||re),Jt((e,d)=>{L&&(e==="y"||e==="Y"?($e(L),J(null)):(e==="n"||e==="N"||d.escape)&&J(null))},{isActive:!!L});const ne=()=>l(W,{dimColor:!0,children:"\u2500".repeat(i)});return X(M,{flexDirection:"column",height:O,width:i,overflowX:"hidden",children:[l(M,{height:be,width:i,children:l(Vt,{repoPath:P,branch:H?.branch??null,isLoading:me,error:de,debug:x.debug,watcherState:q,width:i})}),l(ne,{}),l(oo,{bottomTab:t,currentPane:s,terminalWidth:i,topPaneHeight:v,files:u,selectedIndex:w,fileListScrollOffset:te,stagedCount:ge,onStage:S,onUnstage:y,commits:K,historySelectedIndex:yt,historyScrollOffset:G,onSelectHistoryCommit:(e,d)=>U(d),compareDiff:R,compareListSelection:g,compareScrollOffset:z,includeUncommitted:wt,explorerCurrentPath:Rt,explorerItems:le,explorerSelectedIndex:vt,explorerScrollOffset:_,explorerIsLoading:Ft,explorerError:kt}),l(ne,{}),l(ro,{bottomTab:t,currentPane:s,terminalWidth:i,bottomPaneHeight:gt,diffScrollOffset:xt,currentTheme:ye,diff:N,selectedFile:Ze,stagedCount:ge,onCommit:ot,onCommitCancel:zt,getHeadCommitMessage:it,onCommitInputFocusChange:pt,historySelectedCommit:ue,historyCommitDiff:he,compareDiff:R,compareLoading:lt,compareError:st,compareListSelection:g,compareSelectionDiff:mt,wrapMode:T,explorerSelectedFile:Dt,explorerFileScrollOffset:Te}),l(ne,{}),L?X(M,{children:[X(W,{color:"yellow",bold:!0,children:["Discard changes to"," "]}),l(W,{color:"cyan",children:L.path}),X(W,{color:"yellow",bold:!0,children:["?"," "]}),l(W,{dimColor:!0,children:"(y/n)"})]}):l(to,{activeTab:t,mouseEnabled:Lt,autoTabEnabled:V,wrapMode:T}),A==="theme"&&l(M,{position:"absolute",marginTop:0,marginLeft:0,children:l(So,{currentTheme:ye,onSelect:Kt,onCancel:()=>C(null),width:i,height:O})}),A==="hotkeys"&&l(M,{position:"absolute",marginTop:0,marginLeft:0,children:l(yo,{onClose:()=>C(null),width:i,height:O})}),re&&l(M,{position:"absolute",marginTop:0,marginLeft:0,children:l(wo,{candidates:Ct,currentBranch:R?.baseBranch??null,onSelect:Pt,onCancel:It,width:i,height:O})})]})}
1
+ import blessed from 'neo-blessed';
2
+ import { LayoutManager, SPLIT_RATIO_STEP } from './ui/Layout.js';
3
+ import { formatHeader } from './ui/widgets/Header.js';
4
+ import { formatFooter } from './ui/widgets/Footer.js';
5
+ import { formatFileList, getFileAtIndex, getFileListTotalRows, getFileIndexFromRow, getRowFromFileIndex, } from './ui/widgets/FileList.js';
6
+ import { formatDiff, formatHistoryDiff } from './ui/widgets/DiffView.js';
7
+ import { formatCommitPanel } from './ui/widgets/CommitPanel.js';
8
+ import { formatHistoryView, getCommitAtIndex, } from './ui/widgets/HistoryView.js';
9
+ import { formatCompareListView, getCompareListTotalRows, getNextCompareSelection, getRowFromCompareSelection, getCompareSelectionFromRow, } from './ui/widgets/CompareListView.js';
10
+ import { formatExplorerView, getExplorerTotalRows, } from './ui/widgets/ExplorerView.js';
11
+ import { formatExplorerContent, getExplorerContentTotalRows, } from './ui/widgets/ExplorerContent.js';
12
+ import { ExplorerStateManager, } from './core/ExplorerStateManager.js';
13
+ import { ThemePicker } from './ui/modals/ThemePicker.js';
14
+ import { HotkeysModal } from './ui/modals/HotkeysModal.js';
15
+ import { BaseBranchPicker } from './ui/modals/BaseBranchPicker.js';
16
+ import { DiscardConfirm } from './ui/modals/DiscardConfirm.js';
17
+ import { CommitFlowState } from './state/CommitFlowState.js';
18
+ import { UIState } from './state/UIState.js';
19
+ import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
20
+ import { FilePathWatcher } from './core/FilePathWatcher.js';
21
+ import { saveConfig } from './config.js';
22
+ /**
23
+ * Main application controller.
24
+ * Coordinates between GitStateManager, UIState, and blessed widgets.
25
+ */
26
+ export class App {
27
+ screen;
28
+ layout;
29
+ uiState;
30
+ gitManager = null;
31
+ fileWatcher = null;
32
+ explorerManager = null;
33
+ config;
34
+ commandServer;
35
+ // Current state
36
+ repoPath;
37
+ watcherState = { enabled: false };
38
+ currentTheme;
39
+ // Commit flow state
40
+ commitFlowState;
41
+ commitTextarea = null;
42
+ // Active modals
43
+ activeModal = null;
44
+ // Cached total rows for scroll bounds (single source of truth from render)
45
+ bottomPaneTotalRows = 0;
46
+ constructor(options) {
47
+ this.config = options.config;
48
+ this.commandServer = options.commandServer ?? null;
49
+ this.repoPath = options.initialPath ?? process.cwd();
50
+ this.currentTheme = options.config.theme;
51
+ // Initialize UI state with config values
52
+ this.uiState = new UIState({
53
+ splitRatio: options.config.splitRatio ?? 0.4,
54
+ });
55
+ // Create blessed screen
56
+ this.screen = blessed.screen({
57
+ smartCSR: true,
58
+ fullUnicode: true,
59
+ title: 'diffstalker',
60
+ mouse: true,
61
+ terminal: 'xterm-256color',
62
+ });
63
+ // Force 256-color support (terminfo detection can be unreliable)
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ const screenAny = this.screen;
66
+ if (screenAny.tput) {
67
+ screenAny.tput.colors = 256;
68
+ }
69
+ if (screenAny.program?.tput) {
70
+ screenAny.program.tput.colors = 256;
71
+ }
72
+ // Create layout
73
+ this.layout = new LayoutManager(this.screen, this.uiState.state.splitRatio);
74
+ // Handle screen resize - re-render content
75
+ // Use setImmediate to ensure screen dimensions are fully updated
76
+ this.screen.on('resize', () => {
77
+ setImmediate(() => this.render());
78
+ });
79
+ // Initialize commit flow state
80
+ this.commitFlowState = new CommitFlowState({
81
+ getHeadMessage: () => this.gitManager?.getHeadCommitMessage() ?? Promise.resolve(''),
82
+ onCommit: async (message, amend) => {
83
+ await this.gitManager?.commit(message, amend);
84
+ },
85
+ onSuccess: () => {
86
+ this.uiState.setTab('diff');
87
+ this.render();
88
+ },
89
+ });
90
+ // Create commit textarea (hidden initially)
91
+ this.commitTextarea = blessed.textarea({
92
+ parent: this.layout.bottomPane,
93
+ top: 3,
94
+ left: 1,
95
+ width: '100%-4',
96
+ height: 1,
97
+ inputOnFocus: true,
98
+ hidden: true,
99
+ style: {
100
+ fg: 'white',
101
+ bg: 'default',
102
+ },
103
+ });
104
+ // Handle textarea submission
105
+ this.commitTextarea.on('submit', () => {
106
+ this.commitFlowState.submit();
107
+ });
108
+ // Sync textarea value with commit state
109
+ this.commitTextarea.on('keypress', () => {
110
+ // Defer to next tick to get updated value
111
+ setImmediate(() => {
112
+ const value = this.commitTextarea?.getValue() ?? '';
113
+ this.commitFlowState.setMessage(value);
114
+ });
115
+ });
116
+ // Setup keyboard handlers
117
+ this.setupKeyboardHandlers();
118
+ // Setup mouse handlers
119
+ this.setupMouseHandlers();
120
+ // Setup state change listeners
121
+ this.setupStateListeners();
122
+ // Setup file watcher if enabled
123
+ if (this.config.watcherEnabled) {
124
+ this.setupFileWatcher();
125
+ }
126
+ // Setup IPC command handler if command server provided
127
+ if (this.commandServer) {
128
+ this.setupCommandHandler();
129
+ }
130
+ // Initialize git manager for current repo
131
+ this.initGitManager();
132
+ // Initial render
133
+ this.render();
134
+ }
135
+ setupKeyboardHandlers() {
136
+ // Quit
137
+ this.screen.key(['q', 'C-c'], () => {
138
+ this.exit();
139
+ });
140
+ // Navigation (skip if modal is open - modal handles its own keys)
141
+ this.screen.key(['j', 'down'], () => {
142
+ if (this.activeModal)
143
+ return;
144
+ this.navigateDown();
145
+ });
146
+ this.screen.key(['k', 'up'], () => {
147
+ if (this.activeModal)
148
+ return;
149
+ this.navigateUp();
150
+ });
151
+ // Tab switching (skip if modal is open)
152
+ this.screen.key(['1'], () => {
153
+ if (this.activeModal)
154
+ return;
155
+ this.uiState.setTab('diff');
156
+ });
157
+ this.screen.key(['2'], () => {
158
+ if (this.activeModal)
159
+ return;
160
+ this.uiState.setTab('commit');
161
+ });
162
+ this.screen.key(['3'], () => {
163
+ if (this.activeModal)
164
+ return;
165
+ this.uiState.setTab('history');
166
+ });
167
+ this.screen.key(['4'], () => {
168
+ if (this.activeModal)
169
+ return;
170
+ this.uiState.setTab('compare');
171
+ });
172
+ this.screen.key(['5'], () => {
173
+ if (this.activeModal)
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();
211
+ }
212
+ 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);
362
+ }
363
+ }
364
+ }
365
+ handleFooterClick(x) {
366
+ const width = this.screen.width || 80;
367
+ // Footer layout: left side has toggles, right side has tabs
368
+ // Tabs are right-aligned, so we calculate from the right
369
+ // Tab format: [1]Diff [2]Commit [3]History [4]Compare [5]Explorer
370
+ // Approximate positions from right edge
371
+ const tabPositions = [
372
+ { tab: 'explorer', label: '[5]Explorer', width: 11 },
373
+ { tab: 'compare', label: '[4]Compare', width: 10 },
374
+ { tab: 'history', label: '[3]History', width: 10 },
375
+ { tab: 'commit', label: '[2]Commit', width: 9 },
376
+ { tab: 'diff', label: '[1]Diff', width: 7 },
377
+ ];
378
+ let rightEdge = width;
379
+ for (const { tab, width: tabWidth } of tabPositions) {
380
+ const leftEdge = rightEdge - tabWidth - 1; // -1 for space
381
+ if (x >= leftEdge && x < rightEdge) {
382
+ this.uiState.setTab(tab);
383
+ return;
384
+ }
385
+ rightEdge = leftEdge;
386
+ }
387
+ // Left side toggles (approximate positions)
388
+ // Format: ? [scroll] [auto] [wrap] [dots]
389
+ if (x >= 2 && x <= 9) {
390
+ // [scroll] or m:[select]
391
+ this.toggleMouseMode();
392
+ }
393
+ else if (x >= 11 && x <= 16) {
394
+ // [auto]
395
+ this.uiState.toggleAutoTab();
396
+ }
397
+ else if (x >= 18 && x <= 23) {
398
+ // [wrap]
399
+ this.uiState.toggleWrapMode();
400
+ }
401
+ else if (x >= 25 && x <= 30 && this.uiState.state.bottomTab === 'explorer') {
402
+ // [dots] - only visible in explorer
403
+ this.uiState.toggleMiddleDots();
404
+ }
405
+ else if (x === 0) {
406
+ // ? - open hotkeys
407
+ this.uiState.openModal('hotkeys');
408
+ }
409
+ }
410
+ handleTopPaneScroll(delta) {
411
+ const state = this.uiState.state;
412
+ const visibleHeight = this.layout.dimensions.topPaneHeight;
413
+ if (state.bottomTab === 'history') {
414
+ const totalRows = this.gitManager?.historyState.commits.length ?? 0;
415
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
416
+ const newOffset = Math.min(maxOffset, Math.max(0, state.historyScrollOffset + delta));
417
+ this.uiState.setHistoryScrollOffset(newOffset);
418
+ }
419
+ else if (state.bottomTab === 'compare') {
420
+ const compareState = this.gitManager?.compareState;
421
+ const totalRows = getCompareListTotalRows(compareState?.compareDiff?.commits ?? [], compareState?.compareDiff?.files ?? []);
422
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
423
+ const newOffset = Math.min(maxOffset, Math.max(0, state.compareScrollOffset + delta));
424
+ this.uiState.setCompareScrollOffset(newOffset);
425
+ }
426
+ else if (state.bottomTab === 'explorer') {
427
+ const totalRows = getExplorerTotalRows(this.explorerManager?.state.items ?? []);
428
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
429
+ const newOffset = Math.min(maxOffset, Math.max(0, state.explorerScrollOffset + delta));
430
+ this.uiState.setExplorerScrollOffset(newOffset);
431
+ }
432
+ else {
433
+ const files = this.gitManager?.state.status?.files ?? [];
434
+ const totalRows = getFileListTotalRows(files);
435
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
436
+ const newOffset = Math.min(maxOffset, Math.max(0, state.fileListScrollOffset + delta));
437
+ this.uiState.setFileListScrollOffset(newOffset);
438
+ }
439
+ }
440
+ handleBottomPaneScroll(delta) {
441
+ const state = this.uiState.state;
442
+ const visibleHeight = this.layout.dimensions.bottomPaneHeight;
443
+ const width = this.screen.width || 80;
444
+ if (state.bottomTab === 'explorer') {
445
+ const selectedFile = this.explorerManager?.state.selectedFile;
446
+ const totalRows = getExplorerContentTotalRows(selectedFile?.content ?? null, selectedFile?.path ?? null, selectedFile?.truncated ?? false, width, state.wrapMode);
447
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
448
+ const newOffset = Math.min(maxOffset, Math.max(0, state.explorerFileScrollOffset + delta));
449
+ this.uiState.setExplorerFileScrollOffset(newOffset);
450
+ }
451
+ else {
452
+ // Use cached totalRows from last render (single source of truth)
453
+ const maxOffset = Math.max(0, this.bottomPaneTotalRows - visibleHeight);
454
+ const newOffset = Math.min(maxOffset, Math.max(0, state.diffScrollOffset + delta));
455
+ this.uiState.setDiffScrollOffset(newOffset);
456
+ }
457
+ }
458
+ setupStateListeners() {
459
+ // Update footer when UI state changes
460
+ this.uiState.on('change', () => {
461
+ this.render();
462
+ });
463
+ // Load data when switching tabs
464
+ this.uiState.on('tab-change', (tab) => {
465
+ if (tab === 'history') {
466
+ this.gitManager?.loadHistory();
467
+ }
468
+ else if (tab === 'compare') {
469
+ this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
470
+ }
471
+ else if (tab === 'explorer') {
472
+ // Explorer is already loaded on init, but refresh if needed
473
+ if (!this.explorerManager?.state.items.length) {
474
+ this.explorerManager?.loadDirectory('');
475
+ }
476
+ }
477
+ });
478
+ // Handle modal opening/closing
479
+ this.uiState.on('modal-change', (modal) => {
480
+ // Close any existing modal
481
+ if (this.activeModal) {
482
+ this.activeModal = null;
483
+ }
484
+ // Open new modal if requested
485
+ if (modal === 'theme') {
486
+ this.activeModal = new ThemePicker(this.screen, this.currentTheme, (theme) => {
487
+ this.currentTheme = theme;
488
+ saveConfig({ theme });
489
+ this.activeModal = null;
490
+ this.uiState.closeModal();
491
+ this.render();
492
+ }, () => {
493
+ this.activeModal = null;
494
+ this.uiState.closeModal();
495
+ });
496
+ this.activeModal.focus();
497
+ }
498
+ else if (modal === 'hotkeys') {
499
+ this.activeModal = new HotkeysModal(this.screen, () => {
500
+ this.activeModal = null;
501
+ this.uiState.closeModal();
502
+ });
503
+ this.activeModal.focus();
504
+ }
505
+ else if (modal === 'baseBranch') {
506
+ // Load candidate branches and show picker
507
+ this.gitManager?.getCandidateBaseBranches().then((branches) => {
508
+ const currentBranch = this.gitManager?.compareState.compareBaseBranch ?? null;
509
+ this.activeModal = new BaseBranchPicker(this.screen, branches, currentBranch, (branch) => {
510
+ this.activeModal = null;
511
+ this.uiState.closeModal();
512
+ // Set base branch and refresh compare view
513
+ const includeUncommitted = this.uiState.state.includeUncommitted;
514
+ this.gitManager?.setCompareBaseBranch(branch, includeUncommitted);
515
+ }, () => {
516
+ this.activeModal = null;
517
+ this.uiState.closeModal();
518
+ });
519
+ this.activeModal.focus();
520
+ });
521
+ }
522
+ });
523
+ // Save split ratio to config when it changes
524
+ let saveTimer = null;
525
+ this.uiState.on('change', (state) => {
526
+ if (saveTimer)
527
+ clearTimeout(saveTimer);
528
+ saveTimer = setTimeout(() => {
529
+ if (state.splitRatio !== this.config.splitRatio) {
530
+ saveConfig({ splitRatio: state.splitRatio });
531
+ }
532
+ }, 500);
533
+ });
534
+ }
535
+ setupFileWatcher() {
536
+ this.fileWatcher = new FilePathWatcher(this.config.targetFile);
537
+ this.fileWatcher.on('path-change', (state) => {
538
+ if (state.path && state.path !== this.repoPath) {
539
+ this.repoPath = state.path;
540
+ this.watcherState = {
541
+ enabled: true,
542
+ sourceFile: state.sourceFile ?? this.config.targetFile,
543
+ rawContent: state.rawContent ?? undefined,
544
+ lastUpdate: state.lastUpdate ?? undefined,
545
+ };
546
+ this.initGitManager();
547
+ this.render();
548
+ }
549
+ // Navigate to the followed file if it's within the repo
550
+ if (state.rawContent) {
551
+ this.navigateToFile(state.rawContent);
552
+ this.render();
553
+ }
554
+ });
555
+ this.watcherState = {
556
+ enabled: true,
557
+ sourceFile: this.config.targetFile,
558
+ };
559
+ this.fileWatcher.start();
560
+ // Navigate to the initially followed file
561
+ const initialState = this.fileWatcher.state;
562
+ if (initialState.rawContent) {
563
+ this.watcherState.rawContent = initialState.rawContent;
564
+ this.navigateToFile(initialState.rawContent);
565
+ }
566
+ }
567
+ initGitManager() {
568
+ // Clean up existing manager
569
+ if (this.gitManager) {
570
+ this.gitManager.removeAllListeners();
571
+ removeManagerForRepo(this.repoPath);
572
+ }
573
+ // Get or create manager for this repo
574
+ this.gitManager = getManagerForRepo(this.repoPath);
575
+ // Listen to state changes
576
+ this.gitManager.on('state-change', () => {
577
+ this.render();
578
+ });
579
+ this.gitManager.on('history-state-change', (historyState) => {
580
+ // Auto-select first commit when history loads
581
+ if (historyState.commits.length > 0 && !historyState.selectedCommit) {
582
+ const state = this.uiState.state;
583
+ if (state.bottomTab === 'history') {
584
+ this.selectHistoryCommitByIndex(state.historySelectedIndex);
585
+ }
586
+ }
587
+ this.render();
588
+ });
589
+ this.gitManager.on('compare-state-change', () => {
590
+ this.render();
591
+ });
592
+ this.gitManager.on('compare-selection-change', () => {
593
+ this.render();
594
+ });
595
+ // Start watching and do initial refresh
596
+ this.gitManager.startWatching();
597
+ this.gitManager.refresh();
598
+ // Initialize explorer manager
599
+ this.initExplorerManager();
600
+ }
601
+ initExplorerManager() {
602
+ // Clean up existing manager
603
+ if (this.explorerManager) {
604
+ this.explorerManager.dispose();
605
+ }
606
+ // Create new manager with options
607
+ const options = {
608
+ hideHidden: true,
609
+ hideGitignored: true,
610
+ };
611
+ this.explorerManager = new ExplorerStateManager(this.repoPath, options);
612
+ // Listen to state changes
613
+ this.explorerManager.on('state-change', () => {
614
+ this.render();
615
+ });
616
+ // Load root directory
617
+ this.explorerManager.loadDirectory('');
618
+ }
619
+ setupCommandHandler() {
620
+ if (!this.commandServer)
621
+ return;
622
+ const handler = {
623
+ navigateUp: () => this.navigateUp(),
624
+ navigateDown: () => this.navigateDown(),
625
+ switchTab: (tab) => this.uiState.setTab(tab),
626
+ togglePane: () => this.uiState.togglePane(),
627
+ stage: async () => this.stageSelected(),
628
+ unstage: async () => this.unstageSelected(),
629
+ stageAll: async () => this.stageAll(),
630
+ unstageAll: async () => this.unstageAll(),
631
+ commit: async (message) => this.commit(message),
632
+ refresh: async () => this.refresh(),
633
+ getState: () => this.getAppState(),
634
+ quit: () => this.exit(),
635
+ };
636
+ this.commandServer.setHandler(handler);
637
+ this.commandServer.notifyReady();
638
+ }
639
+ getAppState() {
640
+ const state = this.uiState.state;
641
+ const gitState = this.gitManager?.state;
642
+ const historyState = this.gitManager?.historyState;
643
+ const files = gitState?.status?.files ?? [];
644
+ const commits = historyState?.commits ?? [];
645
+ return {
646
+ currentTab: state.bottomTab,
647
+ currentPane: state.currentPane,
648
+ selectedIndex: state.selectedIndex,
649
+ totalFiles: files.length,
650
+ stagedCount: files.filter((f) => f.staged).length,
651
+ files: files.map((f) => ({
652
+ path: f.path,
653
+ status: f.status,
654
+ staged: f.staged,
655
+ })),
656
+ historySelectedIndex: state.historySelectedIndex,
657
+ historyCommitCount: commits.length,
658
+ compareSelectedIndex: state.compareSelectedIndex,
659
+ compareTotalItems: 0,
660
+ includeUncommitted: state.includeUncommitted,
661
+ explorerPath: this.repoPath,
662
+ explorerSelectedIndex: state.explorerSelectedIndex,
663
+ explorerItemCount: 0,
664
+ wrapMode: state.wrapMode,
665
+ mouseEnabled: state.mouseEnabled,
666
+ autoTabEnabled: state.autoTabEnabled,
667
+ };
668
+ }
669
+ // Navigation methods
670
+ navigateUp() {
671
+ const state = this.uiState.state;
672
+ if (state.bottomTab === 'history') {
673
+ if (state.currentPane === 'history') {
674
+ this.navigateHistoryUp();
675
+ }
676
+ else if (state.currentPane === 'diff') {
677
+ this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
678
+ }
679
+ return;
680
+ }
681
+ if (state.bottomTab === 'compare') {
682
+ if (state.currentPane === 'compare') {
683
+ this.navigateCompareUp();
684
+ }
685
+ else if (state.currentPane === 'diff') {
686
+ this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
687
+ }
688
+ return;
689
+ }
690
+ if (state.bottomTab === 'explorer') {
691
+ if (state.currentPane === 'explorer') {
692
+ this.navigateExplorerUp();
693
+ }
694
+ else if (state.currentPane === 'diff') {
695
+ this.uiState.setExplorerFileScrollOffset(Math.max(0, state.explorerFileScrollOffset - 3));
696
+ }
697
+ return;
698
+ }
699
+ if (state.currentPane === 'files') {
700
+ const files = this.gitManager?.state.status?.files ?? [];
701
+ const newIndex = Math.max(0, state.selectedIndex - 1);
702
+ this.uiState.setSelectedIndex(newIndex);
703
+ this.selectFileByIndex(newIndex);
704
+ // Keep selection visible - scroll up if needed
705
+ const row = getRowFromFileIndex(newIndex, files);
706
+ if (row < state.fileListScrollOffset) {
707
+ this.uiState.setFileListScrollOffset(row);
708
+ }
709
+ }
710
+ else if (state.currentPane === 'diff') {
711
+ this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
712
+ }
713
+ }
714
+ navigateDown() {
715
+ const state = this.uiState.state;
716
+ const files = this.gitManager?.state.status?.files ?? [];
717
+ if (state.bottomTab === 'history') {
718
+ if (state.currentPane === 'history') {
719
+ this.navigateHistoryDown();
720
+ }
721
+ else if (state.currentPane === 'diff') {
722
+ this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
723
+ }
724
+ return;
725
+ }
726
+ if (state.bottomTab === 'compare') {
727
+ if (state.currentPane === 'compare') {
728
+ this.navigateCompareDown();
729
+ }
730
+ else if (state.currentPane === 'diff') {
731
+ this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
732
+ }
733
+ return;
734
+ }
735
+ if (state.bottomTab === 'explorer') {
736
+ if (state.currentPane === 'explorer') {
737
+ this.navigateExplorerDown();
738
+ }
739
+ else if (state.currentPane === 'diff') {
740
+ this.uiState.setExplorerFileScrollOffset(state.explorerFileScrollOffset + 3);
741
+ }
742
+ return;
743
+ }
744
+ if (state.currentPane === 'files') {
745
+ const newIndex = Math.min(files.length - 1, state.selectedIndex + 1);
746
+ this.uiState.setSelectedIndex(newIndex);
747
+ this.selectFileByIndex(newIndex);
748
+ // Keep selection visible - scroll down if needed
749
+ const row = getRowFromFileIndex(newIndex, files);
750
+ const visibleEnd = state.fileListScrollOffset + this.layout.dimensions.topPaneHeight - 1;
751
+ if (row >= visibleEnd) {
752
+ this.uiState.setFileListScrollOffset(state.fileListScrollOffset + (row - visibleEnd + 1));
753
+ }
754
+ }
755
+ else if (state.currentPane === 'diff') {
756
+ this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
757
+ }
758
+ }
759
+ navigateHistoryUp() {
760
+ const state = this.uiState.state;
761
+ const newIndex = Math.max(0, state.historySelectedIndex - 1);
762
+ if (newIndex !== state.historySelectedIndex) {
763
+ this.uiState.setHistorySelectedIndex(newIndex);
764
+ // Keep selection visible
765
+ if (newIndex < state.historyScrollOffset) {
766
+ this.uiState.setHistoryScrollOffset(newIndex);
767
+ }
768
+ this.selectHistoryCommitByIndex(newIndex);
769
+ }
770
+ }
771
+ navigateHistoryDown() {
772
+ const state = this.uiState.state;
773
+ const commits = this.gitManager?.historyState.commits ?? [];
774
+ const newIndex = Math.min(commits.length - 1, state.historySelectedIndex + 1);
775
+ if (newIndex !== state.historySelectedIndex) {
776
+ this.uiState.setHistorySelectedIndex(newIndex);
777
+ // Keep selection visible
778
+ const visibleEnd = state.historyScrollOffset + this.layout.dimensions.topPaneHeight - 1;
779
+ if (newIndex >= visibleEnd) {
780
+ this.uiState.setHistoryScrollOffset(state.historyScrollOffset + 1);
781
+ }
782
+ this.selectHistoryCommitByIndex(newIndex);
783
+ }
784
+ }
785
+ selectHistoryCommitByIndex(index) {
786
+ const commits = this.gitManager?.historyState.commits ?? [];
787
+ const commit = getCommitAtIndex(commits, index);
788
+ if (commit) {
789
+ this.uiState.setDiffScrollOffset(0);
790
+ this.gitManager?.selectHistoryCommit(commit);
791
+ }
792
+ }
793
+ // Compare navigation
794
+ compareSelection = null;
795
+ navigateCompareUp() {
796
+ const compareState = this.gitManager?.compareState;
797
+ const commits = compareState?.compareDiff?.commits ?? [];
798
+ const files = compareState?.compareDiff?.files ?? [];
799
+ if (commits.length === 0 && files.length === 0)
800
+ return;
801
+ const next = getNextCompareSelection(this.compareSelection, commits, files, 'up');
802
+ if (next &&
803
+ (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
804
+ this.selectCompareItem(next);
805
+ // Keep selection visible - scroll up if needed
806
+ const state = this.uiState.state;
807
+ const row = getRowFromCompareSelection(next, commits, files);
808
+ if (row < state.compareScrollOffset) {
809
+ this.uiState.setCompareScrollOffset(row);
810
+ }
811
+ }
812
+ }
813
+ navigateCompareDown() {
814
+ const compareState = this.gitManager?.compareState;
815
+ const commits = compareState?.compareDiff?.commits ?? [];
816
+ const files = compareState?.compareDiff?.files ?? [];
817
+ if (commits.length === 0 && files.length === 0)
818
+ return;
819
+ // Auto-select first item if nothing selected
820
+ if (!this.compareSelection) {
821
+ // Select first commit if available, otherwise first file
822
+ if (commits.length > 0) {
823
+ this.selectCompareItem({ type: 'commit', index: 0 });
824
+ }
825
+ else if (files.length > 0) {
826
+ this.selectCompareItem({ type: 'file', index: 0 });
827
+ }
828
+ return;
829
+ }
830
+ const next = getNextCompareSelection(this.compareSelection, commits, files, 'down');
831
+ if (next &&
832
+ (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
833
+ this.selectCompareItem(next);
834
+ // Keep selection visible - scroll down if needed
835
+ const state = this.uiState.state;
836
+ const row = getRowFromCompareSelection(next, commits, files);
837
+ const visibleEnd = state.compareScrollOffset + this.layout.dimensions.topPaneHeight - 1;
838
+ if (row >= visibleEnd) {
839
+ this.uiState.setCompareScrollOffset(state.compareScrollOffset + (row - visibleEnd + 1));
840
+ }
841
+ }
842
+ }
843
+ selectCompareItem(selection) {
844
+ this.compareSelection = selection;
845
+ this.uiState.setDiffScrollOffset(0);
846
+ if (selection.type === 'commit') {
847
+ this.gitManager?.selectCompareCommit(selection.index);
848
+ }
849
+ else {
850
+ this.gitManager?.selectCompareFile(selection.index);
851
+ }
852
+ }
853
+ // Explorer navigation
854
+ navigateExplorerUp() {
855
+ const state = this.uiState.state;
856
+ const items = this.explorerManager?.state.items ?? [];
857
+ if (items.length === 0)
858
+ return;
859
+ const newScrollOffset = this.explorerManager?.navigateUp(state.explorerScrollOffset);
860
+ if (newScrollOffset !== null && newScrollOffset !== undefined) {
861
+ this.uiState.setExplorerScrollOffset(newScrollOffset);
862
+ }
863
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
864
+ }
865
+ navigateExplorerDown() {
866
+ const state = this.uiState.state;
867
+ const items = this.explorerManager?.state.items ?? [];
868
+ if (items.length === 0)
869
+ return;
870
+ const visibleHeight = this.layout.dimensions.topPaneHeight;
871
+ const newScrollOffset = this.explorerManager?.navigateDown(state.explorerScrollOffset, visibleHeight);
872
+ if (newScrollOffset !== null && newScrollOffset !== undefined) {
873
+ this.uiState.setExplorerScrollOffset(newScrollOffset);
874
+ }
875
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
876
+ }
877
+ async enterExplorerDirectory() {
878
+ await this.explorerManager?.enterDirectory();
879
+ this.uiState.setExplorerScrollOffset(0);
880
+ this.uiState.setExplorerFileScrollOffset(0);
881
+ this.uiState.setExplorerSelectedIndex(0);
882
+ }
883
+ async goExplorerUp() {
884
+ await this.explorerManager?.goUp();
885
+ this.uiState.setExplorerScrollOffset(0);
886
+ this.uiState.setExplorerFileScrollOffset(0);
887
+ this.uiState.setExplorerSelectedIndex(0);
888
+ }
889
+ selectFileByIndex(index) {
890
+ const files = this.gitManager?.state.status?.files ?? [];
891
+ const file = getFileAtIndex(files, index);
892
+ if (file) {
893
+ // Reset diff scroll when changing files
894
+ this.uiState.setDiffScrollOffset(0);
895
+ this.gitManager?.selectFile(file);
896
+ }
897
+ }
898
+ /**
899
+ * Navigate to a file given its absolute path.
900
+ * Extracts the relative path and finds the file in the current file list.
901
+ */
902
+ navigateToFile(absolutePath) {
903
+ if (!absolutePath || !this.repoPath)
904
+ return;
905
+ // Check if the path is within the current repo
906
+ const repoPrefix = this.repoPath.endsWith('/') ? this.repoPath : this.repoPath + '/';
907
+ if (!absolutePath.startsWith(repoPrefix))
908
+ return;
909
+ // Extract relative path
910
+ const relativePath = absolutePath.slice(repoPrefix.length);
911
+ if (!relativePath)
912
+ return;
913
+ // Find the file in the list
914
+ const files = this.gitManager?.state.status?.files ?? [];
915
+ const fileIndex = files.findIndex((f) => f.path === relativePath);
916
+ if (fileIndex >= 0) {
917
+ this.uiState.setSelectedIndex(fileIndex);
918
+ this.selectFileByIndex(fileIndex);
919
+ }
920
+ }
921
+ // Git operations
922
+ async stageSelected() {
923
+ const files = this.gitManager?.state.status?.files ?? [];
924
+ const selectedFile = files[this.uiState.state.selectedIndex];
925
+ if (selectedFile && !selectedFile.staged) {
926
+ await this.gitManager?.stage(selectedFile);
927
+ }
928
+ }
929
+ async unstageSelected() {
930
+ const files = this.gitManager?.state.status?.files ?? [];
931
+ const selectedFile = files[this.uiState.state.selectedIndex];
932
+ if (selectedFile?.staged) {
933
+ await this.gitManager?.unstage(selectedFile);
934
+ }
935
+ }
936
+ async toggleSelected() {
937
+ const files = this.gitManager?.state.status?.files ?? [];
938
+ const selectedFile = files[this.uiState.state.selectedIndex];
939
+ if (selectedFile) {
940
+ if (selectedFile.staged) {
941
+ await this.gitManager?.unstage(selectedFile);
942
+ }
943
+ else {
944
+ await this.gitManager?.stage(selectedFile);
945
+ }
946
+ }
947
+ }
948
+ async stageAll() {
949
+ await this.gitManager?.stageAll();
950
+ }
951
+ async unstageAll() {
952
+ await this.gitManager?.unstageAll();
953
+ }
954
+ showDiscardConfirm(file) {
955
+ this.activeModal = new DiscardConfirm(this.screen, file.path, async () => {
956
+ this.activeModal = null;
957
+ await this.gitManager?.discard(file);
958
+ }, () => {
959
+ this.activeModal = null;
960
+ });
961
+ this.activeModal.focus();
962
+ }
963
+ async commit(message) {
964
+ await this.gitManager?.commit(message);
965
+ }
966
+ async refresh() {
967
+ await this.gitManager?.refresh();
968
+ }
969
+ toggleMouseMode() {
970
+ const willEnable = !this.uiState.state.mouseEnabled;
971
+ this.uiState.toggleMouse();
972
+ // Access program for terminal mouse control (not on screen's TS types)
973
+ const program = this.screen.program;
974
+ if (willEnable) {
975
+ program.enableMouse();
976
+ }
977
+ else {
978
+ program.disableMouse();
979
+ }
980
+ }
981
+ toggleFollow() {
982
+ if (this.fileWatcher) {
983
+ this.fileWatcher.stop();
984
+ this.fileWatcher = null;
985
+ this.watcherState = { enabled: false };
986
+ }
987
+ else {
988
+ this.setupFileWatcher();
989
+ }
990
+ this.render();
991
+ }
992
+ focusCommitInput() {
993
+ if (this.commitTextarea) {
994
+ this.commitTextarea.show();
995
+ this.commitTextarea.focus();
996
+ this.commitTextarea.setValue(this.commitFlowState.state.message);
997
+ this.commitFlowState.setInputFocused(true);
998
+ this.render();
999
+ }
1000
+ }
1001
+ unfocusCommitInput() {
1002
+ if (this.commitTextarea) {
1003
+ const value = this.commitTextarea.getValue() ?? '';
1004
+ this.commitFlowState.setMessage(value);
1005
+ this.commitTextarea.hide();
1006
+ this.commitFlowState.setInputFocused(false);
1007
+ this.screen.focusPush(this.layout.bottomPane);
1008
+ this.render();
1009
+ }
1010
+ }
1011
+ // Render methods
1012
+ render() {
1013
+ this.updateHeader();
1014
+ this.updateTopPane();
1015
+ this.updateBottomPane();
1016
+ this.updateFooter();
1017
+ this.screen.render();
1018
+ }
1019
+ updateHeader() {
1020
+ const gitState = this.gitManager?.state;
1021
+ const width = this.screen.width || 80;
1022
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, this.watcherState, width);
1023
+ this.layout.headerBox.setContent(content);
1024
+ }
1025
+ updateTopPane() {
1026
+ const gitState = this.gitManager?.state;
1027
+ const historyState = this.gitManager?.historyState;
1028
+ const compareState = this.gitManager?.compareState;
1029
+ const files = gitState?.status?.files ?? [];
1030
+ const state = this.uiState.state;
1031
+ const width = this.screen.width || 80;
1032
+ let content;
1033
+ if (state.bottomTab === 'history') {
1034
+ const commits = historyState?.commits ?? [];
1035
+ content = formatHistoryView(commits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, this.layout.dimensions.topPaneHeight);
1036
+ }
1037
+ else if (state.bottomTab === 'compare') {
1038
+ const compareDiff = compareState?.compareDiff;
1039
+ const commits = compareDiff?.commits ?? [];
1040
+ const compareFiles = compareDiff?.files ?? [];
1041
+ content = formatCompareListView(commits, compareFiles, this.compareSelection, state.currentPane === 'compare', width, state.compareScrollOffset, this.layout.dimensions.topPaneHeight);
1042
+ }
1043
+ else if (state.bottomTab === 'explorer') {
1044
+ const explorerState = this.explorerManager?.state;
1045
+ const items = explorerState?.items ?? [];
1046
+ content = formatExplorerView(items, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, this.layout.dimensions.topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
1047
+ }
1048
+ else {
1049
+ content = formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, this.layout.dimensions.topPaneHeight);
1050
+ }
1051
+ this.layout.topPane.setContent(content);
1052
+ }
1053
+ updateBottomPane() {
1054
+ const gitState = this.gitManager?.state;
1055
+ const historyState = this.gitManager?.historyState;
1056
+ const diff = gitState?.diff ?? null;
1057
+ const state = this.uiState.state;
1058
+ const width = this.screen.width || 80;
1059
+ const files = gitState?.status?.files ?? [];
1060
+ const stagedCount = files.filter((f) => f.staged).length;
1061
+ // Update staged count for commit validation
1062
+ this.commitFlowState.setStagedCount(stagedCount);
1063
+ // Show appropriate content based on tab
1064
+ if (state.bottomTab === 'commit') {
1065
+ const commitContent = formatCommitPanel(this.commitFlowState.state, stagedCount, width);
1066
+ this.layout.bottomPane.setContent(commitContent);
1067
+ // Show/hide textarea based on focus
1068
+ if (this.commitTextarea) {
1069
+ if (this.commitFlowState.state.inputFocused) {
1070
+ this.commitTextarea.show();
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);
1099
+ }
1100
+ else {
1101
+ this.bottomPaneTotalRows = 0;
1102
+ this.layout.bottomPane.setContent('{gray-fg}Select a commit or file to view diff{/gray-fg}');
1103
+ }
1104
+ }
1105
+ else if (state.bottomTab === 'explorer') {
1106
+ // Hide commit textarea when not on commit tab
1107
+ if (this.commitTextarea) {
1108
+ this.commitTextarea.hide();
1109
+ }
1110
+ const explorerState = this.explorerManager?.state;
1111
+ const selectedFile = explorerState?.selectedFile ?? null;
1112
+ const content = formatExplorerContent(selectedFile?.path ?? null, selectedFile?.content ?? null, width, state.explorerFileScrollOffset, this.layout.dimensions.bottomPaneHeight, selectedFile?.truncated ?? false, state.wrapMode, state.showMiddleDots);
1113
+ // TODO: formatExplorerContent should also return totalRows
1114
+ this.layout.bottomPane.setContent(content);
1115
+ }
1116
+ else {
1117
+ // Hide commit textarea when not on commit tab
1118
+ if (this.commitTextarea) {
1119
+ this.commitTextarea.hide();
1120
+ }
1121
+ const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, this.layout.dimensions.bottomPaneHeight, this.currentTheme, state.wrapMode);
1122
+ this.bottomPaneTotalRows = totalRows;
1123
+ this.layout.bottomPane.setContent(content);
1124
+ }
1125
+ }
1126
+ updateFooter() {
1127
+ const state = this.uiState.state;
1128
+ const width = this.screen.width || 80;
1129
+ const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, state.showMiddleDots, width);
1130
+ this.layout.footerBox.setContent(content);
1131
+ }
1132
+ /**
1133
+ * Exit the application cleanly.
1134
+ */
1135
+ exit() {
1136
+ // Clean up
1137
+ if (this.gitManager) {
1138
+ removeManagerForRepo(this.repoPath);
1139
+ }
1140
+ if (this.explorerManager) {
1141
+ this.explorerManager.dispose();
1142
+ }
1143
+ if (this.fileWatcher) {
1144
+ this.fileWatcher.stop();
1145
+ }
1146
+ if (this.commandServer) {
1147
+ this.commandServer.stop();
1148
+ }
1149
+ // Destroy screen (this will clean up terminal)
1150
+ this.screen.destroy();
1151
+ }
1152
+ /**
1153
+ * Start the application (returns when app exits).
1154
+ */
1155
+ start() {
1156
+ return new Promise((resolve) => {
1157
+ this.screen.on('destroy', () => {
1158
+ resolve();
1159
+ });
1160
+ });
1161
+ }
1162
+ }