diffstalker 0.1.7 → 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 (62) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bun.lock +72 -312
  3. package/dist/App.js +1136 -515
  4. package/dist/core/ExplorerStateManager.js +266 -0
  5. package/dist/core/FilePathWatcher.js +133 -0
  6. package/dist/core/GitStateManager.js +75 -16
  7. package/dist/git/ignoreUtils.js +30 -0
  8. package/dist/git/status.js +2 -34
  9. package/dist/index.js +67 -53
  10. package/dist/ipc/CommandClient.js +165 -0
  11. package/dist/ipc/CommandServer.js +152 -0
  12. package/dist/state/CommitFlowState.js +86 -0
  13. package/dist/state/UIState.js +182 -0
  14. package/dist/types/tabs.js +4 -0
  15. package/dist/ui/Layout.js +252 -0
  16. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  17. package/dist/ui/modals/DiscardConfirm.js +77 -0
  18. package/dist/ui/modals/HotkeysModal.js +209 -0
  19. package/dist/ui/modals/ThemePicker.js +107 -0
  20. package/dist/ui/widgets/CommitPanel.js +58 -0
  21. package/dist/ui/widgets/CompareListView.js +216 -0
  22. package/dist/ui/widgets/DiffView.js +279 -0
  23. package/dist/ui/widgets/ExplorerContent.js +102 -0
  24. package/dist/ui/widgets/ExplorerView.js +95 -0
  25. package/dist/ui/widgets/FileList.js +185 -0
  26. package/dist/ui/widgets/Footer.js +46 -0
  27. package/dist/ui/widgets/Header.js +111 -0
  28. package/dist/ui/widgets/HistoryView.js +69 -0
  29. package/dist/utils/ansiToBlessed.js +125 -0
  30. package/dist/utils/displayRows.js +185 -6
  31. package/dist/utils/explorerDisplayRows.js +1 -1
  32. package/dist/utils/languageDetection.js +56 -0
  33. package/dist/utils/pathUtils.js +27 -0
  34. package/dist/utils/rowCalculations.js +37 -0
  35. package/dist/utils/wordDiff.js +50 -0
  36. package/package.json +11 -12
  37. package/dist/components/BaseBranchPicker.js +0 -60
  38. package/dist/components/BottomPane.js +0 -101
  39. package/dist/components/CommitPanel.js +0 -58
  40. package/dist/components/CompareListView.js +0 -110
  41. package/dist/components/ExplorerContentView.js +0 -80
  42. package/dist/components/ExplorerView.js +0 -37
  43. package/dist/components/FileList.js +0 -131
  44. package/dist/components/Footer.js +0 -6
  45. package/dist/components/Header.js +0 -107
  46. package/dist/components/HistoryView.js +0 -21
  47. package/dist/components/HotkeysModal.js +0 -108
  48. package/dist/components/Modal.js +0 -19
  49. package/dist/components/ScrollableList.js +0 -125
  50. package/dist/components/ThemePicker.js +0 -42
  51. package/dist/components/TopPane.js +0 -14
  52. package/dist/components/UnifiedDiffView.js +0 -115
  53. package/dist/hooks/useCommitFlow.js +0 -66
  54. package/dist/hooks/useCompareState.js +0 -123
  55. package/dist/hooks/useExplorerState.js +0 -248
  56. package/dist/hooks/useGit.js +0 -156
  57. package/dist/hooks/useHistoryState.js +0 -62
  58. package/dist/hooks/useKeymap.js +0 -167
  59. package/dist/hooks/useLayout.js +0 -154
  60. package/dist/hooks/useMouse.js +0 -87
  61. package/dist/hooks/useTerminalSize.js +0 -20
  62. package/dist/hooks/useWatcher.js +0 -137
