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.
Files changed (53) 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 -3
  9. package/dist/components/ExplorerView.js +37 -1
  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 -1
  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 -9
  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 -1
  43. package/dist/utils/displayRows.js +172 -2
  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 -5
  51. package/dist/utils/mouseCoordinates.js +165 -1
  52. package/dist/utils/rowCalculations.js +209 -4
  53. 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 as h}from"./fileCategories.js";export{getFileListSectionCounts}from"./fileCategories.js";export function getFileListTotalRows(o){const{modifiedCount:t,untrackedCount:i,stagedCount:n}=h(o);let r=0;return t>0&&(r+=1+t),i>0&&(t>0&&(r+=1),r+=1+i),n>0&&((t>0||i>0)&&(r+=1),r+=1+n),r}export function calculatePaneHeights(o,t,i=.4){const n=getFileListTotalRows(o),r=3,a=Math.floor(t*i),s=Math.max(r,Math.min(n,a)),f=t-s;return{topPaneHeight:s,bottomPaneHeight:f}}export function getRowForFileIndex(o,t,i,n){let r=0;if(o<t)return 1+o;t>0&&(r+=1+t);const a=t;if(o<a+i){const g=o-a;return t>0&&(r+=1),r+1+g}i>0&&(t>0&&(r+=1),r+=1+i);const s=t+i,f=o-s;return(t>0||i>0)&&(r+=1),r+1+f}export function calculateScrollOffset(o,t,i){return o<t?Math.max(0,o-1):o>=t+i?o-i+1:t}
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
- export function breakLine(t,n,i=!0){if(n<=0)return[{text:t,isContinuation:!1}];if(t.length<=n)return[{text:t,isContinuation:!1}];const r=[];let e=t,o=!0;for(;e.length>0;){if(e.length<=n){r.push({text:e,isContinuation:!o});break}r.push({text:e.slice(0,n),isContinuation:!o}),e=e.slice(n),o=!1}return i&&l(t,n,r),r}function l(t,n,i){const r=i.map(e=>e.text).join("");if(r!==t)throw new Error(`[LineBreaking] Content was lost during breaking!
2
- Original (${t.length} chars): "${t.slice(0,50)}${t.length>50?"...":""}"
3
- Joined (${r.length} chars): "${r.slice(0,50)}${r.length>50?"...":""}"`);for(let e=0;e<i.length;e++){const o=i[e];if(o.text.length>n&&n>=1)throw new Error(`[LineBreaking] Segment ${e} exceeds maxWidth!
4
- Segment length: ${o.text.length}, maxWidth: ${n}
5
- Segment: "${o.text.slice(0,50)}${o.text.length>50?"...":""}"`)}if(i.length>0&&i[0].isContinuation)throw new Error("[LineBreaking] First segment incorrectly marked as continuation!");for(let e=1;e<i.length;e++)if(!i[e].isContinuation)throw new Error(`[LineBreaking] Segment ${e} should be marked as continuation but isn't!`)}export function getLineRowCount(t,n){return n<=0||t.length<=n?1:Math.ceil(t.length/n)}
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 as d}from"./fileCategories.js";export function calculatePaneBoundaries(t,e,r,a=1){const s=a+2,i=a+1+t,f=i+1,l=i+2,c=l+e-1;return{stagingPaneStart:s,fileListEnd:i,separatorRow:f,diffPaneStart:l,diffPaneEnd:c,footerRow:r}}export function getClickedFileIndex(t,e,r,a,s){if(t<a+1||t>s)return-1;const i=t-(a+1)+e,{modified:f,untracked:l,staged:c}=d(r);let n=0,u=0;if(f.length>0){n++;for(let o=0;o<f.length;o++){if(i===n)return u;n++,u++}}if(l.length>0){f.length>0&&n++,n++;for(let o=0;o<l.length;o++){if(i===n)return u;n++,u++}}if(c.length>0){(f.length>0||l.length>0)&&n++,n++;for(let o=0;o<c.length;o++){if(i===n)return u;n++,u++}}return-1}export function getTabBoundaries(t){const e=t-51;return{diffStart:e,diffEnd:e+6,commitStart:e+8,commitEnd:e+16,historyStart:e+18,historyEnd:e+27,compareStart:e+29,compareEnd:e+38,explorerStart:e+40,explorerEnd:e+50}}export function getClickedTab(t,e){const r=getTabBoundaries(e);return t>=r.diffStart&&t<=r.diffEnd?"diff":t>=r.commitStart&&t<=r.commitEnd?"commit":t>=r.historyStart&&t<=r.historyEnd?"history":t>=r.compareStart&&t<=r.compareEnd?"compare":t>=r.explorerStart&&t<=r.explorerEnd?"explorer":null}export function isButtonAreaClick(t){return t<=6}export function isInPane(t,e,r){return t>=e&&t<=r}export function getFooterLeftClick(t){return t===1?"hotkeys":t>=3&&t<=10?"mouse-mode":t>=12&&t<=17?"auto-tab":t>=19&&t<=24?"wrap":null}
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
+ }