diffstalker 0.1.7 → 0.2.1

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 (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
@@ -0,0 +1,204 @@
1
+ // ANSI escape codes
2
+ const ANSI_RESET = '\x1b[0m';
3
+ const ANSI_BOLD = '\x1b[1m';
4
+ const ANSI_GRAY = '\x1b[90m';
5
+ const ANSI_CYAN = '\x1b[36m';
6
+ const ANSI_YELLOW = '\x1b[33m';
7
+ const ANSI_GREEN = '\x1b[32m';
8
+ const ANSI_RED = '\x1b[31m';
9
+ const ANSI_BLUE = '\x1b[34m';
10
+ const ANSI_MAGENTA = '\x1b[35m';
11
+ const ANSI_INVERSE = '\x1b[7m';
12
+ /**
13
+ * Build tree prefix characters (│ ├ └).
14
+ */
15
+ function buildTreePrefix(row) {
16
+ let prefix = '';
17
+ // Add vertical lines for parent levels
18
+ for (let i = 0; i < row.depth; i++) {
19
+ if (row.parentIsLast[i]) {
20
+ prefix += ' '; // Parent was last, no line needed
21
+ }
22
+ else {
23
+ prefix += '│ '; // Parent has siblings below, draw line
24
+ }
25
+ }
26
+ // Add connector for this item
27
+ if (row.depth > 0 || row.parentIsLast.length === 0) {
28
+ if (row.isLast) {
29
+ prefix += '└ ';
30
+ }
31
+ else {
32
+ prefix += '├ ';
33
+ }
34
+ }
35
+ return prefix;
36
+ }
37
+ /**
38
+ * Get status marker for git status.
39
+ */
40
+ function getStatusMarker(status) {
41
+ if (!status)
42
+ return '';
43
+ switch (status) {
44
+ case 'modified':
45
+ return 'M';
46
+ case 'added':
47
+ return 'A';
48
+ case 'deleted':
49
+ return 'D';
50
+ case 'untracked':
51
+ return '?';
52
+ case 'renamed':
53
+ return 'R';
54
+ case 'copied':
55
+ return 'C';
56
+ default:
57
+ return '';
58
+ }
59
+ }
60
+ /**
61
+ * Get color for git status.
62
+ */
63
+ function getStatusColor(status) {
64
+ if (!status)
65
+ return ANSI_RESET;
66
+ switch (status) {
67
+ case 'modified':
68
+ return ANSI_YELLOW;
69
+ case 'added':
70
+ return ANSI_GREEN;
71
+ case 'deleted':
72
+ return ANSI_RED;
73
+ case 'untracked':
74
+ return ANSI_GRAY;
75
+ case 'renamed':
76
+ return ANSI_BLUE;
77
+ case 'copied':
78
+ return ANSI_MAGENTA;
79
+ default:
80
+ return ANSI_RESET;
81
+ }
82
+ }
83
+ /**
84
+ * Format the explorer tree view as blessed-compatible tagged string.
85
+ */
86
+ export function formatExplorerView(displayRows, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, isLoading = false, error = null) {
87
+ if (error) {
88
+ return `{red-fg}Error: ${escapeContent(error)}{/red-fg}`;
89
+ }
90
+ if (isLoading) {
91
+ return '{gray-fg}Loading...{/gray-fg}';
92
+ }
93
+ if (displayRows.length === 0) {
94
+ return '{gray-fg}(empty directory){/gray-fg}';
95
+ }
96
+ // Apply scroll offset and max height
97
+ const visibleRows = maxHeight
98
+ ? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
99
+ : displayRows.slice(scrollOffset);
100
+ const lines = [];
101
+ for (let i = 0; i < visibleRows.length; i++) {
102
+ const row = visibleRows[i];
103
+ const actualIndex = scrollOffset + i;
104
+ const isSelected = actualIndex === selectedIndex;
105
+ const isHighlighted = isSelected && isFocused;
106
+ const node = row.node;
107
+ // Build tree prefix
108
+ const prefix = buildTreePrefix(row);
109
+ // Directory icon (▸ collapsed, ▾ expanded)
110
+ let icon = '';
111
+ if (node.isDirectory) {
112
+ icon = node.expanded ? '▾ ' : '▸ ';
113
+ }
114
+ // Git status indicator
115
+ const statusMarker = getStatusMarker(node.gitStatus);
116
+ const statusColor = getStatusColor(node.gitStatus);
117
+ const statusDisplay = statusMarker ? `${statusColor}${statusMarker}${ANSI_RESET} ` : '';
118
+ // Directory status indicator (dot if has changed children)
119
+ const dirStatusDisplay = node.isDirectory && node.hasChangedChildren ? `${ANSI_YELLOW}●${ANSI_RESET} ` : '';
120
+ // Calculate available width for name
121
+ const prefixLen = prefix.length +
122
+ icon.length +
123
+ (statusMarker ? 2 : 0) +
124
+ (node.hasChangedChildren && node.isDirectory ? 2 : 0);
125
+ const maxNameLen = Math.max(5, width - prefixLen - 2);
126
+ // Display name (with trailing / for directories)
127
+ let displayName = node.isDirectory ? `${node.name}/` : node.name;
128
+ if (displayName.length > maxNameLen) {
129
+ displayName = displayName.slice(0, maxNameLen - 1) + '…';
130
+ }
131
+ // Build the line
132
+ let line = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
133
+ if (node.isDirectory) {
134
+ line += `${ANSI_BLUE}${icon}${ANSI_RESET}`;
135
+ line += dirStatusDisplay;
136
+ if (isHighlighted) {
137
+ line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
138
+ }
139
+ else {
140
+ line += `${ANSI_BLUE}${displayName}${ANSI_RESET}`;
141
+ }
142
+ }
143
+ else {
144
+ // File
145
+ line += statusDisplay;
146
+ if (isHighlighted) {
147
+ line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
148
+ }
149
+ else if (node.gitStatus) {
150
+ line += `${statusColor}${displayName}${ANSI_RESET}`;
151
+ }
152
+ else {
153
+ line += displayName;
154
+ }
155
+ }
156
+ lines.push(`{escape}${line}{/escape}`);
157
+ }
158
+ return lines.join('\n');
159
+ }
160
+ /**
161
+ * Escape blessed tags in content.
162
+ */
163
+ function escapeContent(content) {
164
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
165
+ }
166
+ /**
167
+ * Build breadcrumb segments from a path.
168
+ * Returns segments like ["src", "components"] for "src/components"
169
+ */
170
+ export function buildBreadcrumbs(currentPath) {
171
+ if (!currentPath)
172
+ return [];
173
+ return currentPath.split('/').filter(Boolean);
174
+ }
175
+ /**
176
+ * Format breadcrumbs for display.
177
+ */
178
+ export function formatBreadcrumbs(currentPath, repoName) {
179
+ const segments = buildBreadcrumbs(currentPath);
180
+ if (segments.length === 0) {
181
+ return `{bold}${escapeContent(repoName)}{/bold}`;
182
+ }
183
+ const parts = [repoName, ...segments];
184
+ return parts
185
+ .map((part, i) => {
186
+ if (i === parts.length - 1) {
187
+ return `{bold}${escapeContent(part)}{/bold}`;
188
+ }
189
+ return `{gray-fg}${escapeContent(part)}{/gray-fg}`;
190
+ })
191
+ .join('{gray-fg}/{/gray-fg}');
192
+ }
193
+ /**
194
+ * Get total rows in explorer for scroll calculations.
195
+ */
196
+ export function getExplorerTotalRows(displayRows) {
197
+ return displayRows.length;
198
+ }
199
+ /**
200
+ * Get row at index.
201
+ */
202
+ export function getExplorerRowAtIndex(displayRows, index) {
203
+ return displayRows[index] ?? null;
204
+ }
@@ -0,0 +1,185 @@
1
+ import { categorizeFiles } from '../../utils/fileCategories.js';
2
+ import { shortenPath } from '../../utils/formatPath.js';
3
+ function getStatusChar(status) {
4
+ switch (status) {
5
+ case 'modified':
6
+ return 'M';
7
+ case 'added':
8
+ return 'A';
9
+ case 'deleted':
10
+ return 'D';
11
+ case 'untracked':
12
+ return '?';
13
+ case 'renamed':
14
+ return 'R';
15
+ case 'copied':
16
+ return 'C';
17
+ default:
18
+ return ' ';
19
+ }
20
+ }
21
+ function getStatusColor(status) {
22
+ switch (status) {
23
+ case 'modified':
24
+ return 'yellow';
25
+ case 'added':
26
+ return 'green';
27
+ case 'deleted':
28
+ return 'red';
29
+ case 'untracked':
30
+ return 'gray';
31
+ case 'renamed':
32
+ return 'blue';
33
+ case 'copied':
34
+ return 'cyan';
35
+ default:
36
+ return 'white';
37
+ }
38
+ }
39
+ function formatStats(insertions, deletions) {
40
+ if (insertions === undefined && deletions === undefined)
41
+ return '';
42
+ const parts = [];
43
+ if (insertions !== undefined && insertions > 0) {
44
+ parts.push(`{green-fg}+${insertions}{/green-fg}`);
45
+ }
46
+ if (deletions !== undefined && deletions > 0) {
47
+ parts.push(`{red-fg}-${deletions}{/red-fg}`);
48
+ }
49
+ return parts.length > 0 ? ' ' + parts.join(' ') : '';
50
+ }
51
+ /**
52
+ * Build the list of row items for the file list.
53
+ */
54
+ export function buildFileListRows(files) {
55
+ const { modified, untracked, staged } = categorizeFiles(files);
56
+ const rows = [];
57
+ let currentFileIndex = 0;
58
+ if (modified.length > 0) {
59
+ rows.push({ type: 'header', content: 'Modified:', headerColor: 'yellow' });
60
+ modified.forEach((file) => {
61
+ rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
62
+ });
63
+ }
64
+ if (untracked.length > 0) {
65
+ if (modified.length > 0) {
66
+ rows.push({ type: 'spacer' });
67
+ }
68
+ rows.push({ type: 'header', content: 'Untracked:', headerColor: 'gray' });
69
+ untracked.forEach((file) => {
70
+ rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
71
+ });
72
+ }
73
+ if (staged.length > 0) {
74
+ if (modified.length > 0 || untracked.length > 0) {
75
+ rows.push({ type: 'spacer' });
76
+ }
77
+ rows.push({ type: 'header', content: 'Staged:', headerColor: 'green' });
78
+ staged.forEach((file) => {
79
+ rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
80
+ });
81
+ }
82
+ return rows;
83
+ }
84
+ /**
85
+ * Format the file list as blessed-compatible tagged string.
86
+ */
87
+ export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
88
+ if (files.length === 0) {
89
+ return '{gray-fg} No changes{/gray-fg}';
90
+ }
91
+ const rows = buildFileListRows(files);
92
+ const maxPathLength = width - 12; // Account for prefix chars
93
+ // Apply scroll offset and max height
94
+ const visibleRows = maxHeight
95
+ ? rows.slice(scrollOffset, scrollOffset + maxHeight)
96
+ : rows.slice(scrollOffset);
97
+ const lines = [];
98
+ for (const row of visibleRows) {
99
+ if (row.type === 'header') {
100
+ lines.push(`{bold}{${row.headerColor}-fg}${row.content}{/${row.headerColor}-fg}{/bold}`);
101
+ }
102
+ else if (row.type === 'spacer') {
103
+ lines.push('');
104
+ }
105
+ else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
106
+ const file = row.file;
107
+ const isSelected = row.fileIndex === selectedIndex;
108
+ const isHighlighted = isSelected && isFocused;
109
+ const statusChar = getStatusChar(file.status);
110
+ const statusColor = getStatusColor(file.status);
111
+ const actionButton = file.staged ? '[-]' : '[+]';
112
+ const buttonColor = file.staged ? 'red' : 'green';
113
+ // Calculate available space for path
114
+ const stats = formatStats(file.insertions, file.deletions);
115
+ const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
116
+ const availableForPath = maxPathLength - statsLength;
117
+ const displayPath = shortenPath(file.path, availableForPath);
118
+ // Build the line
119
+ let line = '';
120
+ // Selection indicator
121
+ if (isHighlighted) {
122
+ line += '{cyan-fg}{bold}\u25b8 {/bold}{/cyan-fg}';
123
+ }
124
+ else {
125
+ line += ' ';
126
+ }
127
+ // Action button
128
+ line += `{${buttonColor}-fg}${actionButton}{/${buttonColor}-fg} `;
129
+ // Status character
130
+ line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
131
+ // File path (with highlighting)
132
+ if (isHighlighted) {
133
+ line += `{cyan-fg}{inverse}${displayPath}{/inverse}{/cyan-fg}`;
134
+ }
135
+ else {
136
+ line += displayPath;
137
+ }
138
+ // Original path for renames
139
+ if (file.originalPath) {
140
+ line += ` {gray-fg}\u2190 ${shortenPath(file.originalPath, 30)}{/gray-fg}`;
141
+ }
142
+ // Stats
143
+ line += stats;
144
+ lines.push(line);
145
+ }
146
+ }
147
+ return lines.join('\n');
148
+ }
149
+ /**
150
+ * Get the total number of rows in the file list (for scroll calculation).
151
+ */
152
+ export function getFileListTotalRows(files) {
153
+ return buildFileListRows(files).length;
154
+ }
155
+ /**
156
+ * Get the file at a specific index (accounting for category ordering).
157
+ */
158
+ export function getFileAtIndex(files, index) {
159
+ const { ordered } = categorizeFiles(files);
160
+ return ordered[index] ?? null;
161
+ }
162
+ /**
163
+ * Get the file index from a visual row (accounting for headers and spacers).
164
+ * Returns null if the row is a header or spacer.
165
+ */
166
+ export function getFileIndexFromRow(row, files) {
167
+ const rows = buildFileListRows(files);
168
+ const rowItem = rows[row];
169
+ if (rowItem?.type === 'file' && rowItem.fileIndex !== undefined) {
170
+ return rowItem.fileIndex;
171
+ }
172
+ return null;
173
+ }
174
+ /**
175
+ * Get the visual row index for a file index.
176
+ */
177
+ export function getRowFromFileIndex(fileIndex, files) {
178
+ const rows = buildFileListRows(files);
179
+ for (let i = 0; i < rows.length; i++) {
180
+ if (rows[i].type === 'file' && rows[i].fileIndex === fileIndex) {
181
+ return i;
182
+ }
183
+ }
184
+ return 0;
185
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Calculate visible length by stripping blessed tags.
3
+ */
4
+ function calculateVisibleLength(content) {
5
+ return content.replace(/\{[^}]+\}/g, '').length;
6
+ }
7
+ /**
8
+ * Format footer content as blessed-compatible tagged string.
9
+ */
10
+ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, width) {
11
+ // Left side: indicators
12
+ let leftContent = '{gray-fg}?{/gray-fg} ';
13
+ leftContent += mouseEnabled
14
+ ? '{yellow-fg}[scroll]{/yellow-fg}'
15
+ : '{yellow-fg}m:[select]{/yellow-fg}';
16
+ leftContent += ' ';
17
+ leftContent += autoTabEnabled ? '{blue-fg}[auto]{/blue-fg}' : '{gray-fg}[auto]{/gray-fg}';
18
+ leftContent += ' ';
19
+ leftContent += wrapMode ? '{blue-fg}[wrap]{/blue-fg}' : '{gray-fg}[wrap]{/gray-fg}';
20
+ leftContent += ' ';
21
+ leftContent += followEnabled ? '{blue-fg}[follow]{/blue-fg}' : '{gray-fg}[follow]{/gray-fg}';
22
+ if (activeTab === 'explorer') {
23
+ leftContent += ' ';
24
+ leftContent += showOnlyChanges
25
+ ? '{blue-fg}[changes]{/blue-fg}'
26
+ : '{gray-fg}[changes]{/gray-fg}';
27
+ }
28
+ // Right side: tabs
29
+ const tabs = [
30
+ { key: '1', label: 'Diff', tab: 'diff' },
31
+ { key: '2', label: 'Commit', tab: 'commit' },
32
+ { key: '3', label: 'History', tab: 'history' },
33
+ { key: '4', label: 'Compare', tab: 'compare' },
34
+ { key: '5', label: 'Explorer', tab: 'explorer' },
35
+ ];
36
+ const rightContent = tabs
37
+ .map(({ key, label, tab }) => {
38
+ const isActive = activeTab === tab;
39
+ if (isActive) {
40
+ return `{bold}{cyan-fg}[${key}]${label}{/cyan-fg}{/bold}`;
41
+ }
42
+ return `[${key}]${label}`;
43
+ })
44
+ .join(' ');
45
+ // Calculate padding for right alignment
46
+ const leftLen = calculateVisibleLength(leftContent);
47
+ const rightLen = calculateVisibleLength(rightContent);
48
+ const padding = Math.max(1, width - leftLen - rightLen);
49
+ return leftContent + ' '.repeat(padding) + rightContent;
50
+ }
@@ -0,0 +1,68 @@
1
+ import { abbreviateHomePath } from '../../config.js';
2
+ /**
3
+ * Calculate header height based on content.
4
+ * Currently always returns 1 (single line header).
5
+ */
6
+ export function getHeaderHeight() {
7
+ return 1;
8
+ }
9
+ /**
10
+ * Format branch info as blessed-compatible tagged string.
11
+ */
12
+ function formatBranch(branch) {
13
+ let result = `{bold}{green-fg}${branch.current}{/green-fg}{/bold}`;
14
+ if (branch.tracking) {
15
+ result += ` {gray-fg}\u2192{/gray-fg} {blue-fg}${branch.tracking}{/blue-fg}`;
16
+ }
17
+ if (branch.ahead > 0) {
18
+ result += ` {green-fg}\u2191${branch.ahead}{/green-fg}`;
19
+ }
20
+ if (branch.behind > 0) {
21
+ result += ` {red-fg}\u2193${branch.behind}{/red-fg}`;
22
+ }
23
+ return result;
24
+ }
25
+ /**
26
+ * Format header content as blessed-compatible tagged string.
27
+ */
28
+ export function formatHeader(repoPath, branch, isLoading, error, width) {
29
+ if (!repoPath) {
30
+ return '{gray-fg}Waiting for target path...{/gray-fg}';
31
+ }
32
+ const displayPath = abbreviateHomePath(repoPath);
33
+ const isNotGitRepo = error === 'Not a git repository';
34
+ // Build left side content
35
+ let leftContent = `{bold}{cyan-fg}${displayPath}{/cyan-fg}{/bold}`;
36
+ if (isLoading) {
37
+ leftContent += ' {yellow-fg}\u27f3{/yellow-fg}';
38
+ }
39
+ if (isNotGitRepo) {
40
+ leftContent += ' {yellow-fg}(not a git repository){/yellow-fg}';
41
+ }
42
+ else if (error) {
43
+ leftContent += ` {red-fg}(${error}){/red-fg}`;
44
+ }
45
+ // Build right side content (branch info)
46
+ const rightContent = branch ? formatBranch(branch) : '';
47
+ if (rightContent) {
48
+ // Calculate visible text length for left side (excluding ANSI/tags)
49
+ let leftLen = displayPath.length;
50
+ if (isLoading)
51
+ leftLen += 2; // " ⟳"
52
+ if (isNotGitRepo) {
53
+ leftLen += 24; // " (not a git repository)"
54
+ }
55
+ else if (error) {
56
+ leftLen += error.length + 3; // " (error)"
57
+ }
58
+ const rightLen = branch
59
+ ? branch.current.length +
60
+ (branch.tracking ? 3 + branch.tracking.length : 0) +
61
+ (branch.ahead > 0 ? 3 + String(branch.ahead).length : 0) +
62
+ (branch.behind > 0 ? 3 + String(branch.behind).length : 0)
63
+ : 0;
64
+ const padding = Math.max(1, width - leftLen - rightLen - 2);
65
+ return leftContent + ' '.repeat(padding) + rightContent;
66
+ }
67
+ return leftContent;
68
+ }
@@ -0,0 +1,69 @@
1
+ import { formatDate } from '../../utils/formatDate.js';
2
+ import { formatCommitDisplay } from '../../utils/commitFormat.js';
3
+ /**
4
+ * Format the history view as blessed-compatible tagged string.
5
+ */
6
+ export function formatHistoryView(commits, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
7
+ if (commits.length === 0) {
8
+ return '{gray-fg}No commits yet{/gray-fg}';
9
+ }
10
+ // Apply scroll offset and max height
11
+ const visibleCommits = maxHeight
12
+ ? commits.slice(scrollOffset, scrollOffset + maxHeight)
13
+ : commits.slice(scrollOffset);
14
+ const lines = [];
15
+ for (let i = 0; i < visibleCommits.length; i++) {
16
+ const commit = visibleCommits[i];
17
+ const actualIndex = scrollOffset + i;
18
+ const isSelected = actualIndex === selectedIndex;
19
+ const isHighlighted = isSelected && isFocused;
20
+ const dateStr = formatDate(commit.date);
21
+ // Fixed parts: hash(7) + spaces(4) + date + parens(2) + selection indicator(2)
22
+ const baseWidth = 7 + 4 + dateStr.length + 2 + 2;
23
+ const remainingWidth = Math.max(10, width - baseWidth);
24
+ const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
25
+ let line = '';
26
+ // Selection indicator
27
+ if (isHighlighted) {
28
+ line += '{cyan-fg}{bold}▸ {/bold}{/cyan-fg}';
29
+ }
30
+ else {
31
+ line += ' ';
32
+ }
33
+ // Short hash
34
+ line += `{yellow-fg}${commit.shortHash}{/yellow-fg} `;
35
+ // Message (with highlighting)
36
+ if (isHighlighted) {
37
+ line += `{cyan-fg}{inverse}${escapeContent(displayMessage)}{/inverse}{/cyan-fg}`;
38
+ }
39
+ else {
40
+ line += escapeContent(displayMessage);
41
+ }
42
+ // Date
43
+ line += ` {gray-fg}(${dateStr}){/gray-fg}`;
44
+ // Refs (branch names, tags)
45
+ if (displayRefs) {
46
+ line += ` {green-fg}${escapeContent(displayRefs)}{/green-fg}`;
47
+ }
48
+ lines.push(line);
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+ /**
53
+ * Escape blessed tags in content.
54
+ */
55
+ function escapeContent(content) {
56
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
57
+ }
58
+ /**
59
+ * Get the total number of rows in the history view (for scroll calculation).
60
+ */
61
+ export function getHistoryTotalRows(commits) {
62
+ return commits.length;
63
+ }
64
+ /**
65
+ * Get the commit at a specific index.
66
+ */
67
+ export function getCommitAtIndex(commits, index) {
68
+ return commits[index] ?? null;
69
+ }