@@ -0,0 +1,102 @@
1
+ import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, applyMiddleDots, } from '../../utils/explorerDisplayRows.js';
2
+ import { truncateAnsi } from '../../utils/ansiTruncate.js';
3
+ import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
4
+ /**
5
+ * Escape blessed tags in content.
6
+ */
7
+ function escapeContent(content) {
8
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
9
+ }
10
+ /**
11
+ * Format explorer file content as blessed-compatible tagged string.
12
+ */
13
+ export function formatExplorerContent(filePath, content, width, scrollOffset = 0, maxHeight, truncated = false, wrapMode = false, showMiddleDots = false) {
14
+ if (!filePath) {
15
+ return '{gray-fg}Select a file to view its contents{/gray-fg}';
16
+ }
17
+ if (!content) {
18
+ return '{gray-fg}Loading...{/gray-fg}';
19
+ }
20
+ // Build base rows with syntax highlighting
21
+ const baseRows = buildExplorerContentRows(content, filePath, truncated);
22
+ if (baseRows.length === 0) {
23
+ return '{gray-fg}(empty file){/gray-fg}';
24
+ }
25
+ // Calculate line number width
26
+ const lineNumWidth = getExplorerContentLineNumWidth(baseRows);
27
+ // Calculate content width for wrapping
28
+ // Layout: lineNum + space(1) + content
29
+ const contentWidth = width - lineNumWidth - 2;
30
+ // Apply wrapping if enabled
31
+ const displayRows = wrapExplorerContentRows(baseRows, contentWidth, wrapMode);
32
+ // Apply scroll offset and max height
33
+ const visibleRows = maxHeight
34
+ ? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
35
+ : displayRows.slice(scrollOffset);
36
+ const lines = [];
37
+ for (const row of visibleRows) {
38
+ if (row.type === 'truncation') {
39
+ lines.push(`{yellow-fg}${escapeContent(row.content)}{/yellow-fg}`);
40
+ continue;
41
+ }
42
+ // Code row
43
+ const isContinuation = row.isContinuation ?? false;
44
+ // Line number display
45
+ let lineNumDisplay;
46
+ if (isContinuation) {
47
+ lineNumDisplay = '>>'.padStart(lineNumWidth, ' ');
48
+ }
49
+ else {
50
+ lineNumDisplay = String(row.lineNum).padStart(lineNumWidth, ' ');
51
+ }
52
+ // Determine what content to display
53
+ const rawContent = row.content;
54
+ const shouldTruncate = !wrapMode && rawContent.length > contentWidth;
55
+ // Use highlighted content if available and not a continuation or middle-dots mode
56
+ const canUseHighlighting = row.highlighted && !isContinuation && !showMiddleDots;
57
+ let displayContent;
58
+ if (canUseHighlighting && row.highlighted) {
59
+ // Use ANSI-aware truncation to preserve syntax highlighting
60
+ const truncatedHighlight = shouldTruncate
61
+ ? truncateAnsi(row.highlighted, contentWidth)
62
+ : row.highlighted;
63
+ // Convert ANSI to blessed tags
64
+ displayContent = ansiToBlessed(truncatedHighlight);
65
+ }
66
+ else {
67
+ // Plain content path
68
+ let plainContent = rawContent;
69
+ // Apply middle-dots to raw content
70
+ if (showMiddleDots && !isContinuation) {
71
+ plainContent = applyMiddleDots(plainContent, true);
72
+ }
73
+ // Simple truncation for plain content
74
+ if (shouldTruncate) {
75
+ plainContent = plainContent.slice(0, Math.max(0, contentWidth - 1)) + '...';
76
+ }
77
+ displayContent = escapeContent(plainContent);
78
+ }
79
+ // Format line with line number
80
+ let line = '';
81
+ if (isContinuation) {
82
+ line = `{cyan-fg}${lineNumDisplay}{/cyan-fg} ${displayContent || ' '}`;
83
+ }
84
+ else {
85
+ line = `{gray-fg}${lineNumDisplay}{/gray-fg} ${displayContent || ' '}`;
86
+ }
87
+ lines.push(line);
88
+ }
89
+ return lines.join('\n');
90
+ }
91
+ /**
92
+ * Get total rows for scroll calculations.
93
+ * Accounts for wrap mode when calculating.
94
+ */
95
+ export function getExplorerContentTotalRows(content, filePath, truncated, width, wrapMode) {
96
+ if (!content)
97
+ return 0;
98
+ const rows = buildExplorerContentRows(content, filePath, truncated);
99
+ const lineNumWidth = getExplorerContentLineNumWidth(rows);
100
+ const contentWidth = width - lineNumWidth - 2;
101
+ return getExplorerContentRowCount(rows, contentWidth, wrapMode);
102
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Escape blessed tags in content.
3
+ */
4
+ function escapeContent(content) {
5
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
6
+ }
7
+ /**
8
+ * Format the explorer directory listing as blessed-compatible tagged string.
9
+ */
10
+ export function formatExplorerView(items, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, isLoading = false, error = null) {
11
+ if (error) {
12
+ return `{red-fg}Error: ${escapeContent(error)}{/red-fg}`;
13
+ }
14
+ if (isLoading) {
15
+ return '{gray-fg}Loading...{/gray-fg}';
16
+ }
17
+ if (items.length === 0) {
18
+ return '{gray-fg}(empty directory){/gray-fg}';
19
+ }
20
+ // Apply scroll offset and max height
21
+ const visibleItems = maxHeight
22
+ ? items.slice(scrollOffset, scrollOffset + maxHeight)
23
+ : items.slice(scrollOffset);
24
+ // Calculate max name width for alignment
25
+ const maxNameWidth = Math.min(Math.max(...items.map((item) => item.name.length + (item.isDirectory ? 1 : 0))), width - 10);
26
+ const lines = [];
27
+ for (let i = 0; i < visibleItems.length; i++) {
28
+ const item = visibleItems[i];
29
+ const actualIndex = scrollOffset + i;
30
+ const isSelected = actualIndex === selectedIndex;
31
+ const isHighlighted = isSelected && isFocused;
32
+ const displayName = item.isDirectory ? `${item.name}/` : item.name;
33
+ const paddedName = displayName.padEnd(maxNameWidth + 1);
34
+ let line = '';
35
+ if (isHighlighted) {
36
+ // Selected and focused - highlight with cyan
37
+ if (item.isDirectory) {
38
+ line = `{cyan-fg}{bold}{inverse}${escapeContent(paddedName)}{/inverse}{/bold}{/cyan-fg}`;
39
+ }
40
+ else {
41
+ line = `{cyan-fg}{bold}{inverse}${escapeContent(paddedName)}{/inverse}{/bold}{/cyan-fg}`;
42
+ }
43
+ }
44
+ else {
45
+ // Not selected or not focused
46
+ if (item.isDirectory) {
47
+ line = `{blue-fg}${escapeContent(paddedName)}{/blue-fg}`;
48
+ }
49
+ else {
50
+ line = escapeContent(paddedName);
51
+ }
52
+ }
53
+ lines.push(line);
54
+ }
55
+ return lines.join('\n');
56
+ }
57
+ /**
58
+ * Build breadcrumb segments from a path.
59
+ * Returns segments like ["src", "components"] for "src/components"
60
+ */
61
+ export function buildBreadcrumbs(currentPath) {
62
+ if (!currentPath)
63
+ return [];
64
+ return currentPath.split('/').filter(Boolean);
65
+ }
66
+ /**
67
+ * Format breadcrumbs for display.
68
+ */
69
+ export function formatBreadcrumbs(currentPath, repoName) {
70
+ const segments = buildBreadcrumbs(currentPath);
71
+ if (segments.length === 0) {
72
+ return `{bold}${escapeContent(repoName)}{/bold}`;
73
+ }
74
+ const parts = [repoName, ...segments];
75
+ return parts
76
+ .map((part, i) => {
77
+ if (i === parts.length - 1) {
78
+ return `{bold}${escapeContent(part)}{/bold}`;
79
+ }
80
+ return `{gray-fg}${escapeContent(part)}{/gray-fg}`;
81
+ })
82
+ .join('{gray-fg}/{/gray-fg}');
83
+ }
84
+ /**
85
+ * Get total rows in explorer for scroll calculations.
86
+ */
87
+ export function getExplorerTotalRows(items) {
88
+ return items.length;
89
+ }
90
+ /**
91
+ * Get item at index.
92
+ */
93
+ export function getExplorerItemAtIndex(items, index) {
94
+ return items[index] ?? null;
95
+ }
@@ -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,46 @@
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, showMiddleDots, 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
+ if (activeTab === 'explorer') {
21
+ leftContent += ' ';
22
+ leftContent += showMiddleDots ? '{blue-fg}[dots]{/blue-fg}' : '{gray-fg}[dots]{/gray-fg}';
23
+ }
24
+ // Right side: tabs
25
+ const tabs = [
26
+ { key: '1', label: 'Diff', tab: 'diff' },
27
+ { key: '2', label: 'Commit', tab: 'commit' },
28
+ { key: '3', label: 'History', tab: 'history' },
29
+ { key: '4', label: 'Compare', tab: 'compare' },
30
+ { key: '5', label: 'Explorer', tab: 'explorer' },
31
+ ];
32
+ const rightContent = tabs
33
+ .map(({ key, label, tab }) => {
34
+ const isActive = activeTab === tab;
35
+ if (isActive) {
36
+ return `{bold}{cyan-fg}[${key}]${label}{/cyan-fg}{/bold}`;
37
+ }
38
+ return `[${key}]${label}`;
39
+ })
40
+ .join(' ');
41
+ // Calculate padding for right alignment
42
+ const leftLen = calculateVisibleLength(leftContent);
43
+ const rightLen = calculateVisibleLength(rightContent);
44
+ const padding = Math.max(1, width - leftLen - rightLen);
45
+ return leftContent + ' '.repeat(padding) + rightContent;
46
+ }
@@ -0,0 +1,111 @@
1
+ import { abbreviateHomePath } from '../../config.js';
2
+ /**
3
+ * Calculate header height based on content.
4
+ */
5
+ export function getHeaderHeight(repoPath, branch, watcherState, width, error = null, isLoading = false) {
6
+ if (!repoPath)
7
+ return 1;
8
+ const displayPath = abbreviateHomePath(repoPath);
9
+ const isNotGitRepo = error === 'Not a git repository';
10
+ // Calculate branch width
11
+ let branchWidth = 0;
12
+ if (branch) {
13
+ branchWidth = branch.current.length;
14
+ if (branch.tracking)
15
+ branchWidth += 3 + branch.tracking.length;
16
+ if (branch.ahead > 0)
17
+ branchWidth += 3 + String(branch.ahead).length;
18
+ if (branch.behind > 0)
19
+ branchWidth += 3 + String(branch.behind).length;
20
+ }
21
+ // Calculate left side width
22
+ let leftWidth = displayPath.length;
23
+ if (isLoading)
24
+ leftWidth += 2;
25
+ if (isNotGitRepo)
26
+ leftWidth += 24;
27
+ if (error && !isNotGitRepo)
28
+ leftWidth += error.length + 3;
29
+ // Check if follow indicator causes wrap
30
+ if (watcherState?.enabled && watcherState.sourceFile) {
31
+ const followPath = abbreviateHomePath(watcherState.sourceFile);
32
+ const fullFollow = ` (follow: ${followPath})`;
33
+ const availableOneLine = width - leftWidth - branchWidth - 4;
34
+ if (fullFollow.length > availableOneLine) {
35
+ const availableWithWrap = width - leftWidth - 2;
36
+ if (fullFollow.length <= availableWithWrap) {
37
+ return 2;
38
+ }
39
+ }
40
+ }
41
+ return 1;
42
+ }
43
+ /**
44
+ * Format branch info as blessed-compatible tagged string.
45
+ */
46
+ function formatBranch(branch) {
47
+ let result = `{bold}{green-fg}${branch.current}{/green-fg}{/bold}`;
48
+ if (branch.tracking) {
49
+ result += ` {gray-fg}\u2192{/gray-fg} {blue-fg}${branch.tracking}{/blue-fg}`;
50
+ }
51
+ if (branch.ahead > 0) {
52
+ result += ` {green-fg}\u2191${branch.ahead}{/green-fg}`;
53
+ }
54
+ if (branch.behind > 0) {
55
+ result += ` {red-fg}\u2193${branch.behind}{/red-fg}`;
56
+ }
57
+ return result;
58
+ }
59
+ /**
60
+ * Format header content as blessed-compatible tagged string.
61
+ */
62
+ export function formatHeader(repoPath, branch, isLoading, error, watcherState, width) {
63
+ if (!repoPath) {
64
+ return '{gray-fg}Waiting for target path...{/gray-fg}';
65
+ }
66
+ const displayPath = abbreviateHomePath(repoPath);
67
+ const isNotGitRepo = error === 'Not a git repository';
68
+ // Build left side content
69
+ let leftContent = `{bold}{cyan-fg}${displayPath}{/cyan-fg}{/bold}`;
70
+ if (isLoading) {
71
+ leftContent += ' {yellow-fg}\u27f3{/yellow-fg}';
72
+ }
73
+ if (isNotGitRepo) {
74
+ leftContent += ' {yellow-fg}(not a git repository){/yellow-fg}';
75
+ }
76
+ else if (error) {
77
+ leftContent += ` {red-fg}(${error}){/red-fg}`;
78
+ }
79
+ // Add follow indicator if enabled
80
+ if (watcherState?.enabled && watcherState.sourceFile) {
81
+ const followPath = abbreviateHomePath(watcherState.sourceFile);
82
+ leftContent += ` {gray-fg}(follow: ${followPath}){/gray-fg}`;
83
+ }
84
+ // Build right side content (branch info)
85
+ const rightContent = branch ? formatBranch(branch) : '';
86
+ if (rightContent) {
87
+ // Calculate visible text length for left side (excluding ANSI/tags)
88
+ let leftLen = displayPath.length;
89
+ if (isLoading)
90
+ leftLen += 2; // " ⟳"
91
+ if (isNotGitRepo) {
92
+ leftLen += 24; // " (not a git repository)"
93
+ }
94
+ else if (error) {
95
+ leftLen += error.length + 3; // " (error)"
96
+ }
97
+ if (watcherState?.enabled && watcherState.sourceFile) {
98
+ const followPath = abbreviateHomePath(watcherState.sourceFile);
99
+ leftLen += 10 + followPath.length; // " (follow: path)"
100
+ }
101
+ const rightLen = branch
102
+ ? branch.current.length +
103
+ (branch.tracking ? 3 + branch.tracking.length : 0) +
104
+ (branch.ahead > 0 ? 3 + String(branch.ahead).length : 0) +
105
+ (branch.behind > 0 ? 3 + String(branch.behind).length : 0)
106
+ : 0;
107
+ const padding = Math.max(1, width - leftLen - rightLen - 2);
108
+ return leftContent + ' '.repeat(padding) + rightContent;
109
+ }
110
+ return leftContent;
111
+ }
@@ -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
+ }