diffstalker 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +378 -0
  4. package/dist/App.js +1162 -1
  5. package/dist/config.js +83 -2
  6. package/dist/core/ExplorerStateManager.js +266 -0
  7. package/dist/core/FilePathWatcher.js +133 -0
  8. package/dist/core/GitOperationQueue.js +109 -1
  9. package/dist/core/GitStateManager.js +525 -1
  10. package/dist/git/diff.js +471 -10
  11. package/dist/git/ignoreUtils.js +30 -0
  12. package/dist/git/status.js +237 -5
  13. package/dist/index.js +70 -16
  14. package/dist/ipc/CommandClient.js +165 -0
  15. package/dist/ipc/CommandServer.js +152 -0
  16. package/dist/services/commitService.js +22 -1
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +182 -0
  19. package/dist/themes.js +127 -1
  20. package/dist/types/tabs.js +4 -0
  21. package/dist/ui/Layout.js +252 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/HotkeysModal.js +209 -0
  25. package/dist/ui/modals/ThemePicker.js +107 -0
  26. package/dist/ui/widgets/CommitPanel.js +58 -0
  27. package/dist/ui/widgets/CompareListView.js +216 -0
  28. package/dist/ui/widgets/DiffView.js +279 -0
  29. package/dist/ui/widgets/ExplorerContent.js +102 -0
  30. package/dist/ui/widgets/ExplorerView.js +95 -0
  31. package/dist/ui/widgets/FileList.js +185 -0
  32. package/dist/ui/widgets/Footer.js +46 -0
  33. package/dist/ui/widgets/Header.js +111 -0
  34. package/dist/ui/widgets/HistoryView.js +69 -0
  35. package/dist/utils/ansiToBlessed.js +125 -0
  36. package/dist/utils/ansiTruncate.js +108 -0
  37. package/dist/utils/baseBranchCache.js +44 -2
  38. package/dist/utils/commitFormat.js +38 -1
  39. package/dist/utils/diffFilters.js +21 -1
  40. package/dist/utils/diffRowCalculations.js +113 -1
  41. package/dist/utils/displayRows.js +351 -2
  42. package/dist/utils/explorerDisplayRows.js +169 -0
  43. package/dist/utils/fileCategories.js +26 -1
  44. package/dist/utils/formatDate.js +39 -1
  45. package/dist/utils/formatPath.js +58 -1
  46. package/dist/utils/languageDetection.js +236 -0
  47. package/dist/utils/layoutCalculations.js +98 -1
  48. package/dist/utils/lineBreaking.js +88 -5
  49. package/dist/utils/mouseCoordinates.js +165 -1
  50. package/dist/utils/pathUtils.js +27 -0
  51. package/dist/utils/rowCalculations.js +246 -4
  52. package/dist/utils/wordDiff.js +50 -0
  53. package/package.json +15 -19
  54. package/dist/components/BaseBranchPicker.js +0 -1
  55. package/dist/components/BottomPane.js +0 -1
  56. package/dist/components/CommitPanel.js +0 -1
  57. package/dist/components/CompareListView.js +0 -1
  58. package/dist/components/ExplorerContentView.js +0 -3
  59. package/dist/components/ExplorerView.js +0 -1
  60. package/dist/components/FileList.js +0 -1
  61. package/dist/components/Footer.js +0 -1
  62. package/dist/components/Header.js +0 -1
  63. package/dist/components/HistoryView.js +0 -1
  64. package/dist/components/HotkeysModal.js +0 -1
  65. package/dist/components/Modal.js +0 -1
  66. package/dist/components/ScrollableList.js +0 -1
  67. package/dist/components/ThemePicker.js +0 -1
  68. package/dist/components/TopPane.js +0 -1
  69. package/dist/components/UnifiedDiffView.js +0 -1
  70. package/dist/hooks/useCommitFlow.js +0 -1
  71. package/dist/hooks/useCompareState.js +0 -1
  72. package/dist/hooks/useExplorerState.js +0 -9
  73. package/dist/hooks/useGit.js +0 -1
  74. package/dist/hooks/useHistoryState.js +0 -1
  75. package/dist/hooks/useKeymap.js +0 -1
  76. package/dist/hooks/useLayout.js +0 -1
  77. package/dist/hooks/useMouse.js +0 -1
  78. package/dist/hooks/useTerminalSize.js +0 -1
  79. package/dist/hooks/useWatcher.js +0 -11
