diffstalker 0.1.6 → 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 -3
- package/dist/components/ExplorerView.js +37 -1
- 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 -1
- 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 -9
- 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 -1
- package/dist/utils/displayRows.js +172 -2
- 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 -5
- package/dist/utils/mouseCoordinates.js +165 -1
- package/dist/utils/rowCalculations.js +209 -4
- package/package.json +7 -10
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language detection and syntax highlighting utilities for file content.
|
|
3
|
+
* Uses the emphasize package for ANSI terminal colors.
|
|
4
|
+
*/
|
|
5
|
+
import { createEmphasize } from 'emphasize';
|
|
6
|
+
import { common } from 'lowlight';
|
|
7
|
+
// Create emphasize instance with common languages
|
|
8
|
+
const emphasize = createEmphasize(common);
|
|
9
|
+
// Map file extensions to highlight.js language names
|
|
10
|
+
const EXTENSION_TO_LANGUAGE = {
|
|
11
|
+
// TypeScript/JavaScript
|
|
12
|
+
ts: 'typescript',
|
|
13
|
+
tsx: 'typescript',
|
|
14
|
+
js: 'javascript',
|
|
15
|
+
jsx: 'javascript',
|
|
16
|
+
mjs: 'javascript',
|
|
17
|
+
cjs: 'javascript',
|
|
18
|
+
// Web
|
|
19
|
+
html: 'xml',
|
|
20
|
+
htm: 'xml',
|
|
21
|
+
xml: 'xml',
|
|
22
|
+
svg: 'xml',
|
|
23
|
+
css: 'css',
|
|
24
|
+
scss: 'scss',
|
|
25
|
+
sass: 'scss',
|
|
26
|
+
less: 'less',
|
|
27
|
+
// Data formats
|
|
28
|
+
json: 'json',
|
|
29
|
+
yaml: 'yaml',
|
|
30
|
+
yml: 'yaml',
|
|
31
|
+
toml: 'ini',
|
|
32
|
+
// Shell/Config
|
|
33
|
+
sh: 'bash',
|
|
34
|
+
bash: 'bash',
|
|
35
|
+
zsh: 'bash',
|
|
36
|
+
fish: 'bash',
|
|
37
|
+
ps1: 'powershell',
|
|
38
|
+
bat: 'dos',
|
|
39
|
+
cmd: 'dos',
|
|
40
|
+
// Systems languages
|
|
41
|
+
c: 'c',
|
|
42
|
+
h: 'c',
|
|
43
|
+
cpp: 'cpp',
|
|
44
|
+
hpp: 'cpp',
|
|
45
|
+
cc: 'cpp',
|
|
46
|
+
cxx: 'cpp',
|
|
47
|
+
rs: 'rust',
|
|
48
|
+
go: 'go',
|
|
49
|
+
zig: 'zig',
|
|
50
|
+
// JVM
|
|
51
|
+
java: 'java',
|
|
52
|
+
kt: 'kotlin',
|
|
53
|
+
kts: 'kotlin',
|
|
54
|
+
scala: 'scala',
|
|
55
|
+
groovy: 'groovy',
|
|
56
|
+
gradle: 'groovy',
|
|
57
|
+
// Scripting
|
|
58
|
+
py: 'python',
|
|
59
|
+
rb: 'ruby',
|
|
60
|
+
pl: 'perl',
|
|
61
|
+
lua: 'lua',
|
|
62
|
+
php: 'php',
|
|
63
|
+
r: 'r',
|
|
64
|
+
// Functional
|
|
65
|
+
hs: 'haskell',
|
|
66
|
+
ml: 'ocaml',
|
|
67
|
+
fs: 'fsharp',
|
|
68
|
+
fsx: 'fsharp',
|
|
69
|
+
ex: 'elixir',
|
|
70
|
+
exs: 'elixir',
|
|
71
|
+
erl: 'erlang',
|
|
72
|
+
clj: 'clojure',
|
|
73
|
+
cljs: 'clojure',
|
|
74
|
+
// .NET
|
|
75
|
+
cs: 'csharp',
|
|
76
|
+
vb: 'vbnet',
|
|
77
|
+
// Documentation
|
|
78
|
+
md: 'markdown',
|
|
79
|
+
markdown: 'markdown',
|
|
80
|
+
rst: 'plaintext',
|
|
81
|
+
txt: 'plaintext',
|
|
82
|
+
// Config/Build
|
|
83
|
+
Makefile: 'makefile',
|
|
84
|
+
Dockerfile: 'dockerfile',
|
|
85
|
+
cmake: 'cmake',
|
|
86
|
+
ini: 'ini',
|
|
87
|
+
conf: 'ini',
|
|
88
|
+
cfg: 'ini',
|
|
89
|
+
// SQL
|
|
90
|
+
sql: 'sql',
|
|
91
|
+
// Other
|
|
92
|
+
vim: 'vim',
|
|
93
|
+
diff: 'diff',
|
|
94
|
+
patch: 'diff',
|
|
95
|
+
};
|
|
96
|
+
// Special filenames that map to languages
|
|
97
|
+
const FILENAME_TO_LANGUAGE = {
|
|
98
|
+
Makefile: 'makefile',
|
|
99
|
+
makefile: 'makefile',
|
|
100
|
+
GNUmakefile: 'makefile',
|
|
101
|
+
Dockerfile: 'dockerfile',
|
|
102
|
+
dockerfile: 'dockerfile',
|
|
103
|
+
Jenkinsfile: 'groovy',
|
|
104
|
+
Vagrantfile: 'ruby',
|
|
105
|
+
Gemfile: 'ruby',
|
|
106
|
+
Rakefile: 'ruby',
|
|
107
|
+
'.gitignore': 'plaintext',
|
|
108
|
+
'.gitattributes': 'plaintext',
|
|
109
|
+
'.editorconfig': 'ini',
|
|
110
|
+
'.prettierrc': 'json',
|
|
111
|
+
'.eslintrc': 'json',
|
|
112
|
+
'tsconfig.json': 'json',
|
|
113
|
+
'package.json': 'json',
|
|
114
|
+
'package-lock.json': 'json',
|
|
115
|
+
'bun.lockb': 'plaintext',
|
|
116
|
+
'yarn.lock': 'yaml',
|
|
117
|
+
'pnpm-lock.yaml': 'yaml',
|
|
118
|
+
'Cargo.toml': 'ini',
|
|
119
|
+
'Cargo.lock': 'ini',
|
|
120
|
+
'go.mod': 'go',
|
|
121
|
+
'go.sum': 'plaintext',
|
|
122
|
+
};
|
|
123
|
+
// Cache of available languages
|
|
124
|
+
let availableLanguages = null;
|
|
125
|
+
function getAvailableLanguages() {
|
|
126
|
+
if (!availableLanguages) {
|
|
127
|
+
availableLanguages = new Set(emphasize.listLanguages());
|
|
128
|
+
}
|
|
129
|
+
return availableLanguages;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get the highlight.js language name from a file path.
|
|
133
|
+
* Returns null if the language cannot be determined or is not supported.
|
|
134
|
+
*/
|
|
135
|
+
export function getLanguageFromPath(filePath) {
|
|
136
|
+
if (!filePath)
|
|
137
|
+
return null;
|
|
138
|
+
// Check special filenames first
|
|
139
|
+
const filename = filePath.split('/').pop() ?? '';
|
|
140
|
+
if (FILENAME_TO_LANGUAGE[filename]) {
|
|
141
|
+
const lang = FILENAME_TO_LANGUAGE[filename];
|
|
142
|
+
return getAvailableLanguages().has(lang) ? lang : null;
|
|
143
|
+
}
|
|
144
|
+
// Get extension
|
|
145
|
+
const ext = filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : null;
|
|
146
|
+
if (!ext)
|
|
147
|
+
return null;
|
|
148
|
+
const lang = EXTENSION_TO_LANGUAGE[ext];
|
|
149
|
+
if (!lang)
|
|
150
|
+
return null;
|
|
151
|
+
// Verify language is available
|
|
152
|
+
return getAvailableLanguages().has(lang) ? lang : null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Apply syntax highlighting to a line of code.
|
|
156
|
+
* Returns the highlighted string with ANSI escape codes.
|
|
157
|
+
* If highlighting fails, returns the original content.
|
|
158
|
+
*/
|
|
159
|
+
export function highlightLine(content, language) {
|
|
160
|
+
if (!content || !language)
|
|
161
|
+
return content;
|
|
162
|
+
try {
|
|
163
|
+
const result = emphasize.highlight(language, content);
|
|
164
|
+
return result.value;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// If highlighting fails, return original content
|
|
168
|
+
return content;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Apply syntax highlighting to multiple lines.
|
|
173
|
+
* More efficient than calling highlightLine for each line
|
|
174
|
+
* as it reuses the language detection.
|
|
175
|
+
*/
|
|
176
|
+
export function highlightLines(lines, language) {
|
|
177
|
+
if (!language || lines.length === 0)
|
|
178
|
+
return lines;
|
|
179
|
+
return lines.map((line) => highlightLine(line, language));
|
|
180
|
+
}
|
|
@@ -1 +1,98 @@
|
|
|
1
|
-
import{getFileListSectionCounts
|
|
1
|
+
import { getFileListSectionCounts } from './fileCategories.js';
|
|
2
|
+
// Re-export for backwards compatibility
|
|
3
|
+
export { getFileListSectionCounts } from './fileCategories.js';
|
|
4
|
+
/**
|
|
5
|
+
* Calculate total rows for the FileList component.
|
|
6
|
+
* Accounts for headers and spacers between sections.
|
|
7
|
+
*/
|
|
8
|
+
export function getFileListTotalRows(files) {
|
|
9
|
+
const { modifiedCount, untrackedCount, stagedCount } = getFileListSectionCounts(files);
|
|
10
|
+
let rows = 0;
|
|
11
|
+
// Modified section
|
|
12
|
+
if (modifiedCount > 0) {
|
|
13
|
+
rows += 1 + modifiedCount; // header + files
|
|
14
|
+
}
|
|
15
|
+
// Untracked section
|
|
16
|
+
if (untrackedCount > 0) {
|
|
17
|
+
if (modifiedCount > 0)
|
|
18
|
+
rows += 1; // spacer
|
|
19
|
+
rows += 1 + untrackedCount; // header + files
|
|
20
|
+
}
|
|
21
|
+
// Staged section
|
|
22
|
+
if (stagedCount > 0) {
|
|
23
|
+
if (modifiedCount > 0 || untrackedCount > 0)
|
|
24
|
+
rows += 1; // spacer
|
|
25
|
+
rows += 1 + stagedCount; // header + files
|
|
26
|
+
}
|
|
27
|
+
return rows;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Calculate the heights of the top (file list) and bottom (diff/commit/etc) panes
|
|
31
|
+
* based on the number of files and available content area.
|
|
32
|
+
*
|
|
33
|
+
* The top pane grows to fit files up to 40% of content height.
|
|
34
|
+
* The bottom pane gets the remaining space.
|
|
35
|
+
*/
|
|
36
|
+
export function calculatePaneHeights(files, contentHeight, maxTopRatio = 0.4) {
|
|
37
|
+
// Calculate content rows needed for staging area
|
|
38
|
+
// Uses getFileListTotalRows for consistency with FileList rendering
|
|
39
|
+
const neededRows = getFileListTotalRows(files);
|
|
40
|
+
// Minimum height of 3 (header + 2 lines for empty state)
|
|
41
|
+
const minHeight = 3;
|
|
42
|
+
// Maximum is maxTopRatio of content area
|
|
43
|
+
const maxHeight = Math.floor(contentHeight * maxTopRatio);
|
|
44
|
+
// Use the smaller of needed or max, but at least min
|
|
45
|
+
const topHeight = Math.max(minHeight, Math.min(neededRows, maxHeight));
|
|
46
|
+
const bottomHeight = contentHeight - topHeight;
|
|
47
|
+
return { topPaneHeight: topHeight, bottomPaneHeight: bottomHeight };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Calculate which row in the file list a file at a given index occupies.
|
|
51
|
+
* This accounts for headers and spacers in the list.
|
|
52
|
+
* File order: Modified → Untracked → Staged (matches FileList.tsx)
|
|
53
|
+
*/
|
|
54
|
+
export function getRowForFileIndex(selectedIndex, modifiedCount, untrackedCount, _stagedCount) {
|
|
55
|
+
let row = 0;
|
|
56
|
+
// Modified section
|
|
57
|
+
if (selectedIndex < modifiedCount) {
|
|
58
|
+
// In modified section: header + file rows
|
|
59
|
+
return 1 + selectedIndex;
|
|
60
|
+
}
|
|
61
|
+
if (modifiedCount > 0) {
|
|
62
|
+
row += 1 + modifiedCount; // header + files
|
|
63
|
+
}
|
|
64
|
+
// Untracked section
|
|
65
|
+
const untrackedStart = modifiedCount;
|
|
66
|
+
if (selectedIndex < untrackedStart + untrackedCount) {
|
|
67
|
+
// In untracked section
|
|
68
|
+
const untrackedIdx = selectedIndex - untrackedStart;
|
|
69
|
+
if (modifiedCount > 0)
|
|
70
|
+
row += 1; // spacer
|
|
71
|
+
return row + 1 + untrackedIdx; // header + file position
|
|
72
|
+
}
|
|
73
|
+
if (untrackedCount > 0) {
|
|
74
|
+
if (modifiedCount > 0)
|
|
75
|
+
row += 1; // spacer
|
|
76
|
+
row += 1 + untrackedCount; // header + files
|
|
77
|
+
}
|
|
78
|
+
// Staged section
|
|
79
|
+
const stagedStart = modifiedCount + untrackedCount;
|
|
80
|
+
const stagedIdx = selectedIndex - stagedStart;
|
|
81
|
+
if (modifiedCount > 0 || untrackedCount > 0)
|
|
82
|
+
row += 1; // spacer
|
|
83
|
+
return row + 1 + stagedIdx; // header + file position
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Calculate the scroll offset needed to keep a selected row visible.
|
|
87
|
+
*/
|
|
88
|
+
export function calculateScrollOffset(selectedRow, currentScrollOffset, visibleHeight) {
|
|
89
|
+
// Scroll up if selected is above visible area
|
|
90
|
+
if (selectedRow < currentScrollOffset) {
|
|
91
|
+
return Math.max(0, selectedRow - 1);
|
|
92
|
+
}
|
|
93
|
+
// Scroll down if selected is below visible area
|
|
94
|
+
else if (selectedRow >= currentScrollOffset + visibleHeight) {
|
|
95
|
+
return selectedRow - visibleHeight + 1;
|
|
96
|
+
}
|
|
97
|
+
return currentScrollOffset;
|
|
98
|
+
}
|
|
@@ -1,5 +1,88 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for manually breaking long lines at exact character boundaries.
|
|
3
|
+
* This gives us full control over line wrapping behavior in the diff view.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Break a string into segments that fit within the given width.
|
|
7
|
+
* Breaks at exact character boundaries for predictable, consistent output.
|
|
8
|
+
*
|
|
9
|
+
* @param content - The string to break
|
|
10
|
+
* @param maxWidth - Maximum width for each segment
|
|
11
|
+
* @param validate - If true, validates the result and throws on errors (default: true)
|
|
12
|
+
* @returns Array of line segments
|
|
13
|
+
*/
|
|
14
|
+
export function breakLine(content, maxWidth, validate = true) {
|
|
15
|
+
if (maxWidth <= 0) {
|
|
16
|
+
return [{ text: content, isContinuation: false }];
|
|
17
|
+
}
|
|
18
|
+
if (content.length <= maxWidth) {
|
|
19
|
+
return [{ text: content, isContinuation: false }];
|
|
20
|
+
}
|
|
21
|
+
const segments = [];
|
|
22
|
+
let remaining = content;
|
|
23
|
+
let isFirst = true;
|
|
24
|
+
while (remaining.length > 0) {
|
|
25
|
+
if (remaining.length <= maxWidth) {
|
|
26
|
+
segments.push({ text: remaining, isContinuation: !isFirst });
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
segments.push({
|
|
30
|
+
text: remaining.slice(0, maxWidth),
|
|
31
|
+
isContinuation: !isFirst,
|
|
32
|
+
});
|
|
33
|
+
remaining = remaining.slice(maxWidth);
|
|
34
|
+
isFirst = false;
|
|
35
|
+
}
|
|
36
|
+
// Validate the result
|
|
37
|
+
if (validate) {
|
|
38
|
+
validateBreakResult(content, maxWidth, segments);
|
|
39
|
+
}
|
|
40
|
+
return segments;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate that line breaking produced correct results.
|
|
44
|
+
* Throws an error if validation fails, making issues visible during development.
|
|
45
|
+
*/
|
|
46
|
+
function validateBreakResult(original, maxWidth, segments) {
|
|
47
|
+
// Check 1: Segments should join to equal original
|
|
48
|
+
const joined = segments.map((s) => s.text).join('');
|
|
49
|
+
if (joined !== original) {
|
|
50
|
+
throw new Error(`[LineBreaking] Content was lost during breaking!\n` +
|
|
51
|
+
`Original (${original.length} chars): "${original.slice(0, 50)}${original.length > 50 ? '...' : ''}"\n` +
|
|
52
|
+
`Joined (${joined.length} chars): "${joined.slice(0, 50)}${joined.length > 50 ? '...' : ''}"`);
|
|
53
|
+
}
|
|
54
|
+
// Check 2: No segment should exceed maxWidth (except if maxWidth is too small)
|
|
55
|
+
for (let i = 0; i < segments.length; i++) {
|
|
56
|
+
const segment = segments[i];
|
|
57
|
+
if (segment.text.length > maxWidth && maxWidth >= 1) {
|
|
58
|
+
throw new Error(`[LineBreaking] Segment ${i} exceeds maxWidth!\n` +
|
|
59
|
+
`Segment length: ${segment.text.length}, maxWidth: ${maxWidth}\n` +
|
|
60
|
+
`Segment: "${segment.text.slice(0, 50)}${segment.text.length > 50 ? '...' : ''}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Check 3: First segment should not be marked as continuation
|
|
64
|
+
if (segments.length > 0 && segments[0].isContinuation) {
|
|
65
|
+
throw new Error(`[LineBreaking] First segment incorrectly marked as continuation!`);
|
|
66
|
+
}
|
|
67
|
+
// Check 4: Subsequent segments should be marked as continuation
|
|
68
|
+
for (let i = 1; i < segments.length; i++) {
|
|
69
|
+
if (!segments[i].isContinuation) {
|
|
70
|
+
throw new Error(`[LineBreaking] Segment ${i} should be marked as continuation but isn't!`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Calculate how many visual rows a content string will take when broken.
|
|
76
|
+
*
|
|
77
|
+
* @param content - The string content
|
|
78
|
+
* @param maxWidth - Maximum width per row
|
|
79
|
+
* @returns Number of rows needed
|
|
80
|
+
*/
|
|
81
|
+
export function getLineRowCount(content, maxWidth) {
|
|
82
|
+
if (maxWidth <= 0)
|
|
83
|
+
return 1;
|
|
84
|
+
if (content.length <= maxWidth)
|
|
85
|
+
return 1;
|
|
86
|
+
// Simple math since we break at exact boundaries
|
|
87
|
+
return Math.ceil(content.length / maxWidth);
|
|
88
|
+
}
|
|
@@ -1 +1,165 @@
|
|
|
1
|
-
import{categorizeFiles
|
|
1
|
+
import { categorizeFiles } from './fileCategories.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calculate the row boundaries for each pane in the layout.
|
|
4
|
+
* Layout: Header (headerHeight) + sep (1) + top pane + sep (1) + bottom pane + sep (1) + footer (1)
|
|
5
|
+
*/
|
|
6
|
+
export function calculatePaneBoundaries(topPaneHeight, bottomPaneHeight, terminalHeight, headerHeight = 1) {
|
|
7
|
+
// Layout (1-indexed rows):
|
|
8
|
+
// Rows 1 to headerHeight: Header
|
|
9
|
+
// Row headerHeight+1: Separator
|
|
10
|
+
// Row headerHeight+2: Top pane header ("STAGING AREA" or "COMMITS")
|
|
11
|
+
// Rows headerHeight+3 to headerHeight+1+topPaneHeight: Top pane content
|
|
12
|
+
const stagingPaneStart = headerHeight + 2; // First row of top pane (the header row)
|
|
13
|
+
const fileListEnd = headerHeight + 1 + topPaneHeight; // Last row of top pane
|
|
14
|
+
const separatorRow = fileListEnd + 1; // Separator between panes
|
|
15
|
+
const diffPaneStart = fileListEnd + 2; // First row of bottom pane content
|
|
16
|
+
const diffPaneEnd = diffPaneStart + bottomPaneHeight - 1;
|
|
17
|
+
const footerRow = terminalHeight;
|
|
18
|
+
return {
|
|
19
|
+
stagingPaneStart,
|
|
20
|
+
fileListEnd,
|
|
21
|
+
separatorRow,
|
|
22
|
+
diffPaneStart,
|
|
23
|
+
diffPaneEnd,
|
|
24
|
+
footerRow,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Given a y-coordinate in the file list area, calculate which file index was clicked.
|
|
29
|
+
* Returns -1 if the click is not on a file.
|
|
30
|
+
*
|
|
31
|
+
* FileList layout: Modified → Untracked → Staged (with headers and spacers)
|
|
32
|
+
*/
|
|
33
|
+
export function getClickedFileIndex(y, scrollOffset, files, stagingPaneStart, fileListEnd) {
|
|
34
|
+
if (y < stagingPaneStart + 1 || y > fileListEnd)
|
|
35
|
+
return -1;
|
|
36
|
+
// Calculate which row in the list was clicked (0-indexed)
|
|
37
|
+
// Use stagingPaneStart + 1 to account for the "STAGING AREA" header row
|
|
38
|
+
const listRow = y - (stagingPaneStart + 1) + scrollOffset;
|
|
39
|
+
// Split files into 3 categories (same order as FileList)
|
|
40
|
+
const { modified: modifiedFiles, untracked: untrackedFiles, staged: stagedFiles, } = categorizeFiles(files);
|
|
41
|
+
// Build row map (same structure as FileList builds)
|
|
42
|
+
// Each section: header (1) + files (n)
|
|
43
|
+
// Spacer (1) between sections if previous section exists
|
|
44
|
+
let currentRow = 0;
|
|
45
|
+
let currentFileIndex = 0;
|
|
46
|
+
// Modified section
|
|
47
|
+
if (modifiedFiles.length > 0) {
|
|
48
|
+
currentRow++; // "Modified:" header
|
|
49
|
+
for (let i = 0; i < modifiedFiles.length; i++) {
|
|
50
|
+
if (listRow === currentRow) {
|
|
51
|
+
return currentFileIndex;
|
|
52
|
+
}
|
|
53
|
+
currentRow++;
|
|
54
|
+
currentFileIndex++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Untracked section
|
|
58
|
+
if (untrackedFiles.length > 0) {
|
|
59
|
+
if (modifiedFiles.length > 0) {
|
|
60
|
+
currentRow++; // spacer
|
|
61
|
+
}
|
|
62
|
+
currentRow++; // "Untracked:" header
|
|
63
|
+
for (let i = 0; i < untrackedFiles.length; i++) {
|
|
64
|
+
if (listRow === currentRow) {
|
|
65
|
+
return currentFileIndex;
|
|
66
|
+
}
|
|
67
|
+
currentRow++;
|
|
68
|
+
currentFileIndex++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Staged section
|
|
72
|
+
if (stagedFiles.length > 0) {
|
|
73
|
+
if (modifiedFiles.length > 0 || untrackedFiles.length > 0) {
|
|
74
|
+
currentRow++; // spacer
|
|
75
|
+
}
|
|
76
|
+
currentRow++; // "Staged:" header
|
|
77
|
+
for (let i = 0; i < stagedFiles.length; i++) {
|
|
78
|
+
if (listRow === currentRow) {
|
|
79
|
+
return currentFileIndex;
|
|
80
|
+
}
|
|
81
|
+
currentRow++;
|
|
82
|
+
currentFileIndex++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return -1;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Calculate the x-coordinate boundaries for each tab in the footer.
|
|
89
|
+
* Tab layout (right-aligned): [1]Diff [2]Commit [3]History [4]Compare [5]Explorer (51 chars total)
|
|
90
|
+
*/
|
|
91
|
+
export function getTabBoundaries(terminalWidth) {
|
|
92
|
+
const tabsStart = terminalWidth - 51; // 1-indexed start of tabs section
|
|
93
|
+
return {
|
|
94
|
+
diffStart: tabsStart,
|
|
95
|
+
diffEnd: tabsStart + 6,
|
|
96
|
+
commitStart: tabsStart + 8,
|
|
97
|
+
commitEnd: tabsStart + 16,
|
|
98
|
+
historyStart: tabsStart + 18,
|
|
99
|
+
historyEnd: tabsStart + 27,
|
|
100
|
+
compareStart: tabsStart + 29,
|
|
101
|
+
compareEnd: tabsStart + 38,
|
|
102
|
+
explorerStart: tabsStart + 40,
|
|
103
|
+
explorerEnd: tabsStart + 50,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Given an x-coordinate in the footer row, determine which tab was clicked.
|
|
108
|
+
* Returns null if no tab was clicked.
|
|
109
|
+
*/
|
|
110
|
+
export function getClickedTab(x, terminalWidth) {
|
|
111
|
+
const bounds = getTabBoundaries(terminalWidth);
|
|
112
|
+
if (x >= bounds.diffStart && x <= bounds.diffEnd) {
|
|
113
|
+
return 'diff';
|
|
114
|
+
}
|
|
115
|
+
else if (x >= bounds.commitStart && x <= bounds.commitEnd) {
|
|
116
|
+
return 'commit';
|
|
117
|
+
}
|
|
118
|
+
else if (x >= bounds.historyStart && x <= bounds.historyEnd) {
|
|
119
|
+
return 'history';
|
|
120
|
+
}
|
|
121
|
+
else if (x >= bounds.compareStart && x <= bounds.compareEnd) {
|
|
122
|
+
return 'compare';
|
|
123
|
+
}
|
|
124
|
+
else if (x >= bounds.explorerStart && x <= bounds.explorerEnd) {
|
|
125
|
+
return 'explorer';
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Check if a click is in the file button area (first 6 columns for stage/unstage toggle).
|
|
131
|
+
*/
|
|
132
|
+
export function isButtonAreaClick(x) {
|
|
133
|
+
return x <= 6;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Check if a y-coordinate is within a given pane.
|
|
137
|
+
*/
|
|
138
|
+
export function isInPane(y, paneStart, paneEnd) {
|
|
139
|
+
return y >= paneStart && y <= paneEnd;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Given an x-coordinate in the footer row, determine which left indicator was clicked.
|
|
143
|
+
* Layout (scroll mode): "? [scroll] [auto] [wrap]"
|
|
144
|
+
* 1 3 10 12 17 19 24
|
|
145
|
+
* (In select mode, mouse tracking is disabled so clicks don't register)
|
|
146
|
+
*/
|
|
147
|
+
export function getFooterLeftClick(x) {
|
|
148
|
+
// "?" area: column 1
|
|
149
|
+
if (x === 1) {
|
|
150
|
+
return 'hotkeys';
|
|
151
|
+
}
|
|
152
|
+
// "[scroll]" or "[select]" area: columns 3-10
|
|
153
|
+
if (x >= 3 && x <= 10) {
|
|
154
|
+
return 'mouse-mode';
|
|
155
|
+
}
|
|
156
|
+
// "[auto]" area: columns 12-17
|
|
157
|
+
if (x >= 12 && x <= 17) {
|
|
158
|
+
return 'auto-tab';
|
|
159
|
+
}
|
|
160
|
+
// "[wrap]" area: columns 19-24
|
|
161
|
+
if (x >= 19 && x <= 24) {
|
|
162
|
+
return 'wrap';
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|