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.
@@ -1,16 +1,13 @@
1
- import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, applyMiddleDots, } from '../../utils/explorerDisplayRows.js';
1
+ import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, } from '../../utils/explorerDisplayRows.js';
2
2
  import { truncateAnsi } from '../../utils/ansiTruncate.js';
3
- import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
4
- /**
5
- * Escape blessed tags in content.
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, showMiddleDots = 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
- lines.push(`{yellow-fg}${escapeContent(row.content)}{/yellow-fg}`);
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 or middle-dots mode
56
- const canUseHighlighting = row.highlighted && !isContinuation && !showMiddleDots;
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
- const truncatedHighlight = shouldTruncate
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 = escapeContent(plainContent);
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
- * Escape blessed tags in content.
13
+ * Build tree prefix characters (│ ├ └).
3
14
  */
4
- function escapeContent(content) {
5
- return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
15
+ function buildTreePrefix(row) {
16
+ let prefix = '';
17
+ // Add vertical lines for parent levels
18
+ for (let i = 0; i < row.depth; i++) {
19
+ if (row.parentIsLast[i]) {
20
+ prefix += ' '; // Parent was last, no line needed
21
+ }
22
+ else {
23
+ prefix += '│ '; // Parent has siblings below, draw line
24
+ }
25
+ }
26
+ // Add connector for this item
27
+ if (row.depth > 0 || row.parentIsLast.length === 0) {
28
+ if (row.isLast) {
29
+ prefix += '└ ';
30
+ }
31
+ else {
32
+ prefix += '├ ';
33
+ }
34
+ }
35
+ return prefix;
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
- * Format the explorer directory listing as blessed-compatible tagged string.
61
+ * Get color for git status.
9
62
  */
10
- export function formatExplorerView(items, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, isLoading = false, error = null) {
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 (items.length === 0) {
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 visibleItems = maxHeight
22
- ? items.slice(scrollOffset, scrollOffset + maxHeight)
23
- : items.slice(scrollOffset);
24
- // Calculate max name width for alignment
25
- const maxNameWidth = Math.min(Math.max(...items.map((item) => item.name.length + (item.isDirectory ? 1 : 0))), width - 10);
97
+ const visibleRows = maxHeight
98
+ ? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
99
+ : displayRows.slice(scrollOffset);
26
100
  const lines = [];
27
- for (let i = 0; i < visibleItems.length; i++) {
28
- const item = visibleItems[i];
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 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}`;
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 = `{cyan-fg}{bold}{inverse}${escapeContent(paddedName)}{/inverse}{/bold}{/cyan-fg}`;
140
+ line += `${ANSI_BLUE}${displayName}${ANSI_RESET}`;
42
141
  }
43
142
  }
44
143
  else {
45
- // Not selected or not focused
46
- if (item.isDirectory) {
47
- line = `{blue-fg}${escapeContent(paddedName)}{/blue-fg}`;
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 = escapeContent(paddedName);
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(items) {
88
- return items.length;
196
+ export function getExplorerTotalRows(displayRows) {
197
+ return displayRows.length;
89
198
  }
90
199
  /**
91
- * Get item at index.
200
+ * Get row at index.
92
201
  */
93
- export function getExplorerItemAtIndex(items, index) {
94
- return items[index] ?? null;
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, showMiddleDots, width) {
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 += showMiddleDots ? '{blue-fg}[dots]{/blue-fg}' : '{gray-fg}[dots]{/gray-fg}';
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(repoPath, branch, watcherState, width, error = null, isLoading = false) {
6
- if (!repoPath)
7
- return 1;
8
- const displayPath = abbreviateHomePath(repoPath);
9
- const isNotGitRepo = error === 'Not a git repository';
10
- // Calculate branch width
11
- let branchWidth = 0;
12
- if (branch) {
13
- branchWidth = branch.current.length;
14
- if (branch.tracking)
15
- branchWidth += 3 + branch.tracking.length;
16
- if (branch.ahead > 0)
17
- branchWidth += 3 + String(branch.ahead).length;
18
- if (branch.behind > 0)
19
- branchWidth += 3 + String(branch.behind).length;
20
- }
21
- // Calculate left side width
22
- let leftWidth = displayPath.length;
23
- if (isLoading)
24
- leftWidth += 2;
25
- if (isNotGitRepo)
26
- leftWidth += 24;
27
- if (error && !isNotGitRepo)
28
- leftWidth += error.length + 3;
29
- // Check if follow indicator causes wrap
30
- if (watcherState?.enabled && watcherState.sourceFile) {
31
- const followPath = abbreviateHomePath(watcherState.sourceFile);
32
- const fullFollow = ` (follow: ${followPath})`;
33
- const availableOneLine = width - leftWidth - branchWidth - 4;
34
- if (fullFollow.length > availableOneLine) {
35
- const availableWithWrap = width - leftWidth - 2;
36
- if (fullFollow.length <= availableWithWrap) {
37
- return 2;
38
- }
39
- }
40
- }
6
+ export function getHeaderHeight() {
41
7
  return 1;
42
8
  }
43
9
  /**
@@ -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, watcherState, width) {
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
+ ];
File without changes