@@ -0,0 +1,209 @@
1
+ import blessed from 'neo-blessed';
2
+ const hotkeyGroups = [
3
+ {
4
+ title: 'Navigation',
5
+ entries: [
6
+ { key: 'j/k', description: 'Move up/down' },
7
+ { key: 'Tab', description: 'Toggle pane focus' },
8
+ ],
9
+ },
10
+ {
11
+ title: 'Staging',
12
+ entries: [
13
+ { key: 's', description: 'Stage file' },
14
+ { key: 'U', description: 'Unstage file' },
15
+ { key: 'A', description: 'Stage all' },
16
+ { key: 'Z', description: 'Unstage all' },
17
+ { key: 'Space', description: 'Toggle stage' },
18
+ ],
19
+ },
20
+ {
21
+ title: 'Actions',
22
+ entries: [
23
+ { key: 'c', description: 'Commit panel' },
24
+ { key: 'r', description: 'Refresh' },
25
+ { key: 'q', description: 'Quit' },
26
+ ],
27
+ },
28
+ {
29
+ title: 'Resize',
30
+ entries: [
31
+ { key: '-', description: 'Shrink top pane' },
32
+ { key: '+', description: 'Grow top pane' },
33
+ ],
34
+ },
35
+ {
36
+ title: 'Tabs',
37
+ entries: [
38
+ { key: '1', description: 'Diff view' },
39
+ { key: '2', description: 'Commit panel' },
40
+ { key: '3', description: 'History view' },
41
+ { key: '4', description: 'Compare view' },
42
+ { key: '5', description: 'Explorer view' },
43
+ ],
44
+ },
45
+ {
46
+ title: 'Toggles',
47
+ entries: [
48
+ { key: 'm', description: 'Mouse mode' },
49
+ { key: 'w', description: 'Wrap mode' },
50
+ { key: 'f', description: 'Follow mode' },
51
+ { key: 't', description: 'Theme picker' },
52
+ { key: '?', description: 'This help' },
53
+ ],
54
+ },
55
+ {
56
+ title: 'Explorer',
57
+ entries: [
58
+ { key: 'Enter', description: 'Enter directory' },
59
+ { key: 'Backspace', description: 'Go up' },
60
+ ],
61
+ },
62
+ {
63
+ title: 'Compare',
64
+ entries: [
65
+ { key: 'b', description: 'Base branch picker' },
66
+ { key: 'u', description: 'Toggle uncommitted' },
67
+ ],
68
+ },
69
+ {
70
+ title: 'Diff',
71
+ entries: [{ key: 'd', description: 'Discard changes' }],
72
+ },
73
+ ];
74
+ /**
75
+ * HotkeysModal shows available keyboard shortcuts.
76
+ */
77
+ export class HotkeysModal {
78
+ box;
79
+ screen;
80
+ onClose;
81
+ constructor(screen, onClose) {
82
+ this.screen = screen;
83
+ this.onClose = onClose;
84
+ // Calculate modal dimensions
85
+ const screenWidth = screen.width;
86
+ const screenHeight = screen.height;
87
+ // Determine layout based on screen width
88
+ const useTwoColumns = screenWidth >= 90;
89
+ const width = useTwoColumns ? Math.min(80, screenWidth - 4) : Math.min(42, screenWidth - 4);
90
+ const height = Math.min(this.calculateHeight(useTwoColumns), screenHeight - 4);
91
+ this.box = blessed.box({
92
+ parent: screen,
93
+ top: 'center',
94
+ left: 'center',
95
+ width,
96
+ height,
97
+ border: {
98
+ type: 'line',
99
+ },
100
+ style: {
101
+ border: {
102
+ fg: 'cyan',
103
+ },
104
+ },
105
+ tags: true,
106
+ keys: true,
107
+ scrollable: true,
108
+ alwaysScroll: true,
109
+ });
110
+ // Setup key handlers
111
+ this.setupKeyHandlers();
112
+ // Render content
113
+ this.render(useTwoColumns, width);
114
+ }
115
+ calculateHeight(useTwoColumns) {
116
+ if (useTwoColumns) {
117
+ const midpoint = Math.ceil(hotkeyGroups.length / 2);
118
+ const leftGroups = hotkeyGroups.slice(0, midpoint);
119
+ const rightGroups = hotkeyGroups.slice(midpoint);
120
+ const leftLines = leftGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
121
+ const rightLines = rightGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
122
+ return Math.max(leftLines, rightLines) + 5;
123
+ }
124
+ else {
125
+ return hotkeyGroups.reduce((sum, g) => sum + g.entries.length + 2, 0) + 5;
126
+ }
127
+ }
128
+ setupKeyHandlers() {
129
+ this.box.key(['escape', 'enter', '?', 'q'], () => {
130
+ this.close();
131
+ this.onClose();
132
+ });
133
+ // Close on click anywhere
134
+ this.box.on('click', () => {
135
+ this.close();
136
+ this.onClose();
137
+ });
138
+ }
139
+ /**
140
+ * Calculate the visible width of a string (excluding blessed tags).
141
+ */
142
+ visibleWidth(str) {
143
+ return str.replace(/\{[^}]+\}/g, '').length;
144
+ }
145
+ /**
146
+ * Pad a string with blessed tags to a visible width.
147
+ */
148
+ padToVisible(str, targetWidth) {
149
+ const visible = this.visibleWidth(str);
150
+ const padding = Math.max(0, targetWidth - visible);
151
+ return str + ' '.repeat(padding);
152
+ }
153
+ render(useTwoColumns, width) {
154
+ const lines = [];
155
+ // Header
156
+ lines.push('{bold}{cyan-fg} Keyboard Shortcuts{/cyan-fg}{/bold}');
157
+ lines.push('');
158
+ if (useTwoColumns) {
159
+ const midpoint = Math.ceil(hotkeyGroups.length / 2);
160
+ const leftGroups = hotkeyGroups.slice(0, midpoint);
161
+ const rightGroups = hotkeyGroups.slice(midpoint);
162
+ const colWidth = Math.floor((width - 6) / 2);
163
+ // Render side by side
164
+ const leftLines = this.renderGroups(leftGroups, colWidth);
165
+ const rightLines = this.renderGroups(rightGroups, colWidth);
166
+ const maxLines = Math.max(leftLines.length, rightLines.length);
167
+ for (let i = 0; i < maxLines; i++) {
168
+ const left = this.padToVisible(leftLines[i] || '', colWidth);
169
+ const right = rightLines[i] || '';
170
+ lines.push(left + ' ' + right);
171
+ }
172
+ }
173
+ else {
174
+ // Single column
175
+ for (const group of hotkeyGroups) {
176
+ lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
177
+ for (const entry of group.entries) {
178
+ lines.push(` {cyan-fg}${entry.key.padEnd(10)}{/cyan-fg} ${entry.description}`);
179
+ }
180
+ lines.push('');
181
+ }
182
+ }
183
+ // Footer
184
+ lines.push('');
185
+ lines.push('{gray-fg}Press Esc, Enter, or ? to close{/gray-fg}');
186
+ this.box.setContent(lines.join('\n'));
187
+ this.screen.render();
188
+ }
189
+ renderGroups(groups, colWidth) {
190
+ const lines = [];
191
+ for (const group of groups) {
192
+ lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
193
+ for (const entry of group.entries) {
194
+ lines.push(` {cyan-fg}${entry.key.padEnd(10)}{/cyan-fg} ${entry.description}`);
195
+ }
196
+ lines.push('');
197
+ }
198
+ return lines;
199
+ }
200
+ close() {
201
+ this.box.destroy();
202
+ }
203
+ /**
204
+ * Focus the modal.
205
+ */
206
+ focus() {
207
+ this.box.focus();
208
+ }
209
+ }
@@ -0,0 +1,107 @@
1
+ import blessed from 'neo-blessed';
2
+ import { themes, themeOrder, getTheme } from '../../themes.js';
3
+ /**
4
+ * ThemePicker modal for selecting diff themes.
5
+ */
6
+ export class ThemePicker {
7
+ box;
8
+ screen;
9
+ selectedIndex;
10
+ currentTheme;
11
+ onSelect;
12
+ onCancel;
13
+ constructor(screen, currentTheme, onSelect, onCancel) {
14
+ this.screen = screen;
15
+ this.currentTheme = currentTheme;
16
+ this.onSelect = onSelect;
17
+ this.onCancel = onCancel;
18
+ // Find current theme index
19
+ this.selectedIndex = themeOrder.indexOf(currentTheme);
20
+ if (this.selectedIndex < 0)
21
+ this.selectedIndex = 0;
22
+ // Create modal box
23
+ const width = 50;
24
+ const height = themeOrder.length + 12; // themes + header + preview + footer + borders + padding
25
+ this.box = blessed.box({
26
+ parent: screen,
27
+ top: 'center',
28
+ left: 'center',
29
+ width,
30
+ height,
31
+ border: {
32
+ type: 'line',
33
+ },
34
+ style: {
35
+ border: {
36
+ fg: 'cyan',
37
+ },
38
+ },
39
+ tags: true,
40
+ keys: true,
41
+ });
42
+ // Setup key handlers
43
+ this.setupKeyHandlers();
44
+ // Initial render
45
+ this.render();
46
+ }
47
+ setupKeyHandlers() {
48
+ this.box.key(['escape', 'q'], () => {
49
+ this.close();
50
+ this.onCancel();
51
+ });
52
+ this.box.key(['enter', 'space'], () => {
53
+ const selected = themeOrder[this.selectedIndex];
54
+ this.close();
55
+ this.onSelect(selected);
56
+ });
57
+ this.box.key(['up', 'k'], () => {
58
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
59
+ this.render();
60
+ });
61
+ this.box.key(['down', 'j'], () => {
62
+ this.selectedIndex = Math.min(themeOrder.length - 1, this.selectedIndex + 1);
63
+ this.render();
64
+ });
65
+ }
66
+ render() {
67
+ const lines = [];
68
+ // Header
69
+ lines.push('{bold}{cyan-fg} Select Theme{/cyan-fg}{/bold}');
70
+ lines.push('');
71
+ // Theme list
72
+ for (let i = 0; i < themeOrder.length; i++) {
73
+ const themeName = themeOrder[i];
74
+ const theme = themes[themeName];
75
+ const isSelected = i === this.selectedIndex;
76
+ const isCurrent = themeName === this.currentTheme;
77
+ let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
78
+ line += theme.displayName;
79
+ if (isSelected)
80
+ line += '{/bold}{/cyan-fg}';
81
+ if (isCurrent)
82
+ line += ' {gray-fg}(current){/gray-fg}';
83
+ lines.push(line);
84
+ }
85
+ // Preview section
86
+ lines.push('');
87
+ lines.push('{gray-fg}Preview:{/gray-fg}');
88
+ const previewTheme = getTheme(themeOrder[this.selectedIndex]);
89
+ // Simple preview - just show add/del colors
90
+ lines.push(` {green-fg}+ added line{/green-fg}`);
91
+ lines.push(` {red-fg}- deleted line{/red-fg}`);
92
+ // Footer
93
+ lines.push('');
94
+ lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
95
+ this.box.setContent(lines.join('\n'));
96
+ this.screen.render();
97
+ }
98
+ close() {
99
+ this.box.destroy();
100
+ }
101
+ /**
102
+ * Focus the modal.
103
+ */
104
+ focus() {
105
+ this.box.focus();
106
+ }
107
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Format the commit panel as blessed-compatible tagged string.
3
+ */
4
+ export function formatCommitPanel(state, stagedCount, width) {
5
+ const lines = [];
6
+ // Title
7
+ let title = '{bold}Commit Message{/bold}';
8
+ if (state.amend) {
9
+ title += ' {yellow-fg}(amending){/yellow-fg}';
10
+ }
11
+ lines.push(title);
12
+ lines.push('');
13
+ // Message input area
14
+ const borderChar = state.inputFocused ? '\u2502' : '\u2502';
15
+ const borderColor = state.inputFocused ? 'cyan' : 'gray';
16
+ // Top border
17
+ const innerWidth = Math.max(20, width - 6);
18
+ lines.push(`{${borderColor}-fg}\u250c${'─'.repeat(innerWidth + 2)}\u2510{/${borderColor}-fg}`);
19
+ // Message content (or placeholder)
20
+ const displayMessage = state.message || (state.inputFocused ? '' : 'Press i or Enter to edit...');
21
+ const messageColor = state.message ? '' : '{gray-fg}';
22
+ const messageEnd = state.message ? '' : '{/gray-fg}';
23
+ // Truncate message if needed
24
+ const truncatedMessage = displayMessage.length > innerWidth
25
+ ? displayMessage.slice(0, innerWidth - 1) + '\u2026'
26
+ : displayMessage.padEnd(innerWidth);
27
+ lines.push(`{${borderColor}-fg}${borderChar}{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}${borderChar}{/${borderColor}-fg}`);
28
+ // Bottom border
29
+ lines.push(`{${borderColor}-fg}\u2514${'─'.repeat(innerWidth + 2)}\u2518{/${borderColor}-fg}`);
30
+ lines.push('');
31
+ // Amend checkbox
32
+ const checkbox = state.amend ? '[x]' : '[ ]';
33
+ const checkboxColor = state.amend ? 'green' : 'gray';
34
+ lines.push(`{${checkboxColor}-fg}${checkbox}{/${checkboxColor}-fg} Amend {gray-fg}(a){/gray-fg}`);
35
+ // Error message
36
+ if (state.error) {
37
+ lines.push('');
38
+ lines.push(`{red-fg}${state.error}{/red-fg}`);
39
+ }
40
+ // Committing status
41
+ if (state.isCommitting) {
42
+ lines.push('');
43
+ lines.push('{yellow-fg}Committing...{/yellow-fg}');
44
+ }
45
+ lines.push('');
46
+ // Help text
47
+ const helpText = state.inputFocused
48
+ ? 'Enter: commit | Esc: unfocus'
49
+ : 'i/Enter: edit | Esc: cancel | a: toggle amend';
50
+ lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
51
+ return lines.join('\n');
52
+ }
53
+ /**
54
+ * Format inactive commit panel.
55
+ */
56
+ export function formatCommitPanelInactive() {
57
+ return "{gray-fg}Press '2' or 'c' to open commit panel{/gray-fg}";
58
+ }
@@ -0,0 +1,216 @@
1
+ import { formatDate } from '../../utils/formatDate.js';
2
+ import { formatCommitDisplay } from '../../utils/commitFormat.js';
3
+ import { shortenPath } from '../../utils/formatPath.js';
4
+ /**
5
+ * Build the list of row items for the compare list view.
6
+ */
7
+ export function buildCompareListRows(commits, files, commitsExpanded = true, filesExpanded = true) {
8
+ const result = [];
9
+ // Commits section
10
+ if (commits.length > 0) {
11
+ result.push({ type: 'section-header', sectionType: 'commits' });
12
+ if (commitsExpanded) {
13
+ commits.forEach((commit, i) => {
14
+ result.push({ type: 'commit', commitIndex: i, commit });
15
+ });
16
+ }
17
+ }
18
+ // Files section
19
+ if (files.length > 0) {
20
+ if (commits.length > 0) {
21
+ result.push({ type: 'spacer' });
22
+ }
23
+ result.push({ type: 'section-header', sectionType: 'files' });
24
+ if (filesExpanded) {
25
+ files.forEach((file, i) => {
26
+ result.push({ type: 'file', fileIndex: i, file });
27
+ });
28
+ }
29
+ }
30
+ return result;
31
+ }
32
+ /**
33
+ * Escape blessed tags in content.
34
+ */
35
+ function escapeContent(content) {
36
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
37
+ }
38
+ /**
39
+ * Format a commit row.
40
+ */
41
+ function formatCommitRow(commit, isSelected, isFocused, width) {
42
+ const isHighlighted = isSelected && isFocused;
43
+ const dateStr = formatDate(commit.date);
44
+ // Fixed parts: indent(2) + hash(7) + spaces(4) + date + parens(2)
45
+ const baseWidth = 2 + 7 + 4 + dateStr.length + 2;
46
+ const remainingWidth = Math.max(10, width - baseWidth);
47
+ const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
48
+ let line = ' ';
49
+ line += `{yellow-fg}${commit.shortHash}{/yellow-fg} `;
50
+ if (isHighlighted) {
51
+ line += `{cyan-fg}{inverse}${escapeContent(displayMessage)}{/inverse}{/cyan-fg}`;
52
+ }
53
+ else {
54
+ line += escapeContent(displayMessage);
55
+ }
56
+ line += ` {gray-fg}(${dateStr}){/gray-fg}`;
57
+ if (displayRefs) {
58
+ line += ` {green-fg}${escapeContent(displayRefs)}{/green-fg}`;
59
+ }
60
+ return line;
61
+ }
62
+ /**
63
+ * Format a file row.
64
+ */
65
+ function formatFileRow(file, isSelected, isFocused, maxPathLength) {
66
+ const isHighlighted = isSelected && isFocused;
67
+ const isUncommitted = file.isUncommitted ?? false;
68
+ const statusColors = {
69
+ added: 'green',
70
+ modified: 'yellow',
71
+ deleted: 'red',
72
+ renamed: 'blue',
73
+ };
74
+ const statusChars = {
75
+ added: 'A',
76
+ modified: 'M',
77
+ deleted: 'D',
78
+ renamed: 'R',
79
+ };
80
+ // Account for stats: " (+123 -456)" and possible "*" for uncommitted
81
+ const statsLength = 5 + String(file.additions).length + String(file.deletions).length;
82
+ const uncommittedLength = isUncommitted ? 14 : 0;
83
+ const availableForPath = Math.max(10, maxPathLength - statsLength - uncommittedLength);
84
+ let line = ' ';
85
+ if (isUncommitted) {
86
+ line += '{magenta-fg}{bold}*{/bold}{/magenta-fg}';
87
+ }
88
+ const statusColor = isUncommitted ? 'magenta' : statusColors[file.status];
89
+ line += `{${statusColor}-fg}{bold}${statusChars[file.status]}{/bold}{/${statusColor}-fg} `;
90
+ const displayPath = shortenPath(file.path, availableForPath);
91
+ if (isHighlighted) {
92
+ line += `{cyan-fg}{inverse}${escapeContent(displayPath)}{/inverse}{/cyan-fg}`;
93
+ }
94
+ else if (isUncommitted) {
95
+ line += `{magenta-fg}${escapeContent(displayPath)}{/magenta-fg}`;
96
+ }
97
+ else {
98
+ line += escapeContent(displayPath);
99
+ }
100
+ line += ` {gray-fg}({/gray-fg}{green-fg}+${file.additions}{/green-fg} {red-fg}-${file.deletions}{/red-fg}{gray-fg}){/gray-fg}`;
101
+ if (isUncommitted) {
102
+ line += ' {magenta-fg}[uncommitted]{/magenta-fg}';
103
+ }
104
+ return line;
105
+ }
106
+ /**
107
+ * Format the compare list view as blessed-compatible tagged string.
108
+ */
109
+ export function formatCompareListView(commits, files, selectedItem, isFocused, width, scrollOffset = 0, maxHeight) {
110
+ if (commits.length === 0 && files.length === 0) {
111
+ return '{gray-fg}No changes compared to base branch{/gray-fg}';
112
+ }
113
+ const rows = buildCompareListRows(commits, files);
114
+ // Apply scroll offset and max height
115
+ const visibleRows = maxHeight
116
+ ? rows.slice(scrollOffset, scrollOffset + maxHeight)
117
+ : rows.slice(scrollOffset);
118
+ const lines = [];
119
+ for (const row of visibleRows) {
120
+ if (row.type === 'section-header') {
121
+ const isCommits = row.sectionType === 'commits';
122
+ const count = isCommits ? commits.length : files.length;
123
+ const label = isCommits ? 'Commits' : 'Files';
124
+ lines.push(`{cyan-fg}{bold}▼ ${label}{/bold}{/cyan-fg} {gray-fg}(${count}){/gray-fg}`);
125
+ }
126
+ else if (row.type === 'spacer') {
127
+ lines.push('');
128
+ }
129
+ else if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
130
+ const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
131
+ lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
132
+ }
133
+ else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
134
+ const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
135
+ lines.push(formatFileRow(row.file, isSelected, isFocused, width - 5));
136
+ }
137
+ }
138
+ return lines.join('\n');
139
+ }
140
+ /**
141
+ * Get the total number of rows in the compare list view (for scroll calculation).
142
+ */
143
+ export function getCompareListTotalRows(commits, files, commitsExpanded = true, filesExpanded = true) {
144
+ let count = 0;
145
+ if (commits.length > 0) {
146
+ count += 1; // header
147
+ if (commitsExpanded)
148
+ count += commits.length;
149
+ }
150
+ if (files.length > 0) {
151
+ if (commits.length > 0)
152
+ count += 1; // spacer
153
+ count += 1; // header
154
+ if (filesExpanded)
155
+ count += files.length;
156
+ }
157
+ return count;
158
+ }
159
+ /**
160
+ * Map a row index to a selection.
161
+ * Returns null if the row is a header or spacer.
162
+ */
163
+ export function getCompareSelectionFromRow(rowIndex, commits, files, commitsExpanded = true, filesExpanded = true) {
164
+ const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
165
+ const row = rows[rowIndex];
166
+ if (!row)
167
+ return null;
168
+ if (row.type === 'commit' && row.commitIndex !== undefined) {
169
+ return { type: 'commit', index: row.commitIndex };
170
+ }
171
+ if (row.type === 'file' && row.fileIndex !== undefined) {
172
+ return { type: 'file', index: row.fileIndex };
173
+ }
174
+ return null;
175
+ }
176
+ /**
177
+ * Find the row index for a given selection.
178
+ */
179
+ export function getRowFromCompareSelection(selection, commits, files, commitsExpanded = true, filesExpanded = true) {
180
+ const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
181
+ for (let i = 0; i < rows.length; i++) {
182
+ const row = rows[i];
183
+ if (selection.type === 'commit' &&
184
+ row.type === 'commit' &&
185
+ row.commitIndex === selection.index) {
186
+ return i;
187
+ }
188
+ if (selection.type === 'file' && row.type === 'file' && row.fileIndex === selection.index) {
189
+ return i;
190
+ }
191
+ }
192
+ return 0;
193
+ }
194
+ /**
195
+ * Navigate to next selectable item.
196
+ */
197
+ export function getNextCompareSelection(current, commits, files, direction) {
198
+ const rows = buildCompareListRows(commits, files);
199
+ // Find current row index
200
+ let currentRowIndex = 0;
201
+ if (current) {
202
+ currentRowIndex = getRowFromCompareSelection(current, commits, files);
203
+ }
204
+ // Find next selectable row
205
+ const delta = direction === 'down' ? 1 : -1;
206
+ let nextRowIndex = currentRowIndex + delta;
207
+ while (nextRowIndex >= 0 && nextRowIndex < rows.length) {
208
+ const selection = getCompareSelectionFromRow(nextRowIndex, commits, files);
209
+ if (selection) {
210
+ return selection;
211
+ }
212
+ nextRowIndex += delta;
213
+ }
214
+ // Stay at current if no valid next selection
215
+ return current;
216
+ }