diffstalker 0.2.0 → 0.2.1
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/.github/workflows/release.yml +8 -0
- package/bun.lock +23 -0
- package/dist/App.js +225 -471
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +444 -78
- package/dist/core/GitStateManager.js +169 -93
- package/dist/git/diff.js +4 -0
- package/dist/index.js +54 -53
- package/dist/state/UIState.js +17 -4
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/FileFinder.js +232 -0
- package/dist/ui/widgets/CompareListView.js +86 -64
- package/dist/ui/widgets/DiffView.js +19 -17
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +140 -31
- package/dist/ui/widgets/Footer.js +6 -2
- package/dist/ui/widgets/Header.js +3 -46
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +4 -1
- package/dist/utils/ansiToBlessed.js +0 -125
- package/dist/utils/mouseCoordinates.js +0 -165
- package/dist/utils/rowCalculations.js +0 -246
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth,
|
|
1
|
+
import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, } from '../../utils/explorerDisplayRows.js';
|
|
2
2
|
import { truncateAnsi } from '../../utils/ansiTruncate.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
function escapeContent(content) {
|
|
8
|
-
return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
9
|
-
}
|
|
3
|
+
const ANSI_RESET = '\x1b[0m';
|
|
4
|
+
const ANSI_GRAY = '\x1b[90m';
|
|
5
|
+
const ANSI_CYAN = '\x1b[36m';
|
|
6
|
+
const ANSI_YELLOW = '\x1b[33m';
|
|
10
7
|
/**
|
|
11
8
|
* Format explorer file content as blessed-compatible tagged string.
|
|
12
9
|
*/
|
|
13
|
-
export function formatExplorerContent(filePath, content, width, scrollOffset = 0, maxHeight, truncated = false, wrapMode = false
|
|
10
|
+
export function formatExplorerContent(filePath, content, width, scrollOffset = 0, maxHeight, truncated = false, wrapMode = false) {
|
|
14
11
|
if (!filePath) {
|
|
15
12
|
return '{gray-fg}Select a file to view its contents{/gray-fg}';
|
|
16
13
|
}
|
|
@@ -36,7 +33,8 @@ export function formatExplorerContent(filePath, content, width, scrollOffset = 0
|
|
|
36
33
|
const lines = [];
|
|
37
34
|
for (const row of visibleRows) {
|
|
38
35
|
if (row.type === 'truncation') {
|
|
39
|
-
|
|
36
|
+
// Use {escape} with raw ANSI for consistency
|
|
37
|
+
lines.push(`{escape}${ANSI_YELLOW}${row.content}${ANSI_RESET}{/escape}`);
|
|
40
38
|
continue;
|
|
41
39
|
}
|
|
42
40
|
// Code row
|
|
@@ -52,38 +50,27 @@ export function formatExplorerContent(filePath, content, width, scrollOffset = 0
|
|
|
52
50
|
// Determine what content to display
|
|
53
51
|
const rawContent = row.content;
|
|
54
52
|
const shouldTruncate = !wrapMode && rawContent.length > contentWidth;
|
|
55
|
-
// Use highlighted content if available and not a continuation
|
|
56
|
-
const canUseHighlighting = row.highlighted && !isContinuation
|
|
53
|
+
// Use highlighted content if available and not a continuation
|
|
54
|
+
const canUseHighlighting = row.highlighted && !isContinuation;
|
|
57
55
|
let displayContent;
|
|
58
56
|
if (canUseHighlighting && row.highlighted) {
|
|
59
57
|
// Use ANSI-aware truncation to preserve syntax highlighting
|
|
60
|
-
|
|
58
|
+
displayContent = shouldTruncate
|
|
61
59
|
? truncateAnsi(row.highlighted, contentWidth)
|
|
62
60
|
: row.highlighted;
|
|
63
|
-
// Convert ANSI to blessed tags
|
|
64
|
-
displayContent = ansiToBlessed(truncatedHighlight);
|
|
65
61
|
}
|
|
66
62
|
else {
|
|
67
63
|
// Plain content path
|
|
68
64
|
let plainContent = rawContent;
|
|
69
|
-
// Apply middle-dots to raw content
|
|
70
|
-
if (showMiddleDots && !isContinuation) {
|
|
71
|
-
plainContent = applyMiddleDots(plainContent, true);
|
|
72
|
-
}
|
|
73
65
|
// Simple truncation for plain content
|
|
74
66
|
if (shouldTruncate) {
|
|
75
67
|
plainContent = plainContent.slice(0, Math.max(0, contentWidth - 1)) + '...';
|
|
76
68
|
}
|
|
77
|
-
displayContent =
|
|
78
|
-
}
|
|
79
|
-
// Format line with line number
|
|
80
|
-
let line = '';
|
|
81
|
-
if (isContinuation) {
|
|
82
|
-
line = `{cyan-fg}${lineNumDisplay}{/cyan-fg} ${displayContent || ' '}`;
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
line = `{gray-fg}${lineNumDisplay}{/gray-fg} ${displayContent || ' '}`;
|
|
69
|
+
displayContent = plainContent;
|
|
86
70
|
}
|
|
71
|
+
// Format line with line number using raw ANSI (avoids blessed escaping issues)
|
|
72
|
+
const lineNumColor = isContinuation ? ANSI_CYAN : ANSI_GRAY;
|
|
73
|
+
const line = `{escape}${lineNumColor}${lineNumDisplay}${ANSI_RESET} ${displayContent || ' '}{/escape}`;
|
|
87
74
|
lines.push(line);
|
|
88
75
|
}
|
|
89
76
|
return lines.join('\n');
|
|
@@ -1,59 +1,168 @@
|
|
|
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;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get status marker for git status.
|
|
39
|
+
*/
|
|
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
|
+
}
|
|
6
59
|
}
|
|
7
60
|
/**
|
|
8
|
-
*
|
|
61
|
+
* Get color for git status.
|
|
9
62
|
*/
|
|
10
|
-
|
|
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 the explorer tree view as blessed-compatible tagged string.
|
|
85
|
+
*/
|
|
86
|
+
export function formatExplorerView(displayRows, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, isLoading = false, error = null) {
|
|
11
87
|
if (error) {
|
|
12
88
|
return `{red-fg}Error: ${escapeContent(error)}{/red-fg}`;
|
|
13
89
|
}
|
|
14
90
|
if (isLoading) {
|
|
15
91
|
return '{gray-fg}Loading...{/gray-fg}';
|
|
16
92
|
}
|
|
17
|
-
if (
|
|
93
|
+
if (displayRows.length === 0) {
|
|
18
94
|
return '{gray-fg}(empty directory){/gray-fg}';
|
|
19
95
|
}
|
|
20
96
|
// Apply scroll offset and max height
|
|
21
|
-
const
|
|
22
|
-
?
|
|
23
|
-
:
|
|
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);
|
|
97
|
+
const visibleRows = maxHeight
|
|
98
|
+
? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
|
|
99
|
+
: displayRows.slice(scrollOffset);
|
|
26
100
|
const lines = [];
|
|
27
|
-
for (let i = 0; i <
|
|
28
|
-
const
|
|
101
|
+
for (let i = 0; i < visibleRows.length; i++) {
|
|
102
|
+
const row = visibleRows[i];
|
|
29
103
|
const actualIndex = scrollOffset + i;
|
|
30
104
|
const isSelected = actualIndex === selectedIndex;
|
|
31
105
|
const isHighlighted = isSelected && isFocused;
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
106
|
+
const node = row.node;
|
|
107
|
+
// Build tree prefix
|
|
108
|
+
const prefix = buildTreePrefix(row);
|
|
109
|
+
// Directory icon (▸ collapsed, ▾ expanded)
|
|
110
|
+
let icon = '';
|
|
111
|
+
if (node.isDirectory) {
|
|
112
|
+
icon = node.expanded ? '▾ ' : '▸ ';
|
|
113
|
+
}
|
|
114
|
+
// Git status indicator
|
|
115
|
+
const statusMarker = getStatusMarker(node.gitStatus);
|
|
116
|
+
const statusColor = getStatusColor(node.gitStatus);
|
|
117
|
+
const statusDisplay = statusMarker ? `${statusColor}${statusMarker}${ANSI_RESET} ` : '';
|
|
118
|
+
// Directory status indicator (dot if has changed children)
|
|
119
|
+
const dirStatusDisplay = node.isDirectory && node.hasChangedChildren ? `${ANSI_YELLOW}●${ANSI_RESET} ` : '';
|
|
120
|
+
// Calculate available width for name
|
|
121
|
+
const prefixLen = prefix.length +
|
|
122
|
+
icon.length +
|
|
123
|
+
(statusMarker ? 2 : 0) +
|
|
124
|
+
(node.hasChangedChildren && node.isDirectory ? 2 : 0);
|
|
125
|
+
const maxNameLen = Math.max(5, width - prefixLen - 2);
|
|
126
|
+
// Display name (with trailing / for directories)
|
|
127
|
+
let displayName = node.isDirectory ? `${node.name}/` : node.name;
|
|
128
|
+
if (displayName.length > maxNameLen) {
|
|
129
|
+
displayName = displayName.slice(0, maxNameLen - 1) + '…';
|
|
130
|
+
}
|
|
131
|
+
// Build the line
|
|
132
|
+
let line = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
|
|
133
|
+
if (node.isDirectory) {
|
|
134
|
+
line += `${ANSI_BLUE}${icon}${ANSI_RESET}`;
|
|
135
|
+
line += dirStatusDisplay;
|
|
136
|
+
if (isHighlighted) {
|
|
137
|
+
line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
|
|
39
138
|
}
|
|
40
139
|
else {
|
|
41
|
-
line
|
|
140
|
+
line += `${ANSI_BLUE}${displayName}${ANSI_RESET}`;
|
|
42
141
|
}
|
|
43
142
|
}
|
|
44
143
|
else {
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
144
|
+
// File
|
|
145
|
+
line += statusDisplay;
|
|
146
|
+
if (isHighlighted) {
|
|
147
|
+
line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
|
|
148
|
+
}
|
|
149
|
+
else if (node.gitStatus) {
|
|
150
|
+
line += `${statusColor}${displayName}${ANSI_RESET}`;
|
|
48
151
|
}
|
|
49
152
|
else {
|
|
50
|
-
line
|
|
153
|
+
line += displayName;
|
|
51
154
|
}
|
|
52
155
|
}
|
|
53
|
-
lines.push(line);
|
|
156
|
+
lines.push(`{escape}${line}{/escape}`);
|
|
54
157
|
}
|
|
55
158
|
return lines.join('\n');
|
|
56
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Escape blessed tags in content.
|
|
162
|
+
*/
|
|
163
|
+
function escapeContent(content) {
|
|
164
|
+
return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
165
|
+
}
|
|
57
166
|
/**
|
|
58
167
|
* Build breadcrumb segments from a path.
|
|
59
168
|
* Returns segments like ["src", "components"] for "src/components"
|
|
@@ -84,12 +193,12 @@ export function formatBreadcrumbs(currentPath, repoName) {
|
|
|
84
193
|
/**
|
|
85
194
|
* Get total rows in explorer for scroll calculations.
|
|
86
195
|
*/
|
|
87
|
-
export function getExplorerTotalRows(
|
|
88
|
-
return
|
|
196
|
+
export function getExplorerTotalRows(displayRows) {
|
|
197
|
+
return displayRows.length;
|
|
89
198
|
}
|
|
90
199
|
/**
|
|
91
|
-
* Get
|
|
200
|
+
* Get row at index.
|
|
92
201
|
*/
|
|
93
|
-
export function
|
|
94
|
-
return
|
|
202
|
+
export function getExplorerRowAtIndex(displayRows, index) {
|
|
203
|
+
return displayRows[index] ?? null;
|
|
95
204
|
}
|
|
@@ -7,7 +7,7 @@ function calculateVisibleLength(content) {
|
|
|
7
7
|
/**
|
|
8
8
|
* Format footer content as blessed-compatible tagged string.
|
|
9
9
|
*/
|
|
10
|
-
export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode,
|
|
10
|
+
export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, width) {
|
|
11
11
|
// Left side: indicators
|
|
12
12
|
let leftContent = '{gray-fg}?{/gray-fg} ';
|
|
13
13
|
leftContent += mouseEnabled
|
|
@@ -17,9 +17,13 @@ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode,
|
|
|
17
17
|
leftContent += autoTabEnabled ? '{blue-fg}[auto]{/blue-fg}' : '{gray-fg}[auto]{/gray-fg}';
|
|
18
18
|
leftContent += ' ';
|
|
19
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}';
|
|
20
22
|
if (activeTab === 'explorer') {
|
|
21
23
|
leftContent += ' ';
|
|
22
|
-
leftContent +=
|
|
24
|
+
leftContent += showOnlyChanges
|
|
25
|
+
? '{blue-fg}[changes]{/blue-fg}'
|
|
26
|
+
: '{gray-fg}[changes]{/gray-fg}';
|
|
23
27
|
}
|
|
24
28
|
// Right side: tabs
|
|
25
29
|
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
|
/**
|
|
@@ -59,7 +25,7 @@ function formatBranch(branch) {
|
|
|
59
25
|
/**
|
|
60
26
|
* Format header content as blessed-compatible tagged string.
|
|
61
27
|
*/
|
|
62
|
-
export function formatHeader(repoPath, branch, isLoading, error,
|
|
28
|
+
export function formatHeader(repoPath, branch, isLoading, error, width) {
|
|
63
29
|
if (!repoPath) {
|
|
64
30
|
return '{gray-fg}Waiting for target path...{/gray-fg}';
|
|
65
31
|
}
|
|
@@ -76,11 +42,6 @@ export function formatHeader(repoPath, branch, isLoading, error, watcherState, w
|
|
|
76
42
|
else if (error) {
|
|
77
43
|
leftContent += ` {red-fg}(${error}){/red-fg}`;
|
|
78
44
|
}
|
|
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
45
|
// Build right side content (branch info)
|
|
85
46
|
const rightContent = branch ? formatBranch(branch) : '';
|
|
86
47
|
if (rightContent) {
|
|
@@ -94,10 +55,6 @@ export function formatHeader(repoPath, branch, isLoading, error, watcherState, w
|
|
|
94
55
|
else if (error) {
|
|
95
56
|
leftLen += error.length + 3; // " (error)"
|
|
96
57
|
}
|
|
97
|
-
if (watcherState?.enabled && watcherState.sourceFile) {
|
|
98
|
-
const followPath = abbreviateHomePath(watcherState.sourceFile);
|
|
99
|
-
leftLen += 10 + followPath.length; // " (follow: path)"
|
|
100
|
-
}
|
|
101
58
|
const rightLen = branch
|
|
102
59
|
? branch.current.length +
|
|
103
60
|
(branch.tracking ? 3 + branch.tracking.length : 0) +
|
|
@@ -24,3 +24,40 @@ export function getFileListSectionCounts(files) {
|
|
|
24
24
|
stagedCount: staged.length,
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Which category does flat index `i` fall in, and what's the position within it?
|
|
29
|
+
* Flat order is: modified → untracked → staged.
|
|
30
|
+
*/
|
|
31
|
+
export function getCategoryForIndex(files, index) {
|
|
32
|
+
const { modified, untracked } = categorizeFiles(files);
|
|
33
|
+
const modLen = modified.length;
|
|
34
|
+
const untLen = untracked.length;
|
|
35
|
+
if (index < modLen) {
|
|
36
|
+
return { category: 'modified', categoryIndex: index };
|
|
37
|
+
}
|
|
38
|
+
if (index < modLen + untLen) {
|
|
39
|
+
return { category: 'untracked', categoryIndex: index - modLen };
|
|
40
|
+
}
|
|
41
|
+
return { category: 'staged', categoryIndex: index - modLen - untLen };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Convert category + position back to a flat index (clamped).
|
|
45
|
+
* If the target category is empty, falls back to last file overall, or 0 if no files.
|
|
46
|
+
*/
|
|
47
|
+
export function getIndexForCategoryPosition(files, category, categoryIndex) {
|
|
48
|
+
const { modified, untracked, staged, ordered } = categorizeFiles(files);
|
|
49
|
+
if (ordered.length === 0)
|
|
50
|
+
return 0;
|
|
51
|
+
const categories = { modified, untracked, staged };
|
|
52
|
+
const catFiles = categories[category];
|
|
53
|
+
if (catFiles.length === 0) {
|
|
54
|
+
return ordered.length - 1;
|
|
55
|
+
}
|
|
56
|
+
const clampedIndex = Math.min(categoryIndex, catFiles.length - 1);
|
|
57
|
+
const offsets = {
|
|
58
|
+
modified: 0,
|
|
59
|
+
untracked: modified.length,
|
|
60
|
+
staged: modified.length + untracked.length,
|
|
61
|
+
};
|
|
62
|
+
return offsets[category] + clampedIndex;
|
|
63
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility for building a tree view from flat file paths.
|
|
3
|
+
* Collapses single-child directories into combined path segments.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Build a tree structure from flat file paths.
|
|
7
|
+
* Paths should be sorted alphabetically before calling this.
|
|
8
|
+
*/
|
|
9
|
+
export function buildFileTree(files) {
|
|
10
|
+
// Root node
|
|
11
|
+
const root = {
|
|
12
|
+
name: '',
|
|
13
|
+
fullPath: '',
|
|
14
|
+
isDirectory: true,
|
|
15
|
+
children: [],
|
|
16
|
+
depth: 0,
|
|
17
|
+
};
|
|
18
|
+
// Build initial trie structure
|
|
19
|
+
for (let i = 0; i < files.length; i++) {
|
|
20
|
+
const file = files[i];
|
|
21
|
+
const parts = file.path.split('/');
|
|
22
|
+
let current = root;
|
|
23
|
+
for (let j = 0; j < parts.length; j++) {
|
|
24
|
+
const part = parts[j];
|
|
25
|
+
const isFile = j === parts.length - 1;
|
|
26
|
+
const pathSoFar = parts.slice(0, j + 1).join('/');
|
|
27
|
+
let child = current.children.find((c) => c.name === part && c.isDirectory === !isFile);
|
|
28
|
+
if (!child) {
|
|
29
|
+
child = {
|
|
30
|
+
name: part,
|
|
31
|
+
fullPath: pathSoFar,
|
|
32
|
+
isDirectory: !isFile,
|
|
33
|
+
children: [],
|
|
34
|
+
depth: current.depth + 1,
|
|
35
|
+
fileIndex: isFile ? i : undefined,
|
|
36
|
+
};
|
|
37
|
+
current.children.push(child);
|
|
38
|
+
}
|
|
39
|
+
current = child;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Collapse single-child directories
|
|
43
|
+
collapseTree(root);
|
|
44
|
+
// Sort children: directories first, then files, alphabetically
|
|
45
|
+
sortTree(root);
|
|
46
|
+
return root;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Collapse single-child directory chains.
|
|
50
|
+
* e.g., a -> b -> c -> file becomes "a/b/c" -> file
|
|
51
|
+
*/
|
|
52
|
+
function collapseTree(node) {
|
|
53
|
+
// First, recursively collapse children
|
|
54
|
+
for (const child of node.children) {
|
|
55
|
+
collapseTree(child);
|
|
56
|
+
}
|
|
57
|
+
// Then collapse this node's single-child directory chains
|
|
58
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
59
|
+
const child = node.children[i];
|
|
60
|
+
// Collapse if: directory with exactly one child that is also a directory
|
|
61
|
+
while (child.isDirectory && child.children.length === 1 && child.children[0].isDirectory) {
|
|
62
|
+
const grandchild = child.children[0];
|
|
63
|
+
child.name = `${child.name}/${grandchild.name}`;
|
|
64
|
+
child.fullPath = grandchild.fullPath;
|
|
65
|
+
child.children = grandchild.children;
|
|
66
|
+
// Update depths of all descendants
|
|
67
|
+
updateDepths(child, child.depth);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Update depths recursively after collapsing.
|
|
73
|
+
*/
|
|
74
|
+
function updateDepths(node, depth) {
|
|
75
|
+
node.depth = depth;
|
|
76
|
+
for (const child of node.children) {
|
|
77
|
+
updateDepths(child, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Sort tree: directories first (alphabetically), then files (alphabetically).
|
|
82
|
+
*/
|
|
83
|
+
function sortTree(node) {
|
|
84
|
+
node.children.sort((a, b) => {
|
|
85
|
+
// Directories before files
|
|
86
|
+
if (a.isDirectory && !b.isDirectory)
|
|
87
|
+
return -1;
|
|
88
|
+
if (!a.isDirectory && b.isDirectory)
|
|
89
|
+
return 1;
|
|
90
|
+
// Alphabetically within same type
|
|
91
|
+
return a.name.localeCompare(b.name);
|
|
92
|
+
});
|
|
93
|
+
for (const child of node.children) {
|
|
94
|
+
sortTree(child);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Flatten tree into a list of row items for rendering.
|
|
99
|
+
* Skips the root node (which has empty name).
|
|
100
|
+
*/
|
|
101
|
+
export function flattenTree(root) {
|
|
102
|
+
const rows = [];
|
|
103
|
+
function traverse(node, parentIsLast) {
|
|
104
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
105
|
+
const child = node.children[i];
|
|
106
|
+
const isLast = i === node.children.length - 1;
|
|
107
|
+
rows.push({
|
|
108
|
+
type: child.isDirectory ? 'directory' : 'file',
|
|
109
|
+
name: child.name,
|
|
110
|
+
fullPath: child.fullPath,
|
|
111
|
+
depth: child.depth - 1, // Subtract 1 because root is depth 0
|
|
112
|
+
fileIndex: child.fileIndex,
|
|
113
|
+
isLast,
|
|
114
|
+
parentIsLast: [...parentIsLast],
|
|
115
|
+
});
|
|
116
|
+
if (child.isDirectory) {
|
|
117
|
+
traverse(child, [...parentIsLast, isLast]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
traverse(root, []);
|
|
122
|
+
return rows;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Build tree prefix for rendering (the │ ├ └ characters).
|
|
126
|
+
*/
|
|
127
|
+
export function buildTreePrefix(row) {
|
|
128
|
+
let prefix = '';
|
|
129
|
+
// Add vertical lines for parent levels
|
|
130
|
+
for (let i = 0; i < row.depth; i++) {
|
|
131
|
+
if (row.parentIsLast[i]) {
|
|
132
|
+
prefix += ' '; // Parent was last, no line needed
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
prefix += '│ '; // Parent has siblings below, draw line
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Add connector for this item
|
|
139
|
+
if (row.depth >= 0) {
|
|
140
|
+
if (row.isLast) {
|
|
141
|
+
prefix += '└ ';
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
prefix += '├ ';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return prefix;
|
|
148
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// eslint.metrics.js
|
|
2
|
+
// Used by the metrics script to gather complexity data.
|
|
3
|
+
// Warns at low thresholds so all functions appear in the output.
|
|
4
|
+
import baseConfig from './eslint.config.js';
|
|
5
|
+
|
|
6
|
+
export default [
|
|
7
|
+
...baseConfig,
|
|
8
|
+
{
|
|
9
|
+
rules: {
|
|
10
|
+
complexity: ['warn', { max: 1 }],
|
|
11
|
+
'sonarjs/cognitive-complexity': ['warn', 1],
|
|
12
|
+
'max-depth': ['warn', { max: 1 }],
|
|
13
|
+
'max-lines-per-function': ['warn', { max: 1 }],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
];
|
package/metrics/.gitkeep
ADDED
|
File without changes
|