diffstalker 0.2.0 → 0.2.2

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 (46) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. package/dist/utils/rowCalculations.js +0 -246
@@ -1,59 +1,164 @@
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';
1
12
  /**
2
- * Escape blessed tags in content.
13
+ * Build tree prefix characters (│ ├ └).
3
14
  */
4
- function escapeContent(content) {
5
- return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
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;
6
36
  }
7
37
  /**
8
- * Format the explorer directory listing as blessed-compatible tagged string.
38
+ * Get status marker for git status.
9
39
  */
10
- export function formatExplorerView(items, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, isLoading = false, error = null) {
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 a single explorer row as a raw ANSI string.
85
+ */
86
+ function formatExplorerRow(row, isSelected, isFocused, width) {
87
+ const isHighlighted = isSelected && isFocused;
88
+ const node = row.node;
89
+ const prefix = buildTreePrefix(row);
90
+ let icon = '';
91
+ if (node.isDirectory) {
92
+ icon = node.expanded ? '▾ ' : '▸ ';
93
+ }
94
+ const statusMarker = getStatusMarker(node.gitStatus);
95
+ const statusColor = getStatusColor(node.gitStatus);
96
+ const statusDisplay = statusMarker ? `${statusColor}${statusMarker}${ANSI_RESET} ` : '';
97
+ const dirStatusDisplay = node.isDirectory && node.hasChangedChildren ? `${ANSI_YELLOW}●${ANSI_RESET} ` : '';
98
+ const prefixLen = prefix.length +
99
+ icon.length +
100
+ (statusMarker ? 2 : 0) +
101
+ (node.hasChangedChildren && node.isDirectory ? 2 : 0);
102
+ const maxNameLen = Math.max(5, width - prefixLen - 2);
103
+ let displayName = node.isDirectory ? `${node.name}/` : node.name;
104
+ if (displayName.length > maxNameLen) {
105
+ displayName = displayName.slice(0, maxNameLen - 1) + '…';
106
+ }
107
+ let line = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
108
+ if (node.isDirectory) {
109
+ line += `${ANSI_BLUE}${icon}${ANSI_RESET}`;
110
+ line += dirStatusDisplay;
111
+ if (isHighlighted) {
112
+ line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
113
+ }
114
+ else {
115
+ line += `${ANSI_BLUE}${displayName}${ANSI_RESET}`;
116
+ }
117
+ }
118
+ else {
119
+ line += statusDisplay;
120
+ if (isHighlighted) {
121
+ line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
122
+ }
123
+ else if (node.gitStatus) {
124
+ line += `${statusColor}${displayName}${ANSI_RESET}`;
125
+ }
126
+ else {
127
+ line += displayName;
128
+ }
129
+ }
130
+ return line;
131
+ }
132
+ /**
133
+ * Format the explorer tree view as blessed-compatible tagged string.
134
+ */
135
+ export function formatExplorerView(displayRows, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, isLoading = false, error = null) {
11
136
  if (error) {
12
137
  return `{red-fg}Error: ${escapeContent(error)}{/red-fg}`;
13
138
  }
14
139
  if (isLoading) {
15
140
  return '{gray-fg}Loading...{/gray-fg}';
16
141
  }
17
- if (items.length === 0) {
142
+ if (displayRows.length === 0) {
18
143
  return '{gray-fg}(empty directory){/gray-fg}';
19
144
  }
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);
145
+ const visibleRows = maxHeight
146
+ ? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
147
+ : displayRows.slice(scrollOffset);
26
148
  const lines = [];
27
- for (let i = 0; i < visibleItems.length; i++) {
28
- const item = visibleItems[i];
149
+ for (let i = 0; i < visibleRows.length; i++) {
29
150
  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);
151
+ const line = formatExplorerRow(visibleRows[i], actualIndex === selectedIndex, isFocused, width);
152
+ lines.push(`{escape}${line}{/escape}`);
54
153
  }
55
154
  return lines.join('\n');
56
155
  }
156
+ /**
157
+ * Escape blessed tags in content.
158
+ */
159
+ function escapeContent(content) {
160
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
161
+ }
57
162
  /**
58
163
  * Build breadcrumb segments from a path.
59
164
  * Returns segments like ["src", "components"] for "src/components"
@@ -84,12 +189,12 @@ export function formatBreadcrumbs(currentPath, repoName) {
84
189
  /**
85
190
  * Get total rows in explorer for scroll calculations.
86
191
  */
87
- export function getExplorerTotalRows(items) {
88
- return items.length;
192
+ export function getExplorerTotalRows(displayRows) {
193
+ return displayRows.length;
89
194
  }
90
195
  /**
91
- * Get item at index.
196
+ * Get row at index.
92
197
  */
93
- export function getExplorerItemAtIndex(items, index) {
94
- return items[index] ?? null;
198
+ export function getExplorerRowAtIndex(displayRows, index) {
199
+ return displayRows[index] ?? null;
95
200
  }
@@ -1,53 +1,5 @@
1
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
- }
2
+ import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
51
3
  /**
52
4
  * Build the list of row items for the file list.
53
5
  */
@@ -81,10 +33,52 @@ export function buildFileListRows(files) {
81
33
  }
82
34
  return rows;
83
35
  }
36
+ /**
37
+ * Format a single file row as blessed-compatible tagged string.
38
+ */
39
+ /**
40
+ * Format hunk count indicator for a file, e.g. "@2/3".
41
+ * Returns empty string if not applicable.
42
+ */
43
+ function formatHunkIndicator(file, hunkCounts) {
44
+ if (!hunkCounts)
45
+ return '';
46
+ const stagedHunks = hunkCounts.staged.get(file.path) ?? 0;
47
+ const unstagedHunks = hunkCounts.unstaged.get(file.path) ?? 0;
48
+ const total = stagedHunks + unstagedHunks;
49
+ if (total === 0)
50
+ return '';
51
+ const thisCount = file.staged ? stagedHunks : unstagedHunks;
52
+ // Show just @total when all hunks are in this state, otherwise @n/total
53
+ if (thisCount === total)
54
+ return ` {cyan-fg}@${total}{/cyan-fg}`;
55
+ return ` {cyan-fg}@${thisCount}/${total}{/cyan-fg}`;
56
+ }
57
+ function formatFileRow(file, fileIndex, selectedIndex, isFocused, maxPathLength, hunkCounts) {
58
+ const isSelected = fileIndex === selectedIndex;
59
+ const statusChar = getStatusChar(file.status);
60
+ const statusColor = getStatusColor(file.status);
61
+ const actionButton = file.staged ? '[-]' : '[+]';
62
+ const buttonColor = file.staged ? 'red' : 'green';
63
+ // Calculate available space for path
64
+ const stats = formatStats(file.insertions, file.deletions);
65
+ const hunkIndicator = formatHunkIndicator(file, hunkCounts);
66
+ const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
67
+ const hunkLength = hunkIndicator.replace(/\{[^}]+\}/g, '').length;
68
+ const availableForPath = maxPathLength - statsLength - hunkLength;
69
+ let line = formatSelectionIndicator(isSelected, isFocused);
70
+ line += `{${buttonColor}-fg}${actionButton}{/${buttonColor}-fg} `;
71
+ line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
72
+ line += formatFilePath(file.path, isSelected, isFocused, availableForPath);
73
+ line += formatOriginalPath(file.originalPath);
74
+ line += stats;
75
+ line += hunkIndicator;
76
+ return line;
77
+ }
84
78
  /**
85
79
  * Format the file list as blessed-compatible tagged string.
86
80
  */
