diffstalker 0.2.1 → 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/README.md +43 -35
- package/bun.lock +60 -4
- package/dist/App.js +378 -129
- package/dist/KeyBindings.js +59 -9
- package/dist/MouseHandlers.js +56 -20
- package/dist/core/ExplorerStateManager.js +17 -38
- package/dist/core/GitStateManager.js +111 -46
- package/dist/git/diff.js +99 -18
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +53 -47
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +22 -0
- package/dist/ui/PaneRenderers.js +33 -13
- package/dist/ui/modals/FileFinder.js +26 -65
- 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 +44 -23
- package/dist/ui/widgets/DiffView.js +216 -170
- package/dist/ui/widgets/ExplorerView.js +50 -54
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -15
- package/dist/ui/widgets/Header.js +14 -6
- 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/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +0 -1
- package/metrics/v0.2.2.json +229 -0
- package/package.json +6 -2
|
@@ -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,26 +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, followEnabled, showOnlyChanges, 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
|
-
|
|
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
|
-
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}';
|
|
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}';
|
|
27
37
|
}
|
|
28
38
|
// Right side: tabs
|
|
29
39
|
const tabs = [
|
|
@@ -22,6 +22,19 @@ function formatBranch(branch) {
|
|
|
22
22
|
}
|
|
23
23
|
return result;
|
|
24
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
|
+
}
|
|
25
38
|
/**
|
|
26
39
|
* Format header content as blessed-compatible tagged string.
|
|
27
40
|
*/
|
|
@@ -55,12 +68,7 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
|
|
|
55
68
|
else if (error) {
|
|
56
69
|
leftLen += error.length + 3; // " (error)"
|
|
57
70
|
}
|
|
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;
|
|
71
|
+
const rightLen = branch ? computeBranchVisibleLength(branch) : 0;
|
|
64
72
|
const padding = Math.max(1, width - leftLen - rightLen - 2);
|
|
65
73
|
return leftContent + ' '.repeat(padding) + rightContent;
|
|
66
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
|
+
}
|
|
@@ -4,7 +4,7 @@ import { formatDateAbsolute } from './formatDate.js';
|
|
|
4
4
|
import { isDisplayableDiffLine } from './diffFilters.js';
|
|
5
5
|
import { breakLine, getLineRowCount } from './lineBreaking.js';
|
|
6
6
|
import { computeWordDiff, areSimilarEnough } from './wordDiff.js';
|
|
7
|
-
import { getLanguageFromPath, highlightBlockPreserveBg
|
|
7
|
+
import { getLanguageFromPath, highlightBlockPreserveBg } from './languageDetection.js';
|
|
8
8
|
/**
|
|
9
9
|
* Get the text content from a diff line (strip leading +/-/space and control chars)
|
|
10
10
|
*/
|
|
@@ -73,8 +73,7 @@ export function buildDiffDisplayRows(diff) {
|
|
|
73
73
|
const rows = [];
|
|
74
74
|
const fileSections = [];
|
|
75
75
|
let currentSection = null;
|
|
76
|
-
// Phase 1: Build display rows
|
|
77
|
-
// Also collect content for block highlighting
|
|
76
|
+
// Phase 1: Build display rows and collect content streams per file section
|
|
78
77
|
let i = 0;
|
|
79
78
|
while (i < filteredLines.length) {
|
|
80
79
|
const line = filteredLines[i];
|
|
@@ -82,13 +81,10 @@ export function buildDiffDisplayRows(diff) {
|
|
|
82
81
|
if (line.type === 'header') {
|
|
83
82
|
const filePath = extractFilePathFromHeader(line.content);
|
|
84
83
|
if (filePath) {
|
|
85
|
-
// Save previous section if any
|
|
86
84
|
if (currentSection) {
|
|
87
85
|
fileSections.push(currentSection);
|
|
88
|
-
// Add spacer between files for visual separation
|
|
89
86
|
rows.push({ type: 'spacer' });
|
|
90
87
|
}
|
|
91
|
-
// Start new section
|
|
92
88
|
currentSection = {
|
|
93
89
|
language: getLanguageFromPath(filePath),
|
|
94
90
|
startRowIndex: rows.length,
|
|
@@ -102,7 +98,6 @@ export function buildDiffDisplayRows(diff) {
|
|
|
102
98
|
i++;
|
|
103
99
|
continue;
|
|
104
100
|
}
|
|
105
|
-
// Hunks - just convert (don't highlight)
|
|
106
101
|
if (line.type === 'hunk') {
|
|
107
102
|
rows.push(convertDiffLineToDisplayRow(line));
|
|
108
103
|
i++;
|
|
@@ -117,7 +112,6 @@ export function buildDiffDisplayRows(diff) {
|
|
|
117
112
|
lineNum: line.oldLineNum ?? line.newLineNum,
|
|
118
113
|
content,
|
|
119
114
|
});
|
|
120
|
-
// Context appears in both old and new streams
|
|
121
115
|
if (currentSection && currentSection.language) {
|
|
122
116
|
currentSection.oldContent.push(content);
|
|
123
117
|
currentSection.oldRowIndices.push(rowIndex);
|
|
@@ -139,22 +133,19 @@ export function buildDiffDisplayRows(diff) {
|
|
|
139
133
|
additions.push(filteredLines[i]);
|
|
140
134
|
i++;
|
|
141
135
|
}
|
|
142
|
-
//
|
|
136
|
+
// Pair deletions with additions for word-level diff
|
|
143
137
|
const delSegmentsMap = new Map();
|
|
144
138
|
const addSegmentsMap = new Map();
|
|
145
|
-
// Pair deletions with additions for word-level diff (only if similar enough)
|
|
146
139
|
const pairCount = Math.min(deletions.length, additions.length);
|
|
147
140
|
for (let j = 0; j < pairCount; j++) {
|
|
148
141
|
const delContent = getLineContent(deletions[j]);
|
|
149
142
|
const addContent = getLineContent(additions[j]);
|
|
150
|
-
// Only compute word diff if lines are similar enough
|
|
151
143
|
if (areSimilarEnough(delContent, addContent)) {
|
|
152
144
|
const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
|
|
153
145
|
delSegmentsMap.set(j, oldSegments);
|
|
154
146
|
addSegmentsMap.set(j, newSegments);
|
|
155
147
|
}
|
|
156
148
|
}
|
|
157
|
-
// Output deletions first (preserving original diff order)
|
|
158
149
|
for (let j = 0; j < deletions.length; j++) {
|
|
159
150
|
const delLine = deletions[j];
|
|
160
151
|
const delContent = getLineContent(delLine);
|
|
@@ -166,13 +157,11 @@ export function buildDiffDisplayRows(diff) {
|
|
|
166
157
|
content: delContent,
|
|
167
158
|
...(segments && { wordDiffSegments: segments }),
|
|
168
159
|
});
|
|
169
|
-
// Add to old stream (only if no word-diff, as word-diff takes priority)
|
|
170
160
|
if (currentSection && currentSection.language && !segments) {
|
|
171
161
|
currentSection.oldContent.push(delContent);
|
|
172
162
|
currentSection.oldRowIndices.push(rowIndex);
|
|
173
163
|
}
|
|
174
164
|
}
|
|
175
|
-
// Then output additions
|
|
176
165
|
for (let j = 0; j < additions.length; j++) {
|
|
177
166
|
const addLine = additions[j];
|
|
178
167
|
const addContent = getLineContent(addLine);
|
|
@@ -184,14 +173,12 @@ export function buildDiffDisplayRows(diff) {
|
|
|
184
173
|
content: addContent,
|
|
185
174
|
...(segments && { wordDiffSegments: segments }),
|
|
186
175
|
});
|
|
187
|
-
// Add to new stream (only if no word-diff, as word-diff takes priority)
|
|
188
176
|
if (currentSection && currentSection.language && !segments) {
|
|
189
177
|
currentSection.newContent.push(addContent);
|
|
190
178
|
currentSection.newRowIndices.push(rowIndex);
|
|
191
179
|
}
|
|
192
180
|
}
|
|
193
181
|
}
|
|
194
|
-
// Save final section
|
|
195
182
|
if (currentSection) {
|
|
196
183
|
fileSections.push(currentSection);
|
|
197
184
|
}
|
|
@@ -199,14 +186,12 @@ export function buildDiffDisplayRows(diff) {
|
|
|
199
186
|
for (const section of fileSections) {
|
|
200
187
|
if (!section.language)
|
|
201
188
|
continue;
|
|
202
|
-
// Highlight old stream (context + deletions)
|
|
203
189
|
if (section.oldContent.length > 0) {
|
|
204
190
|
const oldHighlighted = highlightBlockPreserveBg(section.oldContent, section.language);
|
|
205
191
|
for (let j = 0; j < section.oldRowIndices.length; j++) {
|
|
206
192
|
const rowIndex = section.oldRowIndices[j];
|
|
207
193
|
const row = rows[rowIndex];
|
|
208
194
|
const highlighted = oldHighlighted[j];
|
|
209
|
-
// Only set highlighted if it's different from content
|
|
210
195
|
if (highlighted &&
|
|
211
196
|
highlighted !== row.content &&
|
|
212
197
|
(row.type === 'diff-del' || row.type === 'diff-context')) {
|
|
@@ -214,15 +199,12 @@ export function buildDiffDisplayRows(diff) {
|
|
|
214
199
|
}
|
|
215
200
|
}
|
|
216
201
|
}
|
|
217
|
-
// Highlight new stream (context + additions)
|
|
218
202
|
if (section.newContent.length > 0) {
|
|
219
203
|
const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
|
|
220
204
|
for (let j = 0; j < section.newRowIndices.length; j++) {
|
|
221
205
|
const rowIndex = section.newRowIndices[j];
|
|
222
206
|
const row = rows[rowIndex];
|
|
223
207
|
const highlighted = newHighlighted[j];
|
|
224
|
-
// Only set highlighted if it's different from content
|
|
225
|
-
// Note: context lines appear in both streams, but result should be same
|
|
226
208
|
if (highlighted &&
|
|
227
209
|
highlighted !== row.content &&
|
|
228
210
|
(row.type === 'diff-add' || row.type === 'diff-context')) {
|
|
@@ -323,6 +305,33 @@ export function wrapDisplayRows(rows, contentWidth, wrapEnabled) {
|
|
|
323
305
|
}
|
|
324
306
|
return result;
|
|
325
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Find hunk boundaries in a DisplayRow or WrappedDisplayRow array.
|
|
310
|
+
* Each hunk spans from a 'diff-hunk' row to the next 'diff-hunk', 'diff-header', 'spacer', or end.
|
|
311
|
+
*/
|
|
312
|
+
export function getHunkBoundaries(rows) {
|
|
313
|
+
const boundaries = [];
|
|
314
|
+
let currentStart = -1;
|
|
315
|
+
for (let i = 0; i < rows.length; i++) {
|
|
316
|
+
const type = rows[i].type;
|
|
317
|
+
if (type === 'diff-hunk') {
|
|
318
|
+
if (currentStart !== -1) {
|
|
319
|
+
boundaries.push({ startRow: currentStart, endRow: i });
|
|
320
|
+
}
|
|
321
|
+
currentStart = i;
|
|
322
|
+
}
|
|
323
|
+
else if (type === 'diff-header' || type === 'spacer') {
|
|
324
|
+
if (currentStart !== -1) {
|
|
325
|
+
boundaries.push({ startRow: currentStart, endRow: i });
|
|
326
|
+
currentStart = -1;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (currentStart !== -1) {
|
|
331
|
+
boundaries.push({ startRow: currentStart, endRow: rows.length });
|
|
332
|
+
}
|
|
333
|
+
return boundaries;
|
|
334
|
+
}
|
|
326
335
|
/**
|
|
327
336
|
* Calculate the total row count after wrapping.
|
|
328
337
|
* More efficient than wrapDisplayRows().length when you only need the count.
|
|
@@ -349,3 +358,74 @@ export function getWrappedRowCount(rows, contentWidth, wrapEnabled) {
|
|
|
349
358
|
}
|
|
350
359
|
return count;
|
|
351
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Parse the index-referenced line number from a hunk header.
|
|
363
|
+
* Staged diffs (HEAD→index): use +new (new-side = index lines).
|
|
364
|
+
* Unstaged diffs (index→working tree): use -old (old-side = index lines).
|
|
365
|
+
*/
|
|
366
|
+
function parseHunkSortKey(hunkContent, source) {
|
|
367
|
+
const m = hunkContent.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/);
|
|
368
|
+
if (!m)
|
|
369
|
+
return 0;
|
|
370
|
+
return source === 'staged' ? parseInt(m[2], 10) : parseInt(m[1], 10);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Extract individual hunks from a DiffResult's lines.
|
|
374
|
+
* Returns file-level header lines separately.
|
|
375
|
+
*/
|
|
376
|
+
function extractHunks(diff, source) {
|
|
377
|
+
if (!diff || diff.lines.length === 0)
|
|
378
|
+
return { fileHeaders: [], hunks: [] };
|
|
379
|
+
const fileHeaders = [];
|
|
380
|
+
const hunks = [];
|
|
381
|
+
let currentHunk = null;
|
|
382
|
+
let hunkIdx = 0;
|
|
383
|
+
for (const line of diff.lines) {
|
|
384
|
+
if (line.type === 'header') {
|
|
385
|
+
if (currentHunk) {
|
|
386
|
+
hunks.push(currentHunk);
|
|
387
|
+
currentHunk = null;
|
|
388
|
+
}
|
|
389
|
+
fileHeaders.push(line);
|
|
390
|
+
}
|
|
391
|
+
else if (line.type === 'hunk') {
|
|
392
|
+
if (currentHunk)
|
|
393
|
+
hunks.push(currentHunk);
|
|
394
|
+
currentHunk = {
|
|
395
|
+
headerLine: line,
|
|
396
|
+
bodyLines: [],
|
|
397
|
+
sortKey: parseHunkSortKey(line.content, source),
|
|
398
|
+
source,
|
|
399
|
+
hunkIndex: hunkIdx++,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
else if (currentHunk) {
|
|
403
|
+
currentHunk.bodyLines.push(line);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (currentHunk)
|
|
407
|
+
hunks.push(currentHunk);
|
|
408
|
+
return { fileHeaders, hunks };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Build combined display rows from unstaged and staged diffs for the same file.
|
|
412
|
+
* Hunks are interleaved by file position (index line number) into a single
|
|
413
|
+
* unified view. Returns display rows and a mapping from combined hunk index
|
|
414
|
+
* to source (unstaged/staged) and original hunk index.
|
|
415
|
+
*/
|
|
416
|
+
export function buildCombinedDiffDisplayRows(unstaged, staged) {
|
|
417
|
+
const u = extractHunks(unstaged, 'unstaged');
|
|
418
|
+
const s = extractHunks(staged, 'staged');
|
|
419
|
+
const allHunks = [...u.hunks, ...s.hunks];
|
|
420
|
+
allHunks.sort((a, b) => a.sortKey - b.sortKey);
|
|
421
|
+
// Build a merged DiffLine array: file headers from whichever has them, then sorted hunks
|
|
422
|
+
const fileHeaders = u.fileHeaders.length > 0 ? u.fileHeaders : s.fileHeaders;
|
|
423
|
+
const mergedLines = [...fileHeaders];
|
|
424
|
+
const hunkMapping = [];
|
|
425
|
+
for (const hunk of allHunks) {
|
|
426
|
+
mergedLines.push(hunk.headerLine, ...hunk.bodyLines);
|
|
427
|
+
hunkMapping.push({ source: hunk.source, hunkIndex: hunk.hunkIndex });
|
|
428
|
+
}
|
|
429
|
+
const rows = buildDiffDisplayRows({ raw: '', lines: mergedLines });
|
|
430
|
+
return { rows, hunkMapping };
|
|
431
|
+
}
|