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.
- package/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/.github/workflows/release.yml +8 -0
- package/README.md +43 -35
- package/bun.lock +82 -3
- package/dist/App.js +555 -552
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +228 -0
- package/dist/MouseHandlers.js +192 -0
- package/dist/core/ExplorerStateManager.js +423 -78
- package/dist/core/GitStateManager.js +260 -119
- package/dist/git/diff.js +102 -17
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +60 -53
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +39 -4
- package/dist/ui/PaneRenderers.js +76 -0
- package/dist/ui/modals/FileFinder.js +193 -0
- package/dist/ui/modals/HotkeysModal.js +12 -3
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +1 -1
- package/dist/ui/widgets/CompareListView.js +123 -80
- package/dist/ui/widgets/DiffView.js +228 -180
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +148 -43
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -11
- package/dist/ui/widgets/Header.js +17 -52
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +15 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/metrics/v0.2.2.json +229 -0
- package/package.json +9 -2
- package/dist/utils/ansiToBlessed.js +0 -125
- package/dist/utils/mouseCoordinates.js +0 -165
- 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
|
-
*
|
|
13
|
+
* Build tree prefix characters (│ ├ └).
|
|
3
14
|
*/
|
|
4
|
-
function
|
|
5
|
-
|
|
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
|
-
*
|
|
38
|
+
* Get status marker for git status.
|
|
9
39
|
*/
|
|
10
|
-
|
|
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 (
|
|
142
|
+
if (displayRows.length === 0) {
|
|
18
143
|
return '{gray-fg}(empty directory){/gray-fg}';
|
|
19
144
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 <
|
|
28
|
-
const item = visibleItems[i];
|
|
149
|
+
for (let i = 0; i < visibleRows.length; i++) {
|
|
29
150
|
const actualIndex = scrollOffset + i;
|
|
30
|
-
const
|
|
31
|
-
|
|
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(
|
|
88
|
-
return
|
|
192
|
+
export function getExplorerTotalRows(displayRows) {
|
|
193
|
+
return displayRows.length;
|
|
89
194
|
}
|
|
90
195
|
/**
|
|
91
|
-
* Get
|
|
196
|
+
* Get row at index.
|
|
92
197
|
*/
|
|
93
|
-
export function
|
|
94
|
-
return
|
|
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 {
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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,
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|