diffstalker 0.1.5 → 0.1.7

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 (56) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/bun.lock +618 -0
  3. package/dist/App.js +541 -1
  4. package/dist/components/BaseBranchPicker.js +60 -1
  5. package/dist/components/BottomPane.js +101 -1
  6. package/dist/components/CommitPanel.js +58 -1
  7. package/dist/components/CompareListView.js +110 -1
  8. package/dist/components/ExplorerContentView.js +80 -0
  9. package/dist/components/ExplorerView.js +37 -0
  10. package/dist/components/FileList.js +131 -1
  11. package/dist/components/Footer.js +6 -1
  12. package/dist/components/Header.js +107 -1
  13. package/dist/components/HistoryView.js +21 -1
  14. package/dist/components/HotkeysModal.js +108 -1
  15. package/dist/components/Modal.js +19 -1
  16. package/dist/components/ScrollableList.js +125 -1
  17. package/dist/components/ThemePicker.js +42 -1
  18. package/dist/components/TopPane.js +14 -1
  19. package/dist/components/UnifiedDiffView.js +115 -0
  20. package/dist/config.js +83 -2
  21. package/dist/core/GitOperationQueue.js +109 -1
  22. package/dist/core/GitStateManager.js +466 -1
  23. package/dist/git/diff.js +471 -10
  24. package/dist/git/status.js +269 -5
  25. package/dist/hooks/useCommitFlow.js +66 -1
  26. package/dist/hooks/useCompareState.js +123 -1
  27. package/dist/hooks/useExplorerState.js +248 -0
  28. package/dist/hooks/useGit.js +156 -1
  29. package/dist/hooks/useHistoryState.js +62 -1
  30. package/dist/hooks/useKeymap.js +167 -1
  31. package/dist/hooks/useLayout.js +154 -1
  32. package/dist/hooks/useMouse.js +87 -1
  33. package/dist/hooks/useTerminalSize.js +20 -1
  34. package/dist/hooks/useWatcher.js +137 -11
  35. package/dist/index.js +43 -3
  36. package/dist/services/commitService.js +22 -1
  37. package/dist/themes.js +127 -1
  38. package/dist/utils/ansiTruncate.js +108 -0
  39. package/dist/utils/baseBranchCache.js +44 -2
  40. package/dist/utils/commitFormat.js +38 -1
  41. package/dist/utils/diffFilters.js +21 -1
  42. package/dist/utils/diffRowCalculations.js +113 -0
  43. package/dist/utils/displayRows.js +172 -0
  44. package/dist/utils/explorerDisplayRows.js +169 -0
  45. package/dist/utils/fileCategories.js +26 -1
  46. package/dist/utils/formatDate.js +39 -1
  47. package/dist/utils/formatPath.js +58 -1
  48. package/dist/utils/languageDetection.js +180 -0
  49. package/dist/utils/layoutCalculations.js +98 -1
  50. package/dist/utils/lineBreaking.js +88 -0
  51. package/dist/utils/mouseCoordinates.js +165 -1
  52. package/dist/utils/rowCalculations.js +209 -3
  53. package/package.json +7 -10
  54. package/dist/components/CompareView.js +0 -1
  55. package/dist/components/DiffView.js +0 -1
  56. package/dist/components/HistoryDiffView.js +0 -1