87
- export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
81
+ export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, hunkCounts) {
88
82
  if (files.length === 0) {
89
83
  return '{gray-fg} No changes{/gray-fg}';
90
84
  }
@@ -95,53 +89,26 @@ export function formatFileList(files, selectedIndex, isFocused, width, scrollOff
95
89
  ? rows.slice(scrollOffset, scrollOffset + maxHeight)
96
90
  : rows.slice(scrollOffset);
97
91
  const lines = [];
92
+ let seenFirstHeader = false;
98
93
  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}`;
94
+ switch (row.type) {
95
+ case 'header': {
96
+ let headerLine = `{bold}{${row.headerColor}-fg}${row.content}{/${row.headerColor}-fg}{/bold}`;
97
+ if (!seenFirstHeader) {
98
+ seenFirstHeader = true;
99
+ headerLine += ' {gray-fg}(h:flat){/gray-fg}';
100
+ }
101
+ lines.push(headerLine);
102
+ break;
141
103
  }
142
- // Stats
143
- line += stats;
144
- lines.push(line);
104
+ case 'spacer':
105
+ lines.push('');
106
+ break;
107
+ case 'file':
108
+ if (row.file && row.fileIndex !== undefined) {
109
+ lines.push(formatFileRow(row.file, row.fileIndex, selectedIndex, isFocused, maxPathLength, hunkCounts ?? null));
110
+ }
111
+ break;
145
112
  }
146
113
  }
147
114
  return lines.join('\n');
@@ -0,0 +1,65 @@
1
+ import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
2
+ function getStagingButton(state) {
3
+ switch (state) {
4
+ case 'unstaged':
5
+ return { text: '[+]', color: 'green' };
6
+ case 'staged':
7
+ return { text: '[-]', color: 'red' };
8
+ case 'partial':
9
+ return { text: '[~]', color: 'yellow' };
10
+ }
11
+ }
12
+ function formatFlatHunkIndicator(entry) {
13
+ if (entry.totalHunks === 0)
14
+ return '';
15
+ // Always show staged/total in flat view
16
+ return ` {cyan-fg}@${entry.stagedHunks}/${entry.totalHunks}{/cyan-fg}`;
17
+ }
18
+ function formatFlatFileRow(entry, index, selectedIndex, isFocused, maxPathLength) {
19
+ const isSelected = index === selectedIndex;
20
+ const statusChar = getStatusChar(entry.status);
21
+ const statusColor = getStatusColor(entry.status);
22
+ const button = getStagingButton(entry.stagingState);
23
+ const stats = formatStats(entry.insertions, entry.deletions);
24
+ const hunkIndicator = formatFlatHunkIndicator(entry);
25
+ const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
26
+ const hunkLength = hunkIndicator.replace(/\{[^}]+\}/g, '').length;
27
+ const availableForPath = maxPathLength - statsLength - hunkLength;
28
+ let line = formatSelectionIndicator(isSelected, isFocused);
29
+ line += `{${button.color}-fg}${button.text}{/${button.color}-fg} `;
30
+ line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
31
+ line += formatFilePath(entry.path, isSelected, isFocused, availableForPath);
32
+ line += formatOriginalPath(entry.originalPath);
33
+ line += stats;
34
+ line += hunkIndicator;
35
+ return line;
36
+ }
37
+ /**
38
+ * Format the flat file list as blessed-compatible tagged string.
39
+ * Row 0 is a header; files start at row 1.
40
+ */
41
+ export function formatFlatFileList(flatFiles, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
42
+ if (flatFiles.length === 0) {
43
+ return '{gray-fg} No changes{/gray-fg}';
44
+ }
45
+ const maxPathLength = width - 12;
46
+ // Build all rows: header + file rows
47
+ const allRows = [];
48
+ allRows.push('{bold}{gray-fg}All files (h):{/gray-fg}{/bold}');
49
+ for (let i = 0; i < flatFiles.length; i++) {
50
+ allRows.push(formatFlatFileRow(flatFiles[i], i, selectedIndex, isFocused, maxPathLength));
51
+ }
52
+ // Apply scroll offset and max height
53
+ const visibleRows = maxHeight
54
+ ? allRows.slice(scrollOffset, scrollOffset + maxHeight)
55
+ : allRows.slice(scrollOffset);
56
+ return visibleRows.join('\n');
57
+ }
58
+ /**
59
+ * Total rows in the flat file list (header + files).
60
+ */
61
+ export function getFlatFileListTotalRows(flatFiles) {
62
+ if (flatFiles.length === 0)
63
+ return 0;
64
+ return flatFiles.length + 1; // +1 for header
65
+ }
@@ -4,22 +4,36 @@
4
4
  function calculateVisibleLength(content) {
5
5
  return content.replace(/\{[^}]+\}/g, '').length;
6
6
  }
7
+ /**
8
+ * Format a toggle indicator: blue when on, gray when off.
9
+ */
10
+ function toggleIndicator(label, enabled) {
11
+ return enabled ? `{blue-fg}[${label}]{/blue-fg}` : `{gray-fg}[${label}]{/gray-fg}`;
12
+ }
13
+ /**
14
+ * Build the left-side indicators for the standard (non-hunk) footer.
15
+ */
16
+ function buildStandardIndicators(mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, activeTab) {
17
+ const parts = [];
18
+ parts.push(mouseEnabled ? '{yellow-fg}[scroll]{/yellow-fg}' : '{yellow-fg}m:[select]{/yellow-fg}');
19
+ parts.push(toggleIndicator('auto', autoTabEnabled));
20
+ parts.push(toggleIndicator('wrap', wrapMode));
21
+ parts.push(toggleIndicator('follow', followEnabled));
22
+ if (activeTab === 'explorer') {
23
+ parts.push(toggleIndicator('changes', showOnlyChanges));
24
+ }
25
+ return parts.join(' ');
26
+ }
7
27
  /**
8
28
  * Format footer content as blessed-compatible tagged string.
9
29
  */
10
- export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, showMiddleDots, width) {
30
+ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, width, currentPane) {
11
31
  // Left side: indicators
12
32
  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}';
33
+ leftContent += buildStandardIndicators(mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, activeTab);
34
+ // Show hunk key hints when diff pane is focused on diff tab
35
+ if (activeTab === 'diff' && currentPane === 'diff') {
36
+ leftContent += ' {gray-fg}n/N:hunk s:toggle{/gray-fg}';
23
37
  }
24
38
  // Right side: tabs
25
39
  const tabs = [
@@ -1,43 +1,9 @@
1
1
  import { abbreviateHomePath } from '../../config.js';
2
2
  /**
3
3
  * Calculate header height based on content.
4
+ * Currently always returns 1 (single line header).
4
5
  */
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
- }
6
+ export function getHeaderHeight() {
41
7
  return 1;
42
8
  }
43
9
  /**
@@ -56,10 +22,23 @@ function formatBranch(branch) {
56
22
  }
57
23
  return result;
58
24
  }
25
+ function computeBranchVisibleLength(branch) {
26
+ let len = branch.current.length;
27
+ if (branch.tracking) {
28
+ len += 3 + branch.tracking.length;
29
+ }
30
+ if (branch.ahead > 0) {
31
+ len += 3 + String(branch.ahead).length;
32
+ }
33
+ if (branch.behind > 0) {
34
+ len += 3 + String(branch.behind).length;
35
+ }
36
+ return len;
37
+ }
59
38
  /**
60
39
  * Format header content as blessed-compatible tagged string.
61
40
  */
62
- export function formatHeader(repoPath, branch, isLoading, error, watcherState, width) {
41
+ export function formatHeader(repoPath, branch, isLoading, error, width) {
63
42
  if (!repoPath) {
64
43
  return '{gray-fg}Waiting for target path...{/gray-fg}';
65
44
  }
@@ -76,11 +55,6 @@ export function formatHeader(repoPath, branch, isLoading, error, watcherState, w
76
55
  else if (error) {
77
56
  leftContent += ` {red-fg}(${error}){/red-fg}`;
78
57
  }
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
58
  // Build right side content (branch info)
85
59
  const rightContent = branch ? formatBranch(branch) : '';
86
60
  if (rightContent) {
@@ -94,16 +68,7 @@ export function formatHeader(repoPath, branch, isLoading, error, watcherState, w
94
68
  else if (error) {
95
69
  leftLen += error.length + 3; // " (error)"
96
70
  }
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;
71
+ const rightLen = branch ? computeBranchVisibleLength(branch) : 0;
107
72
  const padding = Math.max(1, width - leftLen - rightLen - 2);
108
73
  return leftContent + ' '.repeat(padding) + rightContent;
109
74
  }
@@ -0,0 +1,73 @@
1
+ import { shortenPath } from '../../utils/formatPath.js';
2
+ export function getStatusChar(status) {
3
+ switch (status) {
4
+ case 'modified':
5
+ return 'M';
6
+ case 'added':
7
+ return 'A';
8
+ case 'deleted':
9
+ return 'D';
10
+ case 'untracked':
11
+ return '?';
12
+ case 'renamed':
13
+ return 'R';
14
+ case 'copied':
15
+ return 'C';
16
+ default:
17
+ return ' ';
18
+ }
19
+ }
20
+ export function getStatusColor(status) {
21
+ switch (status) {
22
+ case 'modified':
23
+ return 'yellow';
24
+ case 'added':
25
+ return 'green';
26
+ case 'deleted':
27
+ return 'red';
28
+ case 'untracked':
29
+ return 'gray';
30
+ case 'renamed':
31
+ return 'blue';
32
+ case 'copied':
33
+ return 'cyan';
34
+ default:
35
+ return 'white';
36
+ }
37
+ }
38
+ export function formatStats(insertions, deletions) {
39
+ if (insertions === undefined && deletions === undefined)
40
+ return '';
41
+ const parts = [];
42
+ if (insertions !== undefined && insertions > 0) {
43
+ parts.push(`{green-fg}+${insertions}{/green-fg}`);
44
+ }
45
+ if (deletions !== undefined && deletions > 0) {
46
+ parts.push(`{red-fg}-${deletions}{/red-fg}`);
47
+ }
48
+ return parts.length > 0 ? ' ' + parts.join(' ') : '';
49
+ }
50
+ export function formatSelectionIndicator(isSelected, isFocused) {
51
+ if (isSelected && isFocused) {
52
+ return '{cyan-fg}{bold}\u25b8 {/bold}{/cyan-fg}';
53
+ }
54
+ else if (isSelected) {
55
+ return '{gray-fg}\u25b8 {/gray-fg}';
56
+ }
57
+ return ' ';
58
+ }
59
+ export function formatFilePath(path, isSelected, isFocused, maxLength) {
60
+ const displayPath = shortenPath(path, maxLength);
61
+ if (isSelected && isFocused) {
62
+ return `{cyan-fg}{inverse}${displayPath}{/inverse}{/cyan-fg}`;
63
+ }
64
+ else if (isSelected) {
65
+ return `{cyan-fg}${displayPath}{/cyan-fg}`;
66
+ }
67
+ return displayPath;
68
+ }
69
+ export function formatOriginalPath(originalPath) {
70
+ if (!originalPath)
71
+ return '';
72
+ return ` {gray-fg}\u2190 ${shortenPath(originalPath, 30)}{/gray-fg}`;
73
+ }