diffstalker 0.2.2 → 0.2.4
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 +2 -2
- package/dist/App.js +299 -664
- package/dist/KeyBindings.js +125 -39
- package/dist/ModalController.js +166 -0
- package/dist/MouseHandlers.js +43 -25
- package/dist/NavigationController.js +290 -0
- package/dist/StagingOperations.js +199 -0
- package/dist/config.js +39 -0
- package/dist/core/CompareManager.js +134 -0
- package/dist/core/ExplorerStateManager.js +27 -40
- package/dist/core/GitStateManager.js +28 -630
- package/dist/core/HistoryManager.js +72 -0
- package/dist/core/RemoteOperationManager.js +109 -0
- package/dist/core/WorkingTreeManager.js +412 -0
- package/dist/git/status.js +95 -0
- package/dist/index.js +59 -54
- package/dist/state/FocusRing.js +40 -0
- package/dist/state/UIState.js +82 -48
- package/dist/types/remote.js +5 -0
- package/dist/ui/PaneRenderers.js +11 -4
- package/dist/ui/modals/BaseBranchPicker.js +4 -7
- package/dist/ui/modals/CommitActionConfirm.js +66 -0
- package/dist/ui/modals/DiscardConfirm.js +4 -7
- package/dist/ui/modals/FileFinder.js +33 -27
- package/dist/ui/modals/HotkeysModal.js +32 -13
- package/dist/ui/modals/Modal.js +1 -0
- package/dist/ui/modals/RepoPicker.js +109 -0
- package/dist/ui/modals/ThemePicker.js +4 -7
- package/dist/ui/widgets/CommitPanel.js +52 -14
- package/dist/ui/widgets/CompareListView.js +1 -11
- package/dist/ui/widgets/DiffView.js +2 -27
- package/dist/ui/widgets/ExplorerContent.js +1 -4
- package/dist/ui/widgets/ExplorerView.js +1 -11
- package/dist/ui/widgets/FileList.js +2 -8
- package/dist/ui/widgets/Footer.js +1 -0
- package/dist/ui/widgets/Header.js +37 -3
- package/dist/utils/ansi.js +38 -0
- package/dist/utils/ansiTruncate.js +1 -5
- package/dist/utils/displayRows.js +72 -59
- package/dist/utils/fileCategories.js +7 -0
- package/dist/utils/fileResolution.js +23 -0
- package/dist/utils/languageDetection.js +3 -2
- package/dist/utils/logger.js +32 -0
- package/metrics/v0.2.3.json +243 -0
- package/metrics/v0.2.4.json +236 -0
- package/package.json +5 -2
- package/dist/utils/layoutCalculations.js +0 -100
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
import { abbreviateHomePath } from '../../config.js';
|
|
3
|
+
/**
|
|
4
|
+
* RepoPicker modal for switching between recently-visited repositories.
|
|
5
|
+
*/
|
|
6
|
+
export class RepoPicker {
|
|
7
|
+
box;
|
|
8
|
+
screen;
|
|
9
|
+
repos;
|
|
10
|
+
selectedIndex;
|
|
11
|
+
currentRepo;
|
|
12
|
+
onSelect;
|
|
13
|
+
onCancel;
|
|
14
|
+
constructor(screen, repos, currentRepo, onSelect, onCancel) {
|
|
15
|
+
this.screen = screen;
|
|
16
|
+
this.repos = repos;
|
|
17
|
+
this.currentRepo = currentRepo;
|
|
18
|
+
this.onSelect = onSelect;
|
|
19
|
+
this.onCancel = onCancel;
|
|
20
|
+
// Find current repo index
|
|
21
|
+
this.selectedIndex = repos.indexOf(currentRepo);
|
|
22
|
+
if (this.selectedIndex < 0)
|
|
23
|
+
this.selectedIndex = 0;
|
|
24
|
+
// Create modal box
|
|
25
|
+
const screenWidth = screen.width;
|
|
26
|
+
const width = Math.min(70, screenWidth - 4);
|
|
27
|
+
const maxVisibleRepos = Math.min(repos.length, 15);
|
|
28
|
+
const height = maxVisibleRepos + 6; // repos + header + footer + borders + padding
|
|
29
|
+
this.box = blessed.box({
|
|
30
|
+
parent: screen,
|
|
31
|
+
top: 'center',
|
|
32
|
+
left: 'center',
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
border: {
|
|
36
|
+
type: 'line',
|
|
37
|
+
},
|
|
38
|
+
style: {
|
|
39
|
+
border: {
|
|
40
|
+
fg: 'cyan',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
tags: true,
|
|
44
|
+
keys: true,
|
|
45
|
+
scrollable: true,
|
|
46
|
+
alwaysScroll: true,
|
|
47
|
+
});
|
|
48
|
+
// Setup key handlers
|
|
49
|
+
this.setupKeyHandlers();
|
|
50
|
+
// Initial render
|
|
51
|
+
this.render();
|
|
52
|
+
}
|
|
53
|
+
setupKeyHandlers() {
|
|
54
|
+
this.box.key(['escape'], () => {
|
|
55
|
+
this.destroy();
|
|
56
|
+
this.onCancel();
|
|
57
|
+
});
|
|
58
|
+
this.box.key(['enter', 'space'], () => {
|
|
59
|
+
const selected = this.repos[this.selectedIndex];
|
|
60
|
+
if (selected) {
|
|
61
|
+
this.destroy();
|
|
62
|
+
this.onSelect(selected);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
this.box.key(['up', 'k'], () => {
|
|
66
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
67
|
+
this.render();
|
|
68
|
+
});
|
|
69
|
+
this.box.key(['down', 'j'], () => {
|
|
70
|
+
this.selectedIndex = Math.min(this.repos.length - 1, this.selectedIndex + 1);
|
|
71
|
+
this.render();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
render() {
|
|
75
|
+
const lines = [];
|
|
76
|
+
// Header
|
|
77
|
+
lines.push('{bold}{cyan-fg} Recent Repositories{/cyan-fg}{/bold}');
|
|
78
|
+
lines.push('');
|
|
79
|
+
if (this.repos.length === 0) {
|
|
80
|
+
lines.push('{gray-fg}No recent repositories{/gray-fg}');
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Repo list
|
|
84
|
+
for (let i = 0; i < this.repos.length; i++) {
|
|
85
|
+
const repo = this.repos[i];
|
|
86
|
+
const isSelected = i === this.selectedIndex;
|
|
87
|
+
const isCurrent = repo === this.currentRepo;
|
|
88
|
+
let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
|
|
89
|
+
line += abbreviateHomePath(repo);
|
|
90
|
+
if (isSelected)
|
|
91
|
+
line += '{/bold}{/cyan-fg}';
|
|
92
|
+
if (isCurrent)
|
|
93
|
+
line += ' {gray-fg}(current){/gray-fg}';
|
|
94
|
+
lines.push(line);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Footer
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
|
|
100
|
+
this.box.setContent(lines.join('\n'));
|
|
101
|
+
this.screen.render();
|
|
102
|
+
}
|
|
103
|
+
destroy() {
|
|
104
|
+
this.box.destroy();
|
|
105
|
+
}
|
|
106
|
+
focus() {
|
|
107
|
+
this.box.focus();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -45,13 +45,13 @@ export class ThemePicker {
|
|
|
45
45
|
this.render();
|
|
46
46
|
}
|
|
47
47
|
setupKeyHandlers() {
|
|
48
|
-
this.box.key(['escape'
|
|
49
|
-
this.
|
|
48
|
+
this.box.key(['escape'], () => {
|
|
49
|
+
this.destroy();
|
|
50
50
|
this.onCancel();
|
|
51
51
|
});
|
|
52
52
|
this.box.key(['enter', 'space'], () => {
|
|
53
53
|
const selected = themeOrder[this.selectedIndex];
|
|
54
|
-
this.
|
|
54
|
+
this.destroy();
|
|
55
55
|
this.onSelect(selected);
|
|
56
56
|
});
|
|
57
57
|
this.box.key(['up', 'k'], () => {
|
|
@@ -94,12 +94,9 @@ export class ThemePicker {
|
|
|
94
94
|
this.box.setContent(lines.join('\n'));
|
|
95
95
|
this.screen.render();
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
destroy() {
|
|
98
98
|
this.box.destroy();
|
|
99
99
|
}
|
|
100
|
-
/**
|
|
101
|
-
* Focus the modal.
|
|
102
|
-
*/
|
|
103
100
|
focus() {
|
|
104
101
|
this.box.focus();
|
|
105
102
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Build all lines for the commit panel (used for both rendering and totalRows).
|
|
3
3
|
*/
|
|
4
|
-
export function
|
|
4
|
+
export function buildCommitPanelLines(opts) {
|
|
5
|
+
const { state, stagedCount, width, focusedZone } = opts;
|
|
5
6
|
const lines = [];
|
|
6
7
|
// Title
|
|
7
8
|
let title = '{bold}Commit Message{/bold}';
|
|
@@ -10,9 +11,9 @@ export function formatCommitPanel(state, stagedCount, width) {
|
|
|
10
11
|
}
|
|
11
12
|
lines.push(title);
|
|
12
13
|
lines.push('');
|
|
13
|
-
// Message input area
|
|
14
|
-
const
|
|
15
|
-
const borderColor =
|
|
14
|
+
// Message input area - cyan when zone-focused or input-focused
|
|
15
|
+
const messageFocused = state.inputFocused || focusedZone === 'commitMessage';
|
|
16
|
+
const borderColor = messageFocused ? 'cyan' : 'gray';
|
|
16
17
|
// Top border
|
|
17
18
|
const innerWidth = Math.max(20, width - 6);
|
|
18
19
|
lines.push(`{${borderColor}-fg}\u250c${'─'.repeat(innerWidth + 2)}\u2510{/${borderColor}-fg}`);
|
|
@@ -24,14 +25,20 @@ export function formatCommitPanel(state, stagedCount, width) {
|
|
|
24
25
|
const truncatedMessage = displayMessage.length > innerWidth
|
|
25
26
|
? displayMessage.slice(0, innerWidth - 1) + '\u2026'
|
|
26
27
|
: displayMessage.padEnd(innerWidth);
|
|
27
|
-
lines.push(`{${borderColor}-fg}
|
|
28
|
+
lines.push(`{${borderColor}-fg}\u2502{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}\u2502{/${borderColor}-fg}`);
|
|
28
29
|
// Bottom border
|
|
29
30
|
lines.push(`{${borderColor}-fg}\u2514${'─'.repeat(innerWidth + 2)}\u2518{/${borderColor}-fg}`);
|
|
30
31
|
lines.push('');
|
|
31
|
-
// Amend checkbox
|
|
32
|
+
// Amend checkbox - cyan marker when zone-focused
|
|
33
|
+
const amendFocused = focusedZone === 'commitAmend';
|
|
32
34
|
const checkbox = state.amend ? '[x]' : '[ ]';
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
let checkboxColor = 'gray';
|
|
36
|
+
if (amendFocused)
|
|
37
|
+
checkboxColor = 'cyan';
|
|
38
|
+
else if (state.amend)
|
|
39
|
+
checkboxColor = 'green';
|
|
40
|
+
const amendPrefix = amendFocused ? '{cyan-fg}\u25b8 {/cyan-fg}' : ' ';
|
|
41
|
+
lines.push(`${amendPrefix}{${checkboxColor}-fg}${checkbox}{/${checkboxColor}-fg} Amend {gray-fg}(a){/gray-fg}`);
|
|
35
42
|
// Error message
|
|
36
43
|
if (state.error) {
|
|
37
44
|
lines.push('');
|
|
@@ -43,12 +50,43 @@ export function formatCommitPanel(state, stagedCount, width) {
|
|
|
43
50
|
lines.push('{yellow-fg}Committing...{/yellow-fg}');
|
|
44
51
|
}
|
|
45
52
|
lines.push('');
|
|
46
|
-
// Help text
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
// Help text - context-sensitive based on focused zone
|
|
54
|
+
let helpText;
|
|
55
|
+
if (state.inputFocused) {
|
|
56
|
+
helpText = 'Enter: commit | Ctrl+a: amend | Esc: unfocus';
|
|
57
|
+
}
|
|
58
|
+
else if (focusedZone === 'commitMessage') {
|
|
59
|
+
helpText = 'Tab: next | Space: edit | a: amend';
|
|
60
|
+
}
|
|
61
|
+
else if (focusedZone === 'commitAmend') {
|
|
62
|
+
helpText = 'Tab: next | Space: toggle | Esc: back';
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
helpText = 'i/Enter: edit | a: amend | Esc: back';
|
|
66
|
+
}
|
|
50
67
|
lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
|
|
51
|
-
return lines
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Get total row count for the commit panel (for scroll calculations).
|
|
72
|
+
*/
|
|
73
|
+
export function getCommitPanelTotalRows(opts) {
|
|
74
|
+
return buildCommitPanelLines(opts).length;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Format the commit panel as blessed-compatible tagged string.
|
|
78
|
+
*/
|
|
79
|
+
export function formatCommitPanel(state, stagedCount, width, scrollOffset = 0, visibleHeight, focusedZone) {
|
|
80
|
+
const allLines = buildCommitPanelLines({
|
|
81
|
+
state,
|
|
82
|
+
stagedCount,
|
|
83
|
+
width,
|
|
84
|
+
focusedZone,
|
|
85
|
+
});
|
|
86
|
+
if (visibleHeight && allLines.length > visibleHeight) {
|
|
87
|
+
return allLines.slice(scrollOffset, scrollOffset + visibleHeight).join('\n');
|
|
88
|
+
}
|
|
89
|
+
return allLines.join('\n');
|
|
52
90
|
}
|
|
53
91
|
/**
|
|
54
92
|
* Format inactive commit panel.
|
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { formatDate } from '../../utils/formatDate.js';
|
|
2
2
|
import { formatCommitDisplay } from '../../utils/commitFormat.js';
|
|
3
3
|
import { buildFileTree, flattenTree, buildTreePrefix } from '../../utils/fileTree.js';
|
|
4
|
-
|
|
5
|
-
const ANSI_RESET = '\x1b[0m';
|
|
6
|
-
const ANSI_BOLD = '\x1b[1m';
|
|
7
|
-
const ANSI_GRAY = '\x1b[90m';
|
|
8
|
-
const ANSI_CYAN = '\x1b[36m';
|
|
9
|
-
const ANSI_YELLOW = '\x1b[33m';
|
|
10
|
-
const ANSI_GREEN = '\x1b[32m';
|
|
11
|
-
const ANSI_RED = '\x1b[31m';
|
|
12
|
-
const ANSI_BLUE = '\x1b[34m';
|
|
13
|
-
const ANSI_MAGENTA = '\x1b[35m';
|
|
14
|
-
const ANSI_INVERSE = '\x1b[7m';
|
|
4
|
+
import { ANSI_RESET, ANSI_BOLD, ANSI_GRAY, ANSI_CYAN, ANSI_YELLOW, ANSI_GREEN, ANSI_RED, ANSI_BLUE, ANSI_MAGENTA, ANSI_INVERSE, } from '../../utils/ansi.js';
|
|
15
5
|
/**
|
|
16
6
|
* Build the list of row items for the compare list view.
|
|
17
7
|
*/
|
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
import { getTheme } from '../../themes.js';
|
|
2
2
|
import { buildDiffDisplayRows, buildCombinedDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, getHunkBoundaries, } from '../../utils/displayRows.js';
|
|
3
3
|
import { truncateAnsi } from '../../utils/ansiTruncate.js';
|
|
4
|
-
|
|
5
|
-
const ANSI_RESET = '\x1b[0m';
|
|
6
|
-
const ANSI_BOLD = '\x1b[1m';
|
|
7
|
-
const ANSI_GRAY = '\x1b[90m';
|
|
8
|
-
const ANSI_CYAN = '\x1b[36m';
|
|
9
|
-
const ANSI_GREEN = '\x1b[32m';
|
|
10
|
-
const ANSI_YELLOW = '\x1b[33m';
|
|
11
|
-
const ANSI_INVERSE = '\x1b[7m';
|
|
4
|
+
import { ANSI_RESET, ANSI_BOLD, ANSI_GRAY, ANSI_CYAN, ANSI_GREEN, ANSI_YELLOW, ANSI_INVERSE, ansiBg, ansiFg, } from '../../utils/ansi.js';
|
|
12
5
|
/**
|
|
13
6
|
* Truncate string to fit within maxWidth, adding ellipsis if needed.
|
|
14
7
|
*/
|
|
@@ -33,24 +26,6 @@ function formatLineNum(lineNum, width) {
|
|
|
33
26
|
function escapeContent(content) {
|
|
34
27
|
return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
35
28
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Build raw ANSI escape sequence for 24-bit RGB background.
|
|
38
|
-
*/
|
|
39
|
-
function ansiBg(hex) {
|
|
40
|
-
const r = parseInt(hex.slice(1, 3), 16);
|
|
41
|
-
const g = parseInt(hex.slice(3, 5), 16);
|
|
42
|
-
const b = parseInt(hex.slice(5, 7), 16);
|
|
43
|
-
return `\x1b[48;2;${r};${g};${b}m`;
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Build raw ANSI escape sequence for 24-bit RGB foreground.
|
|
47
|
-
*/
|
|
48
|
-
function ansiFg(hex) {
|
|
49
|
-
const r = parseInt(hex.slice(1, 3), 16);
|
|
50
|
-
const g = parseInt(hex.slice(3, 5), 16);
|
|
51
|
-
const b = parseInt(hex.slice(5, 7), 16);
|
|
52
|
-
return `\x1b[38;2;${r};${g};${b}m`;
|
|
53
|
-
}
|
|
54
29
|
/**
|
|
55
30
|
* Format a diff file header row (e.g. "── path/to/file ──").
|
|
56
31
|
*/
|
|
@@ -166,7 +141,7 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
|
|
|
166
141
|
const lineNum = formatLineNum(row.lineNum, lineNumWidth);
|
|
167
142
|
const prefix = `${lineNum} ${symbol} `;
|
|
168
143
|
const rawContent = row.content || '';
|
|
169
|
-
const prefixAnsi =
|
|
144
|
+
const prefixAnsi = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
|
|
170
145
|
if (row.highlighted && !isCont) {
|
|
171
146
|
const content = wrapMode ? row.highlighted : truncateAnsi(row.highlighted, contentWidth);
|
|
172
147
|
return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, } from '../../utils/explorerDisplayRows.js';
|
|
2
2
|
import { truncateAnsi } from '../../utils/ansiTruncate.js';
|
|
3
|
-
|
|
4
|
-
const ANSI_GRAY = '\x1b[90m';
|
|
5
|
-
const ANSI_CYAN = '\x1b[36m';
|
|
6
|
-
const ANSI_YELLOW = '\x1b[33m';
|
|
3
|
+
import { ANSI_RESET, ANSI_GRAY, ANSI_CYAN, ANSI_YELLOW } from '../../utils/ansi.js';
|
|
7
4
|
/**
|
|
8
5
|
* Format explorer file content as blessed-compatible tagged string.
|
|
9
6
|
*/
|
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
|
|
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
|
+
import { ANSI_RESET, ANSI_BOLD, ANSI_GRAY, ANSI_CYAN, ANSI_YELLOW, ANSI_GREEN, ANSI_RED, ANSI_BLUE, ANSI_MAGENTA, ANSI_INVERSE, } from '../../utils/ansi.js';
|
|
12
2
|
/**
|
|
13
3
|
* Build tree prefix characters (│ ├ └).
|
|
14
4
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { categorizeFiles } from '../../utils/fileCategories.js';
|
|
1
|
+
import { categorizeFiles, getFileAtIndex } from '../../utils/fileCategories.js';
|
|
2
2
|
import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
|
|
3
3
|
/**
|
|
4
4
|
* Build the list of row items for the file list.
|
|
@@ -119,13 +119,7 @@ export function formatFileList(files, selectedIndex, isFocused, width, scrollOff
|
|
|
119
119
|
export function getFileListTotalRows(files) {
|
|
120
120
|
return buildFileListRows(files).length;
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
* Get the file at a specific index (accounting for category ordering).
|
|
124
|
-
*/
|
|
125
|
-
export function getFileAtIndex(files, index) {
|
|
126
|
-
const { ordered } = categorizeFiles(files);
|
|
127
|
-
return ordered[index] ?? null;
|
|
128
|
-
}
|
|
122
|
+
export { getFileAtIndex };
|
|
129
123
|
/**
|
|
130
124
|
* Get the file index from a visual row (accounting for headers and spacers).
|
|
131
125
|
* Returns null if the row is a header or spacer.
|
|
@@ -35,6 +35,7 @@ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode,
|
|
|
35
35
|
if (activeTab === 'diff' && currentPane === 'diff') {
|
|
36
36
|
leftContent += ' {gray-fg}n/N:hunk s:toggle{/gray-fg}';
|
|
37
37
|
}
|
|
38
|
+
leftContent += ' {gray-fg}Tab:next{/gray-fg}';
|
|
38
39
|
// Right side: tabs
|
|
39
40
|
const tabs = [
|
|
40
41
|
{ key: '1', label: 'Diff', tab: 'diff' },
|
|
@@ -38,7 +38,7 @@ function computeBranchVisibleLength(branch) {
|
|
|
38
38
|
/**
|
|
39
39
|
* Format header content as blessed-compatible tagged string.
|
|
40
40
|
*/
|
|
41
|
-
export function formatHeader(repoPath, branch, isLoading, error, width) {
|
|
41
|
+
export function formatHeader(repoPath, branch, isLoading, error, width, remoteState) {
|
|
42
42
|
if (!repoPath) {
|
|
43
43
|
return '{gray-fg}Waiting for target path...{/gray-fg}';
|
|
44
44
|
}
|
|
@@ -55,6 +55,39 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
|
|
|
55
55
|
else if (error) {
|
|
56
56
|
leftContent += ` {red-fg}(${error}){/red-fg}`;
|
|
57
57
|
}
|
|
58
|
+
// Remote operation status (shown after left content)
|
|
59
|
+
let remoteStatus = '';
|
|
60
|
+
let remoteStatusLen = 0;
|
|
61
|
+
if (remoteState) {
|
|
62
|
+
if (remoteState.inProgress && remoteState.operation) {
|
|
63
|
+
const labels = {
|
|
64
|
+
push: 'pushing...',
|
|
65
|
+
fetch: 'fetching...',
|
|
66
|
+
pull: 'rebasing...',
|
|
67
|
+
stash: 'stashing...',
|
|
68
|
+
stashPop: 'popping stash...',
|
|
69
|
+
branchSwitch: 'switching branch...',
|
|
70
|
+
branchCreate: 'creating branch...',
|
|
71
|
+
softReset: 'resetting...',
|
|
72
|
+
cherryPick: 'cherry-picking...',
|
|
73
|
+
revert: 'reverting...',
|
|
74
|
+
};
|
|
75
|
+
const label = labels[remoteState.operation] ?? '';
|
|
76
|
+
remoteStatus = ` {yellow-fg}${label}{/yellow-fg}`;
|
|
77
|
+
remoteStatusLen = 1 + label.length;
|
|
78
|
+
}
|
|
79
|
+
else if (remoteState.error) {
|
|
80
|
+
const brief = remoteState.error.length > 40
|
|
81
|
+
? remoteState.error.slice(0, 40) + '\u2026'
|
|
82
|
+
: remoteState.error;
|
|
83
|
+
remoteStatus = ` {red-fg}${brief}{/red-fg}`;
|
|
84
|
+
remoteStatusLen = 1 + brief.length;
|
|
85
|
+
}
|
|
86
|
+
else if (remoteState.lastResult) {
|
|
87
|
+
remoteStatus = ` {green-fg}${remoteState.lastResult}{/green-fg}`;
|
|
88
|
+
remoteStatusLen = 1 + remoteState.lastResult.length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
58
91
|
// Build right side content (branch info)
|
|
59
92
|
const rightContent = branch ? formatBranch(branch) : '';
|
|
60
93
|
if (rightContent) {
|
|
@@ -68,9 +101,10 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
|
|
|
68
101
|
else if (error) {
|
|
69
102
|
leftLen += error.length + 3; // " (error)"
|
|
70
103
|
}
|
|
104
|
+
leftLen += remoteStatusLen;
|
|
71
105
|
const rightLen = branch ? computeBranchVisibleLength(branch) : 0;
|
|
72
106
|
const padding = Math.max(1, width - leftLen - rightLen - 2);
|
|
73
|
-
return leftContent + ' '.repeat(padding) + rightContent;
|
|
107
|
+
return leftContent + remoteStatus + ' '.repeat(padding) + rightContent;
|
|
74
108
|
}
|
|
75
|
-
return leftContent;
|
|
109
|
+
return leftContent + remoteStatus;
|
|
76
110
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized ANSI escape code constants and helpers.
|
|
3
|
+
*
|
|
4
|
+
* All terminal color/style codes live here to avoid duplication across widgets.
|
|
5
|
+
* Terminal mode sequences (mouse mode, cursor visibility) are NOT included —
|
|
6
|
+
* those are a different concern and remain in index.ts.
|
|
7
|
+
*/
|
|
8
|
+
// --- SGR 3/4-bit color and style constants ---
|
|
9
|
+
export const ANSI_RESET = '\x1b[0m';
|
|
10
|
+
export const ANSI_BOLD = '\x1b[1m';
|
|
11
|
+
export const ANSI_INVERSE = '\x1b[7m';
|
|
12
|
+
export const ANSI_RED = '\x1b[31m';
|
|
13
|
+
export const ANSI_GREEN = '\x1b[32m';
|
|
14
|
+
export const ANSI_YELLOW = '\x1b[33m';
|
|
15
|
+
export const ANSI_BLUE = '\x1b[34m';
|
|
16
|
+
export const ANSI_MAGENTA = '\x1b[35m';
|
|
17
|
+
export const ANSI_CYAN = '\x1b[36m';
|
|
18
|
+
export const ANSI_GRAY = '\x1b[90m';
|
|
19
|
+
/** Reset foreground color only (preserves background). */
|
|
20
|
+
export const ANSI_FG_RESET = '\x1b[39m';
|
|
21
|
+
// --- ANSI escape sequence pattern for parsing/stripping ---
|
|
22
|
+
/** Matches SGR sequences like \x1b[32m, \x1b[0m, \x1b[1;34m, \x1b[48;2;30;30;30m */
|
|
23
|
+
export const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
24
|
+
// --- 24-bit RGB helpers ---
|
|
25
|
+
/** Build ANSI escape for 24-bit RGB background from hex color (e.g. '#1e1e2e'). */
|
|
26
|
+
export function ansiBg(hex) {
|
|
27
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
28
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
29
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
30
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
31
|
+
}
|
|
32
|
+
/** Build ANSI escape for 24-bit RGB foreground from hex color (e.g. '#e0e0e0'). */
|
|
33
|
+
export function ansiFg(hex) {
|
|
34
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
35
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
36
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
37
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
38
|
+
}
|
|
@@ -4,11 +4,7 @@
|
|
|
4
4
|
* Truncates strings containing ANSI escape codes at a visual character limit
|
|
5
5
|
* while preserving formatting up to the truncation point.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
// Matches sequences like \x1b[32m, \x1b[0m, \x1b[1;34m, etc.
|
|
9
|
-
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
10
|
-
// ANSI reset sequence to clear all formatting
|
|
11
|
-
const ANSI_RESET = '\x1b[0m';
|
|
7
|
+
import { ANSI_PATTERN, ANSI_RESET } from './ansi.js';
|
|
12
8
|
/**
|
|
13
9
|
* Calculate the visual length of a string (excluding ANSI codes).
|
|
14
10
|
*/
|
|
@@ -5,21 +5,12 @@ import { isDisplayableDiffLine } from './diffFilters.js';
|
|
|
5
5
|
import { breakLine, getLineRowCount } from './lineBreaking.js';
|
|
6
6
|
import { computeWordDiff, areSimilarEnough } from './wordDiff.js';
|
|
7
7
|
import { getLanguageFromPath, highlightBlockPreserveBg } from './languageDetection.js';
|
|
8
|
+
import { getLineContent as extractLineContent } from './diffRowCalculations.js';
|
|
8
9
|
/**
|
|
9
10
|
* Get the text content from a diff line (strip leading +/-/space and control chars)
|
|
10
11
|
*/
|
|
11
12
|
function getLineContent(line) {
|
|
12
|
-
|
|
13
|
-
if (line.type === 'addition' || line.type === 'deletion') {
|
|
14
|
-
content = line.content.slice(1);
|
|
15
|
-
}
|
|
16
|
-
else if (line.type === 'context') {
|
|
17
|
-
// Context lines start with space
|
|
18
|
-
content = line.content.startsWith(' ') ? line.content.slice(1) : line.content;
|
|
19
|
-
}
|
|
20
|
-
else {
|
|
21
|
-
content = line.content;
|
|
22
|
-
}
|
|
13
|
+
const content = extractLineContent(line);
|
|
23
14
|
// Strip control characters that cause rendering artifacts
|
|
24
15
|
// and convert tabs to spaces for consistent width calculation
|
|
25
16
|
return content.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').replace(/\t/g, ' ');
|
|
@@ -60,20 +51,38 @@ function extractFilePathFromHeader(content) {
|
|
|
60
51
|
const match = content.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
61
52
|
return match ? match[1] : null;
|
|
62
53
|
}
|
|
54
|
+
/** Type guard for rows that can receive syntax highlighting. */
|
|
55
|
+
function isHighlightable(row) {
|
|
56
|
+
return row.type === 'diff-add' || row.type === 'diff-del' || row.type === 'diff-context';
|
|
57
|
+
}
|
|
63
58
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* Pairs consecutive deletions/additions within hunks and computes word-level diffs.
|
|
67
|
-
* Applies block-based syntax highlighting to properly handle multi-line constructs.
|
|
59
|
+
* Pair consecutive deletions with additions and compute word-level diffs.
|
|
60
|
+
* Returns maps from pair index to word diff segments for each side.
|
|
68
61
|
*/
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
62
|
+
function pairDeletionsAndAdditions(deletions, additions) {
|
|
63
|
+
const delSegmentsMap = new Map();
|
|
64
|
+
const addSegmentsMap = new Map();
|
|
65
|
+
const pairCount = Math.min(deletions.length, additions.length);
|
|
66
|
+
for (let j = 0; j < pairCount; j++) {
|
|
67
|
+
const delContent = getLineContent(deletions[j]);
|
|
68
|
+
const addContent = getLineContent(additions[j]);
|
|
69
|
+
if (areSimilarEnough(delContent, addContent)) {
|
|
70
|
+
const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
|
|
71
|
+
delSegmentsMap.set(j, oldSegments);
|
|
72
|
+
addSegmentsMap.set(j, newSegments);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { delSegmentsMap, addSegmentsMap };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build display rows from filtered diff lines in a single pass.
|
|
79
|
+
* Collects content streams per file section for later syntax highlighting.
|
|
80
|
+
* Pairs consecutive del/add lines for word-level diff computation.
|
|
81
|
+
*/
|
|
82
|
+
function buildRawDiffRows(filteredLines) {
|
|
73
83
|
const rows = [];
|
|
74
84
|
const fileSections = [];
|
|
75
85
|
let currentSection = null;
|
|
76
|
-
// Phase 1: Build display rows and collect content streams per file section
|
|
77
86
|
let i = 0;
|
|
78
87
|
while (i < filteredLines.length) {
|
|
79
88
|
const line = filteredLines[i];
|
|
@@ -134,18 +143,7 @@ export function buildDiffDisplayRows(diff) {
|
|
|
134
143
|
i++;
|
|
135
144
|
}
|
|
136
145
|
// Pair deletions with additions for word-level diff
|
|
137
|
-
const delSegmentsMap =
|
|
138
|
-
const addSegmentsMap = new Map();
|
|
139
|
-
const pairCount = Math.min(deletions.length, additions.length);
|
|
140
|
-
for (let j = 0; j < pairCount; j++) {
|
|
141
|
-
const delContent = getLineContent(deletions[j]);
|
|
142
|
-
const addContent = getLineContent(additions[j]);
|
|
143
|
-
if (areSimilarEnough(delContent, addContent)) {
|
|
144
|
-
const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
|
|
145
|
-
delSegmentsMap.set(j, oldSegments);
|
|
146
|
-
addSegmentsMap.set(j, newSegments);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
146
|
+
const { delSegmentsMap, addSegmentsMap } = pairDeletionsAndAdditions(deletions, additions);
|
|
149
147
|
for (let j = 0; j < deletions.length; j++) {
|
|
150
148
|
const delLine = deletions[j];
|
|
151
149
|
const delContent = getLineContent(delLine);
|
|
@@ -182,37 +180,52 @@ export function buildDiffDisplayRows(diff) {
|
|
|
182
180
|
if (currentSection) {
|
|
183
181
|
fileSections.push(currentSection);
|
|
184
182
|
}
|
|
185
|
-
|
|
183
|
+
return { rows, fileSections };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Highlight a content stream and map results back to display rows.
|
|
187
|
+
* Only applies highlighting to rows that match the expected types.
|
|
188
|
+
*/
|
|
189
|
+
function applyStreamHighlighting(rows, content, rowIndices, language, allowedTypes) {
|
|
190
|
+
if (content.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
const highlighted = highlightBlockPreserveBg(content, language);
|
|
193
|
+
for (let j = 0; j < rowIndices.length; j++) {
|
|
194
|
+
const rowIndex = rowIndices[j];
|
|
195
|
+
const row = rows[rowIndex];
|
|
196
|
+
const hl = highlighted[j];
|
|
197
|
+
if (hl && isHighlightable(row) && allowedTypes.has(row.type) && hl !== row.content) {
|
|
198
|
+
row.highlighted = hl;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const OLD_STREAM_TYPES = new Set(['diff-del', 'diff-context']);
|
|
203
|
+
const NEW_STREAM_TYPES = new Set(['diff-add', 'diff-context']);
|
|
204
|
+
/**
|
|
205
|
+
* Apply block-based syntax highlighting to display rows.
|
|
206
|
+
* Each file section's old and new content streams are highlighted separately,
|
|
207
|
+
* then mapped back to the corresponding row indices.
|
|
208
|
+
*/
|
|
209
|
+
function applySyntaxHighlighting(rows, fileSections) {
|
|
186
210
|
for (const section of fileSections) {
|
|
187
211
|
if (!section.language)
|
|
188
212
|
continue;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
for (let j = 0; j < section.oldRowIndices.length; j++) {
|
|
192
|
-
const rowIndex = section.oldRowIndices[j];
|
|
193
|
-
const row = rows[rowIndex];
|
|
194
|
-
const highlighted = oldHighlighted[j];
|
|
195
|
-
if (highlighted &&
|
|
196
|
-
highlighted !== row.content &&
|
|
197
|
-
(row.type === 'diff-del' || row.type === 'diff-context')) {
|
|
198
|
-
row.highlighted = highlighted;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
if (section.newContent.length > 0) {
|
|
203
|
-
const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
|
|
204
|
-
for (let j = 0; j < section.newRowIndices.length; j++) {
|
|
205
|
-
const rowIndex = section.newRowIndices[j];
|
|
206
|
-
const row = rows[rowIndex];
|
|
207
|
-
const highlighted = newHighlighted[j];
|
|
208
|
-
if (highlighted &&
|
|
209
|
-
highlighted !== row.content &&
|
|
210
|
-
(row.type === 'diff-add' || row.type === 'diff-context')) {
|
|
211
|
-
row.highlighted = highlighted;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
213
|
+
applyStreamHighlighting(rows, section.oldContent, section.oldRowIndices, section.language, OLD_STREAM_TYPES);
|
|
214
|
+
applyStreamHighlighting(rows, section.newContent, section.newRowIndices, section.language, NEW_STREAM_TYPES);
|
|
215
215
|
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Build display rows from a DiffResult.
|
|
219
|
+
* Filters out non-displayable lines (index, ---, +++ headers).
|
|
220
|
+
* Pairs consecutive deletions/additions within hunks and computes word-level diffs.
|
|
221
|
+
* Applies block-based syntax highlighting to properly handle multi-line constructs.
|
|
222
|
+
*/
|
|
223
|
+
export function buildDiffDisplayRows(diff) {
|
|
224
|
+
if (!diff)
|
|
225
|
+
return [];
|
|
226
|
+
const filteredLines = diff.lines.filter(isDisplayableDiffLine);
|
|
227
|
+
const { rows, fileSections } = buildRawDiffRows(filteredLines);
|
|
228
|
+
applySyntaxHighlighting(rows, fileSections);
|
|
216
229
|
return rows;
|
|
217
230
|
}
|
|
218
231
|
/**
|
|
@@ -61,3 +61,10 @@ export function getIndexForCategoryPosition(files, category, categoryIndex) {
|
|
|
61
61
|
};
|
|
62
62
|
return offsets[category] + clampedIndex;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Get the file at a specific index (accounting for category ordering).
|
|
66
|
+
*/
|
|
67
|
+
export function getFileAtIndex(files, index) {
|
|
68
|
+
const { ordered } = categorizeFiles(files);
|
|
69
|
+
return ordered[index] ?? null;
|
|
70
|
+
}
|