@@ -1,5 +1,269 @@
1
- import{simpleGit as c}from"simple-git";import*as x from"node:fs";import*as k from"node:path";export function parseNumstat(n){const e=new Map;for(const i of n.trim().split(`
2
- `)){if(!i)continue;const s=i.split(" ");if(s.length>=3){const a=s[0]==="-"?0:parseInt(s[0],10),r=s[1]==="-"?0:parseInt(s[1],10),d=s.slice(2).join(" ");e.set(d,{insertions:a,deletions:r})}}return e}async function y(n,e){try{const i=k.join(n,e);return(await x.promises.readFile(i,"utf-8")).split(`
3
- `).filter(a=>a.length>0).length}catch{return 0}}async function S(n,e){if(e.length===0)return new Set;try{const i=new Set,s=100;for(let a=0;a<e.length;a+=s){const r=e.slice(a,a+s);try{const l=(await n.raw(["check-ignore",...r])).trim().split(`
4
- `).filter(u=>u.length>0);for(const u of l)i.add(u)}catch{}}return i}catch{return new Set}}export function parseStatusCode(n){switch(n){case"M":return"modified";case"A":return"added";case"D":return"deleted";case"?":return"untracked";case"R":return"renamed";case"C":return"copied";default:return"modified"}}export async function getStatus(n){const e=c(n);try{if(!await e.checkIsRepo())return{files:[],branch:{current:"",ahead:0,behind:0},isRepo:!1};const s=await e.status(),a=[];for(const t of s.staged)a.push({path:t,status:"added",staged:!0});for(const t of s.modified)a.find(g=>g.path===t&&g.staged)||a.push({path:t,status:"modified",staged:!1});for(const t of s.deleted)a.push({path:t,status:"deleted",staged:!1});for(const t of s.not_added)a.push({path:t,status:"untracked",staged:!1});for(const t of s.renamed)a.push({path:t.to,originalPath:t.from,status:"renamed",staged:!0});const r=[],d=new Set,l=s.files.filter(t=>t.working_dir==="?").map(t=>t.path),u=await S(e,l);for(const t of s.files){if(t.index==="!"||t.working_dir==="!"||u.has(t.path))continue;const o=`${t.path}-${t.index!==" "&&t.index!=="?"}`;d.has(o)||(d.add(o),t.index&&t.index!==" "&&t.index!=="?"&&r.push({path:t.path,status:parseStatusCode(t.index),staged:!0}),t.working_dir&&t.working_dir!==" "&&r.push({path:t.path,status:t.working_dir==="?"?"untracked":parseStatusCode(t.working_dir),staged:!1}))}const[h,p]=await Promise.all([e.diff(["--cached","--numstat"]).catch(()=>""),e.diff(["--numstat"]).catch(()=>"")]),m=parseNumstat(h),w=parseNumstat(p);for(const t of r){const o=t.staged?m.get(t.path):w.get(t.path);o&&(t.insertions=o.insertions,t.deletions=o.deletions)}const f=r.filter(t=>t.status==="untracked");if(f.length>0){const t=await Promise.all(f.map(o=>y(n,o.path)));for(let o=0;o<f.length;o++)f[o].insertions=t[o],f[o].deletions=0}return{files:r,branch:{current:s.current||"HEAD",tracking:s.tracking||void 0,ahead:s.ahead,behind:s.behind},isRepo:!0}}catch{return{files:[],branch:{current:"",ahead:0,behind:0},isRepo:!1}}}export async function stageFile(n,e){await c(n).add(e)}export async function unstageFile(n,e){await c(n).reset(["HEAD","--",e])}export async function stageAll(n){await c(n).add("-A")}export async function unstageAll(n){await c(n).reset(["HEAD"])}export async function discardChanges(n,e){await c(n).checkout(["--",e])}export async function commit(n,e,i=!1){await c(n).commit(e,void 0,i?{"--amend":null}:void 0)}export async function getHeadMessage(n){const e=c(n);try{return(await e.log({n:1})).latest?.message||""}catch{return""}}export async function getCommitHistory(n,e=50){const i=c(n);try{return(await i.log({n:e})).all.map(a=>({hash:a.hash,shortHash:a.hash.slice(0,7),message:a.message.split(`
5
- `)[0],author:a.author_name,date:new Date(a.date),refs:a.refs||""}))}catch{return[]}}
1
+ import { simpleGit } from 'simple-git';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ // Parse git diff --numstat output into a map of path -> stats
5
+ export function parseNumstat(output) {
6
+ const stats = new Map();
7
+ for (const line of output.trim().split('\n')) {
8
+ if (!line)
9
+ continue;
10
+ const parts = line.split('\t');
11
+ if (parts.length >= 3) {
12
+ const insertions = parts[0] === '-' ? 0 : parseInt(parts[0], 10);
13
+ const deletions = parts[1] === '-' ? 0 : parseInt(parts[1], 10);
14
+ const filepath = parts.slice(2).join('\t'); // Handle paths with tabs
15
+ stats.set(filepath, { insertions, deletions });
16
+ }
17
+ }
18
+ return stats;
19
+ }
20
+ // Count lines in a file (for untracked files which don't show in numstat)
21
+ async function countFileLines(repoPath, filePath) {
22
+ try {
23
+ const fullPath = path.join(repoPath, filePath);
24
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
25
+ // Count non-empty lines
26
+ return content.split('\n').filter((line) => line.length > 0).length;
27
+ }
28
+ catch {
29
+ return 0;
30
+ }
31
+ }
32
+ // Check which files from a list are ignored by git
33
+ async function getIgnoredFiles(git, files) {
34
+ if (files.length === 0)
35
+ return new Set();
36
+ try {
37
+ // git check-ignore returns the list of ignored files (one per line)
38
+ // Pass files as arguments (limit batch size to avoid command line length issues)
39
+ const ignoredFiles = new Set();
40
+ const batchSize = 100;
41
+ for (let i = 0; i < files.length; i += batchSize) {
42
+ const batch = files.slice(i, i + batchSize);
43
+ try {
44
+ const result = await git.raw(['check-ignore', ...batch]);
45
+ const ignored = result
46
+ .trim()
47
+ .split('\n')
48
+ .filter((f) => f.length > 0);
49
+ for (const f of ignored) {
50
+ ignoredFiles.add(f);
51
+ }
52
+ }
53
+ catch {
54
+ // check-ignore exits with code 1 if no files are ignored, which throws
55
+ // Just continue to next batch
56
+ }
57
+ }
58
+ return ignoredFiles;
59
+ }
60
+ catch {
61
+ // If check-ignore fails entirely, return empty set
62
+ return new Set();
63
+ }
64
+ }
65
+ export function parseStatusCode(code) {
66
+ switch (code) {
67
+ case 'M':
68
+ return 'modified';
69
+ case 'A':
70
+ return 'added';
71
+ case 'D':
72
+ return 'deleted';
73
+ case '?':
74
+ return 'untracked';
75
+ case 'R':
76
+ return 'renamed';
77
+ case 'C':
78
+ return 'copied';
79
+ default:
80
+ return 'modified';
81
+ }
82
+ }
83
+ export async function getStatus(repoPath) {
84
+ const git = simpleGit(repoPath);
85
+ try {
86
+ const isRepo = await git.checkIsRepo();
87
+ if (!isRepo) {
88
+ return {
89
+ files: [],
90
+ branch: { current: '', ahead: 0, behind: 0 },
91
+ isRepo: false,
92
+ };
93
+ }
94
+ const status = await git.status();
95
+ const files = [];
96
+ // Process staged files
97
+ for (const file of status.staged) {
98
+ files.push({
99
+ path: file,
100
+ status: 'added',
101
+ staged: true,
102
+ });
103
+ }
104
+ // Process modified staged files
105
+ for (const file of status.modified) {
106
+ // Check if it's in the index (staged)
107
+ const existingStaged = files.find((f) => f.path === file && f.staged);
108
+ if (!existingStaged) {
109
+ files.push({
110
+ path: file,
111
+ status: 'modified',
112
+ staged: false,
113
+ });
114
+ }
115
+ }
116
+ // Process deleted files
117
+ for (const file of status.deleted) {
118
+ files.push({
119
+ path: file,
120
+ status: 'deleted',
121
+ staged: false,
122
+ });
123
+ }
124
+ // Process untracked files
125
+ for (const file of status.not_added) {
126
+ files.push({
127
+ path: file,
128
+ status: 'untracked',
129
+ staged: false,
130
+ });
131
+ }
132
+ // Process renamed files
133
+ for (const file of status.renamed) {
134
+ files.push({
135
+ path: file.to,
136
+ originalPath: file.from,
137
+ status: 'renamed',
138
+ staged: true,
139
+ });
140
+ }
141
+ // Use the files array from status for more accurate staging info
142
+ // The status.files array has detailed index/working_dir info
143
+ const processedFiles = [];
144
+ const seen = new Set();
145
+ // Collect untracked files to check if they're ignored
146
+ const untrackedPaths = status.files.filter((f) => f.working_dir === '?').map((f) => f.path);
147
+ // Get the set of ignored files
148
+ const ignoredFiles = await getIgnoredFiles(git, untrackedPaths);
149
+ for (const file of status.files) {
150
+ // Skip ignored files (marked with '!' in either column, or detected by check-ignore)
151
+ if (file.index === '!' || file.working_dir === '!' || ignoredFiles.has(file.path)) {
152
+ continue;
153
+ }
154
+ const key = `${file.path}-${file.index !== ' ' && file.index !== '?'}`;
155
+ if (seen.has(key))
156
+ continue;
157
+ seen.add(key);
158
+ // Staged changes (index column)
159
+ if (file.index && file.index !== ' ' && file.index !== '?') {
160
+ processedFiles.push({
161
+ path: file.path,
162
+ status: parseStatusCode(file.index),
163
+ staged: true,
164
+ });
165
+ }
166
+ // Unstaged changes (working_dir column)
167
+ if (file.working_dir && file.working_dir !== ' ') {
168
+ processedFiles.push({
169
+ path: file.path,
170
+ status: file.working_dir === '?' ? 'untracked' : parseStatusCode(file.working_dir),
171
+ staged: false,
172
+ });
173
+ }
174
+ }
175
+ // Fetch line stats for staged and unstaged files
176
+ const [stagedNumstat, unstagedNumstat] = await Promise.all([
177
+ git.diff(['--cached', '--numstat']).catch(() => ''),
178
+ git.diff(['--numstat']).catch(() => ''),
179
+ ]);
180
+ const stagedStats = parseNumstat(stagedNumstat);
181
+ const unstagedStats = parseNumstat(unstagedNumstat);
182
+ // Apply stats to files
183
+ for (const file of processedFiles) {
184
+ const stats = file.staged ? stagedStats.get(file.path) : unstagedStats.get(file.path);
185
+ if (stats) {
186
+ file.insertions = stats.insertions;
187
+ file.deletions = stats.deletions;
188
+ }
189
+ }
190
+ // Count lines for untracked files (not in numstat output)
191
+ const untrackedFiles = processedFiles.filter((f) => f.status === 'untracked');
192
+ if (untrackedFiles.length > 0) {
193
+ const lineCounts = await Promise.all(untrackedFiles.map((f) => countFileLines(repoPath, f.path)));
194
+ for (let i = 0; i < untrackedFiles.length; i++) {
195
+ untrackedFiles[i].insertions = lineCounts[i];
196
+ untrackedFiles[i].deletions = 0;
197
+ }
198
+ }
199
+ return {
200
+ files: processedFiles,
201
+ branch: {
202
+ current: status.current || 'HEAD',
203
+ tracking: status.tracking || undefined,
204
+ ahead: status.ahead,
205
+ behind: status.behind,
206
+ },
207
+ isRepo: true,
208
+ };
209
+ }
210
+ catch {
211
+ return {
212
+ files: [],
213
+ branch: { current: '', ahead: 0, behind: 0 },
214
+ isRepo: false,
215
+ };
216
+ }
217
+ }
218
+ export async function stageFile(repoPath, filePath) {
219
+ const git = simpleGit(repoPath);
220
+ await git.add(filePath);
221
+ }
222
+ export async function unstageFile(repoPath, filePath) {
223
+ const git = simpleGit(repoPath);
224
+ await git.reset(['HEAD', '--', filePath]);
225
+ }
226
+ export async function stageAll(repoPath) {
227
+ const git = simpleGit(repoPath);
228
+ await git.add('-A');
229
+ }
230
+ export async function unstageAll(repoPath) {
231
+ const git = simpleGit(repoPath);
232
+ await git.reset(['HEAD']);
233
+ }
234
+ export async function discardChanges(repoPath, filePath) {
235
+ const git = simpleGit(repoPath);
236
+ // Restore the file to its state in HEAD (discard working directory changes)
237
+ await git.checkout(['--', filePath]);
238
+ }
239
+ export async function commit(repoPath, message, amend = false) {
240
+ const git = simpleGit(repoPath);
241
+ await git.commit(message, undefined, amend ? { '--amend': null } : undefined);
242
+ }
243
+ export async function getHeadMessage(repoPath) {
244
+ const git = simpleGit(repoPath);
245
+ try {
246
+ const log = await git.log({ n: 1 });
247
+ return log.latest?.message || '';
248
+ }
249
+ catch {
250
+ return '';
251
+ }
252
+ }
253
+ export async function getCommitHistory(repoPath, count = 50) {
254
+ const git = simpleGit(repoPath);
255
+ try {
256
+ const log = await git.log({ n: count });
257
+ return log.all.map((entry) => ({
258
+ hash: entry.hash,
259
+ shortHash: entry.hash.slice(0, 7),
260
+ message: entry.message.split('\n')[0], // First line only
261
+ author: entry.author_name,
262
+ date: new Date(entry.date),
263
+ refs: entry.refs || '',
264
+ }));
265
+ }
266
+ catch {
267
+ return [];
268
+ }
269
+ }
@@ -1 +1,66 @@
1
- import{useState as o,useCallback as i,useEffect as M}from"react";import{validateCommit as S,formatCommitMessage as b}from"../services/commitService.js";export function useCommitFlow(C){const{stagedCount:l,onCommit:m,onSuccess:c,getHeadMessage:f}=C,[s,n]=o(""),[t,r]=o(!1),[p,u]=o(!1),[h,a]=o(null),[v,d]=o(!1);M(()=>{t&&f().then(e=>{e&&!s&&n(e)})},[t,f]);const y=i(()=>{r(e=>!e)},[]),E=i(async()=>{const e=S(s,l,t);if(!e.valid){a(e.error);return}u(!0),a(null);try{await m(b(s),t),n(""),r(!1),c()}catch(g){a(g instanceof Error?g.message:"Commit failed")}finally{u(!1)}},[s,l,t,m,c]),F=i(()=>{n(""),r(!1),a(null),d(!1)},[]);return{message:s,amend:t,isCommitting:p,error:h,inputFocused:v,setMessage:n,toggleAmend:y,setInputFocused:d,handleSubmit:E,reset:F}}
1
+ import { useState, useCallback, useEffect } from 'react';
2
+ import { validateCommit, formatCommitMessage } from '../services/commitService.js';
3
+ /**
4
+ * Hook that manages the commit flow state and logic.
5
+ * Extracted from CommitPanel to separate concerns.
6
+ */
7
+ export function useCommitFlow(options) {
8
+ const { stagedCount, onCommit, onSuccess, getHeadMessage } = options;
9
+ const [message, setMessage] = useState('');
10
+ const [amend, setAmend] = useState(false);
11
+ const [isCommitting, setIsCommitting] = useState(false);
12
+ const [error, setError] = useState(null);
13
+ const [inputFocused, setInputFocused] = useState(false);
14
+ // Load HEAD message when amend is toggled
15
+ useEffect(() => {
16
+ if (amend) {
17
+ getHeadMessage().then((msg) => {
18
+ if (msg && !message) {
19
+ setMessage(msg);
20
+ }
21
+ });
22
+ }
23
+ }, [amend, getHeadMessage]);
24
+ const toggleAmend = useCallback(() => {
25
+ setAmend((prev) => !prev);
26
+ }, []);
27
+ const handleSubmit = useCallback(async () => {
28
+ const validation = validateCommit(message, stagedCount, amend);
29
+ if (!validation.valid) {
30
+ setError(validation.error);
31
+ return;
32
+ }
33
+ setIsCommitting(true);
34
+ setError(null);
35
+ try {
36
+ await onCommit(formatCommitMessage(message), amend);
37
+ setMessage('');
38
+ setAmend(false);
39
+ onSuccess();
40
+ }
41
+ catch (err) {
42
+ setError(err instanceof Error ? err.message : 'Commit failed');
43
+ }
44
+ finally {
45
+ setIsCommitting(false);
46
+ }
47
+ }, [message, stagedCount, amend, onCommit, onSuccess]);
48
+ const reset = useCallback(() => {
49
+ setMessage('');
50
+ setAmend(false);
51
+ setError(null);
52
+ setInputFocused(false);
53
+ }, []);
54
+ return {
55
+ message,
56
+ amend,
57
+ isCommitting,
58
+ error,
59
+ inputFocused,
60
+ setMessage,
61
+ toggleAmend,
62
+ setInputFocused,
63
+ handleSubmit,
64
+ reset,
65
+ };
66
+ }
@@ -1 +1,123 @@
1
- import{useState as r,useEffect as g,useCallback as o,useMemo as b,useRef as X}from"react";import{getCompareItemIndexFromRow as Y,getFileScrollOffset as Z,getCompareDiffTotalRows as _}from"../utils/rowCalculations.js";export function useCompareState({repoPath:a,isActive:e,compareDiff:n,refreshCompareDiff:k,getCandidateBaseBranches:R,setCompareBaseBranch:C,selectCompareCommit:T,topPaneHeight:U,compareScrollOffset:c,setCompareScrollOffset:m,setDiffScrollOffset:l,status:F}){const[d,M]=r(!0),[y,h]=r(null),[u,x]=r(0),i=X(!1),[z,E]=r([]),[L,w]=r(!1);g(()=>{a&&e&&k(d)},[a,e,F,k,d]),g(()=>{a&&e&&R().then(E)},[a,e,R]),g(()=>{e&&(i.current=!1,h(null),l(0))},[e,l]),g(()=>{if(e&&n&&i.current){const t=n.commits.length,s=n.files.length;if(u<t)h({type:"commit",index:u}),T(u),l(0);else if(u<t+s){const I=u-t;h({type:"file",index:I});const W=Z(n,I);l(W)}}},[e,n,u,T,l]);const B=b(()=>n?n.commits.length+n.files.length:0,[n]),j=b(()=>_(n),[n]),q=o(()=>{M(t=>!t)},[]),G=o(()=>{w(!0)},[]),J=o(()=>{w(!1)},[]),K=o(t=>{w(!1),C(t,d)},[C,d]),N=o(()=>{i.current=!0},[]),P=o(()=>{i.current=!0,x(t=>{const s=Math.max(0,t-1);return s<c&&m(s),s})},[c,m]),Q=o(()=>{i.current=!0,x(t=>{const s=Math.min(B-1,t+1),I=c+U-2;return s>=I&&m(c+1),s})},[B,c,U,m]),V=o(t=>n?Y(t,n.commits.length,n.files.length):-1,[n]);return{includeUncommitted:d,compareListSelection:y,compareSelectedIndex:u,baseBranchCandidates:z,showBaseBranchPicker:L,compareTotalItems:B,compareDiffTotalRows:j,setCompareSelectedIndex:x,toggleIncludeUncommitted:q,openBaseBranchPicker:G,closeBaseBranchPicker:J,selectBaseBranch:K,navigateCompareUp:P,navigateCompareDown:Q,markSelectionInitialized:N,getItemIndexFromRow:V}}
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
2
+ import { getCompareItemIndexFromRow, getFileScrollOffset } from '../utils/rowCalculations.js';
3
+ import { buildCompareDisplayRows, getDisplayRowsLineNumWidth, getWrappedRowCount, } from '../utils/displayRows.js';
4
+ export function useCompareState({ repoPath, isActive, compareDiff, refreshCompareDiff, getCandidateBaseBranches, setCompareBaseBranch, selectCompareCommit, topPaneHeight, compareScrollOffset, setCompareScrollOffset, setDiffScrollOffset, status, wrapMode, terminalWidth, }) {
5
+ const [includeUncommitted, setIncludeUncommitted] = useState(true);
6
+ const [compareListSelection, setCompareListSelection] = useState(null);
7
+ const [compareSelectedIndex, setCompareSelectedIndex] = useState(0);
8
+ const compareSelectionInitialized = useRef(false);
9
+ const [baseBranchCandidates, setBaseBranchCandidates] = useState([]);
10
+ const [showBaseBranchPicker, setShowBaseBranchPicker] = useState(false);
11
+ // Fetch compare diff when tab becomes active
12
+ useEffect(() => {
13
+ if (repoPath && isActive) {
14
+ refreshCompareDiff(includeUncommitted);
15
+ }
16
+ }, [repoPath, isActive, status, refreshCompareDiff, includeUncommitted]);
17
+ // Fetch base branch candidates when entering compare view
18
+ useEffect(() => {
19
+ if (repoPath && isActive) {
20
+ getCandidateBaseBranches().then(setBaseBranchCandidates);
21
+ }
22
+ }, [repoPath, isActive, getCandidateBaseBranches]);
23
+ // Reset compare selection state when entering compare tab
24
+ useEffect(() => {
25
+ if (isActive) {
26
+ compareSelectionInitialized.current = false;
27
+ setCompareListSelection(null);
28
+ setDiffScrollOffset(0);
29
+ }
30
+ }, [isActive, setDiffScrollOffset]);
31
+ // Update compare selection when compareSelectedIndex changes (only after user interaction)
32
+ useEffect(() => {
33
+ if (isActive && compareDiff && compareSelectionInitialized.current) {
34
+ const commitCount = compareDiff.commits.length;
35
+ const fileCount = compareDiff.files.length;
36
+ if (compareSelectedIndex < commitCount) {
37
+ setCompareListSelection({ type: 'commit', index: compareSelectedIndex });
38
+ selectCompareCommit(compareSelectedIndex);
39
+ setDiffScrollOffset(0);
40
+ }
41
+ else if (compareSelectedIndex < commitCount + fileCount) {
42
+ const fileIndex = compareSelectedIndex - commitCount;
43
+ setCompareListSelection({ type: 'file', index: fileIndex });
44
+ const scrollTo = getFileScrollOffset(compareDiff, fileIndex);
45
+ setDiffScrollOffset(scrollTo);
46
+ }
47
+ }
48
+ }, [isActive, compareDiff, compareSelectedIndex, selectCompareCommit, setDiffScrollOffset]);
49
+ // Computed values
50
+ const compareTotalItems = useMemo(() => {
51
+ if (!compareDiff)
52
+ return 0;
53
+ return compareDiff.commits.length + compareDiff.files.length;
54
+ }, [compareDiff]);
55
+ // When wrap mode is enabled, account for wrapped lines
56
+ const compareDiffTotalRows = useMemo(() => {
57
+ const displayRows = buildCompareDisplayRows(compareDiff);
58
+ if (!wrapMode)
59
+ return displayRows.length;
60
+ const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
61
+ const contentWidth = terminalWidth - lineNumWidth - 5;
62
+ return getWrappedRowCount(displayRows, contentWidth, true);
63
+ }, [compareDiff, wrapMode, terminalWidth]);
64
+ // Handlers
65
+ const toggleIncludeUncommitted = useCallback(() => {
66
+ setIncludeUncommitted((prev) => !prev);
67
+ }, []);
68
+ const openBaseBranchPicker = useCallback(() => {
69
+ setShowBaseBranchPicker(true);
70
+ }, []);
71
+ const closeBaseBranchPicker = useCallback(() => {
72
+ setShowBaseBranchPicker(false);
73
+ }, []);
74
+ const selectBaseBranch = useCallback((branch) => {
75
+ setShowBaseBranchPicker(false);
76
+ setCompareBaseBranch(branch, includeUncommitted);
77
+ }, [setCompareBaseBranch, includeUncommitted]);
78
+ const markSelectionInitialized = useCallback(() => {
79
+ compareSelectionInitialized.current = true;
80
+ }, []);
81
+ const navigateCompareUp = useCallback(() => {
82
+ compareSelectionInitialized.current = true;
83
+ setCompareSelectedIndex((prev) => {
84
+ const newIndex = Math.max(0, prev - 1);
85
+ if (newIndex < compareScrollOffset)
86
+ setCompareScrollOffset(newIndex);
87
+ return newIndex;
88
+ });
89
+ }, [compareScrollOffset, setCompareScrollOffset]);
90
+ const navigateCompareDown = useCallback(() => {
91
+ compareSelectionInitialized.current = true;
92
+ setCompareSelectedIndex((prev) => {
93
+ const newIndex = Math.min(compareTotalItems - 1, prev + 1);
94
+ const visibleEnd = compareScrollOffset + topPaneHeight - 2;
95
+ if (newIndex >= visibleEnd)
96
+ setCompareScrollOffset(compareScrollOffset + 1);
97
+ return newIndex;
98
+ });
99
+ }, [compareTotalItems, compareScrollOffset, topPaneHeight, setCompareScrollOffset]);
100
+ const getItemIndexFromRow = useCallback((visualRow) => {
101
+ if (!compareDiff)
102
+ return -1;
103
+ return getCompareItemIndexFromRow(visualRow, compareDiff.commits.length, compareDiff.files.length);
104
+ }, [compareDiff]);
105
+ return {
106
+ includeUncommitted,
107
+ compareListSelection,
108
+ compareSelectedIndex,
109
+ baseBranchCandidates,
110
+ showBaseBranchPicker,
111
+ compareTotalItems,
112
+ compareDiffTotalRows,
113
+ setCompareSelectedIndex,
114
+ toggleIncludeUncommitted,
115
+ openBaseBranchPicker,
116
+ closeBaseBranchPicker,
117
+ selectBaseBranch,
118
+ navigateCompareUp,
119
+ navigateCompareDown,
120
+ markSelectionInitialized,
121
+ getItemIndexFromRow,
122
+ };
123
+ }