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.
- package/.github/workflows/release.yml +5 -3
- package/bun.lock +618 -0
- package/dist/App.js +541 -1
- package/dist/components/BaseBranchPicker.js +60 -1
- package/dist/components/BottomPane.js +101 -1
- package/dist/components/CommitPanel.js +58 -1
- package/dist/components/CompareListView.js +110 -1
- package/dist/components/ExplorerContentView.js +80 -0
- package/dist/components/ExplorerView.js +37 -0
- package/dist/components/FileList.js +131 -1
- package/dist/components/Footer.js +6 -1
- package/dist/components/Header.js +107 -1
- package/dist/components/HistoryView.js +21 -1
- package/dist/components/HotkeysModal.js +108 -1
- package/dist/components/Modal.js +19 -1
- package/dist/components/ScrollableList.js +125 -1
- package/dist/components/ThemePicker.js +42 -1
- package/dist/components/TopPane.js +14 -1
- package/dist/components/UnifiedDiffView.js +115 -0
- package/dist/config.js +83 -2
- package/dist/core/GitOperationQueue.js +109 -1
- package/dist/core/GitStateManager.js +466 -1
- package/dist/git/diff.js +471 -10
- package/dist/git/status.js +269 -5
- package/dist/hooks/useCommitFlow.js +66 -1
- package/dist/hooks/useCompareState.js +123 -1
- package/dist/hooks/useExplorerState.js +248 -0
- package/dist/hooks/useGit.js +156 -1
- package/dist/hooks/useHistoryState.js +62 -1
- package/dist/hooks/useKeymap.js +167 -1
- package/dist/hooks/useLayout.js +154 -1
- package/dist/hooks/useMouse.js +87 -1
- package/dist/hooks/useTerminalSize.js +20 -1
- package/dist/hooks/useWatcher.js +137 -11
- package/dist/index.js +43 -3
- package/dist/services/commitService.js +22 -1
- package/dist/themes.js +127 -1
- package/dist/utils/ansiTruncate.js +108 -0
- package/dist/utils/baseBranchCache.js +44 -2
- package/dist/utils/commitFormat.js +38 -1
- package/dist/utils/diffFilters.js +21 -1
- package/dist/utils/diffRowCalculations.js +113 -0
- package/dist/utils/displayRows.js +172 -0
- package/dist/utils/explorerDisplayRows.js +169 -0
- package/dist/utils/fileCategories.js +26 -1
- package/dist/utils/formatDate.js +39 -1
- package/dist/utils/formatPath.js +58 -1
- package/dist/utils/languageDetection.js +180 -0
- package/dist/utils/layoutCalculations.js +98 -1
- package/dist/utils/lineBreaking.js +88 -0
- package/dist/utils/mouseCoordinates.js +165 -1
- package/dist/utils/rowCalculations.js +209 -3
- package/package.json +7 -10
- package/dist/components/CompareView.js +0 -1
- package/dist/components/DiffView.js +0 -1
- package/dist/components/HistoryDiffView.js +0 -1
package/dist/git/status.js
CHANGED
|
@@ -1,5 +1,269 @@
|
|
|
1
|
-
import{simpleGit
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|