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.
@@ -16,7 +16,6 @@ const DEFAULT_STATE = {
16
16
  wrapMode: false,
17
17
  autoTabEnabled: false,
18
18
  mouseEnabled: true,
19
- showMiddleDots: false,
20
19
  hideHiddenFiles: true,
21
20
  hideGitignored: true,
22
21
  splitRatio: 0.4,
@@ -121,9 +120,6 @@ export class UIState extends EventEmitter {
121
120
  toggleMouse() {
122
121
  this.update({ mouseEnabled: !this._state.mouseEnabled });
123
122
  }
124
- toggleMiddleDots() {
125
- this.update({ showMiddleDots: !this._state.showMiddleDots });
126
- }
127
123
  toggleHideHiddenFiles() {
128
124
  this.update({ hideHiddenFiles: !this._state.hideHiddenFiles });
129
125
  }
@@ -179,4 +175,21 @@ export class UIState extends EventEmitter {
179
175
  this.setPane(currentPane === 'explorer' ? 'diff' : 'explorer');
180
176
  }
181
177
  }
178
+ // Reset repo-specific state when switching repositories
179
+ resetForNewRepo() {
180
+ this._state = {
181
+ ...this._state,
182
+ selectedIndex: 0,
183
+ fileListScrollOffset: 0,
184
+ diffScrollOffset: 0,
185
+ historySelectedIndex: 0,
186
+ historyScrollOffset: 0,
187
+ compareSelectedIndex: 0,
188
+ compareScrollOffset: 0,
189
+ explorerSelectedIndex: 0,
190
+ explorerScrollOffset: 0,
191
+ explorerFileScrollOffset: 0,
192
+ };
193
+ this.emit('change', this._state);
194
+ }
182
195
  }
@@ -0,0 +1,56 @@
1
+ import { formatFileList } from './widgets/FileList.js';
2
+ import { formatHistoryView } from './widgets/HistoryView.js';
3
+ import { formatCompareListView } from './widgets/CompareListView.js';
4
+ import { formatExplorerView } from './widgets/ExplorerView.js';
5
+ import { formatDiff, formatHistoryDiff } from './widgets/DiffView.js';
6
+ import { formatCommitPanel } from './widgets/CommitPanel.js';
7
+ import { formatExplorerContent } from './widgets/ExplorerContent.js';
8
+ /**
9
+ * Render the top pane content for the current tab.
10
+ */
11
+ export function renderTopPane(state, files, historyCommits, compareDiff, compareSelection, explorerState, width, topPaneHeight) {
12
+ if (state.bottomTab === 'history') {
13
+ return formatHistoryView(historyCommits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, topPaneHeight);
14
+ }
15
+ if (state.bottomTab === 'compare') {
16
+ const commits = compareDiff?.commits ?? [];
17
+ const compareFiles = compareDiff?.files ?? [];
18
+ return formatCompareListView(commits, compareFiles, compareSelection, state.currentPane === 'compare', width, state.compareScrollOffset, topPaneHeight);
19
+ }
20
+ if (state.bottomTab === 'explorer') {
21
+ const displayRows = explorerState?.displayRows ?? [];
22
+ return formatExplorerView(displayRows, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
23
+ }
24
+ // Default: diff tab file list
25
+ return formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, topPaneHeight);
26
+ }
27
+ /**
28
+ * Render the bottom pane content for the current tab.
29
+ */
30
+ export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight) {
31
+ if (state.bottomTab === 'commit') {
32
+ const content = formatCommitPanel(commitFlowState, stagedCount, width);
33
+ return { content, totalRows: 0 };
34
+ }
35
+ if (state.bottomTab === 'history') {
36
+ const selectedCommit = historyState?.selectedCommit ?? null;
37
+ const commitDiff = historyState?.commitDiff ?? null;
38
+ const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
39
+ return { content, totalRows };
40
+ }
41
+ if (state.bottomTab === 'compare') {
42
+ const compareDiff = compareSelectionState?.diff ?? null;
43
+ if (compareDiff) {
44
+ const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
45
+ return { content, totalRows };
46
+ }
47
+ return { content: '{gray-fg}Select a commit or file to view diff{/gray-fg}', totalRows: 0 };
48
+ }
49
+ if (state.bottomTab === 'explorer') {
50
+ const content = formatExplorerContent(explorerSelectedFile?.path ?? null, explorerSelectedFile?.content ?? null, width, state.explorerFileScrollOffset, bottomPaneHeight, explorerSelectedFile?.truncated ?? false, state.wrapMode);
51
+ return { content, totalRows: 0 };
52
+ }
53
+ // Default: diff tab
54
+ const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
55
+ return { content, totalRows };
56
+ }
@@ -0,0 +1,232 @@
1
+ import blessed from 'neo-blessed';
2
+ const MAX_RESULTS = 15;
3
+ /**
4
+ * Simple fuzzy match scoring.
5
+ * Returns -1 if no match, otherwise a score (higher is better).
6
+ */
7
+ function fuzzyScore(query, target) {
8
+ const lowerQuery = query.toLowerCase();
9
+ const lowerTarget = target.toLowerCase();
10
+ // Must contain all query characters in order
11
+ let queryIndex = 0;
12
+ let score = 0;
13
+ let lastMatchIndex = -1;
14
+ for (let i = 0; i < lowerTarget.length && queryIndex < lowerQuery.length; i++) {
15
+ if (lowerTarget[i] === lowerQuery[queryIndex]) {
16
+ // Bonus for consecutive matches
17
+ if (lastMatchIndex === i - 1) {
18
+ score += 10;
19
+ }
20
+ // Bonus for matching at start of word
21
+ if (i === 0 || lowerTarget[i - 1] === '/' || lowerTarget[i - 1] === '.') {
22
+ score += 5;
23
+ }
24
+ score += 1;
25
+ lastMatchIndex = i;
26
+ queryIndex++;
27
+ }
28
+ }
29
+ // All query characters must match
30
+ if (queryIndex < lowerQuery.length) {
31
+ return -1;
32
+ }
33
+ // Bonus for shorter paths (more specific)
34
+ score += Math.max(0, 50 - target.length);
35
+ return score;
36
+ }
37
+ /**
38
+ * Highlight matched characters in path.
39
+ */
40
+ function highlightMatch(query, path) {
41
+ if (!query)
42
+ return path;
43
+ const lowerQuery = query.toLowerCase();
44
+ const lowerPath = path.toLowerCase();
45
+ let result = '';
46
+ let queryIndex = 0;
47
+ for (let i = 0; i < path.length; i++) {
48
+ if (queryIndex < lowerQuery.length && lowerPath[i] === lowerQuery[queryIndex]) {
49
+ result += `{yellow-fg}${path[i]}{/yellow-fg}`;
50
+ queryIndex++;
51
+ }
52
+ else {
53
+ result += path[i];
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+ /**
59
+ * FileFinder modal for fuzzy file search.
60
+ */
61
+ export class FileFinder {
62
+ box;
63
+ textbox;
64
+ screen;
65
+ allPaths;
66
+ results = [];
67
+ selectedIndex = 0;
68
+ query = '';
69
+ onSelect;
70
+ onCancel;
71
+ constructor(screen, allPaths, onSelect, onCancel) {
72
+ this.screen = screen;
73
+ this.allPaths = allPaths;
74
+ this.onSelect = onSelect;
75
+ this.onCancel = onCancel;
76
+ // Create modal box
77
+ const width = Math.min(80, screen.width - 10);
78
+ const height = MAX_RESULTS + 6; // results + input + header + borders + padding
79
+ this.box = blessed.box({
80
+ parent: screen,
81
+ top: 'center',
82
+ left: 'center',
83
+ width,
84
+ height,
85
+ border: {
86
+ type: 'line',
87
+ },
88
+ style: {
89
+ border: {
90
+ fg: 'cyan',
91
+ },
92
+ },
93
+ tags: true,
94
+ keys: false, // We'll handle keys ourselves
95
+ });
96
+ // Create text input
97
+ this.textbox = blessed.textarea({
98
+ parent: this.box,
99
+ top: 1,
100
+ left: 1,
101
+ width: width - 4,
102
+ height: 1,
103
+ inputOnFocus: true,
104
+ style: {
105
+ fg: 'white',
106
+ bg: 'default',
107
+ },
108
+ });
109
+ // Setup key handlers
110
+ this.setupKeyHandlers();
111
+ // Initial render with all files
112
+ this.updateResults();
113
+ this.render();
114
+ }
115
+ setupKeyHandlers() {
116
+ // Handle escape to cancel
117
+ this.textbox.key(['escape'], () => {
118
+ this.close();
119
+ this.onCancel();
120
+ });
121
+ // Handle enter to select
122
+ this.textbox.key(['enter'], () => {
123
+ if (this.results.length > 0) {
124
+ const selected = this.results[this.selectedIndex];
125
+ this.close();
126
+ this.onSelect(selected.path);
127
+ }
128
+ });
129
+ // Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
130
+ this.textbox.key(['C-j', 'down'], () => {
131
+ this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
132
+ this.render();
133
+ });
134
+ this.textbox.key(['C-k', 'up'], () => {
135
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
136
+ this.render();
137
+ });
138
+ // Handle tab for next result
139
+ this.textbox.key(['tab'], () => {
140
+ this.selectedIndex = (this.selectedIndex + 1) % Math.max(1, this.results.length);
141
+ this.render();
142
+ });
143
+ // Handle shift-tab for previous result
144
+ this.textbox.key(['S-tab'], () => {
145
+ this.selectedIndex =
146
+ (this.selectedIndex - 1 + this.results.length) % Math.max(1, this.results.length);
147
+ this.render();
148
+ });
149
+ // Update results on keypress
150
+ this.textbox.on('keypress', () => {
151
+ // Defer to next tick to get updated value
152
+ setImmediate(() => {
153
+ const newQuery = this.textbox.getValue() || '';
154
+ if (newQuery !== this.query) {
155
+ this.query = newQuery;
156
+ this.selectedIndex = 0;
157
+ this.updateResults();
158
+ this.render();
159
+ }
160
+ });
161
+ });
162
+ }
163
+ updateResults() {
164
+ if (!this.query) {
165
+ // Show first N files when no query
166
+ this.results = this.allPaths.slice(0, MAX_RESULTS).map((path) => ({ path, score: 0 }));
167
+ return;
168
+ }
169
+ // Fuzzy match all paths
170
+ const scored = [];
171
+ for (const path of this.allPaths) {
172
+ const score = fuzzyScore(this.query, path);
173
+ if (score >= 0) {
174
+ scored.push({ path, score });
175
+ }
176
+ }
177
+ // Sort by score (descending)
178
+ scored.sort((a, b) => b.score - a.score);
179
+ // Take top results
180
+ this.results = scored.slice(0, MAX_RESULTS);
181
+ }
182
+ render() {
183
+ const lines = [];
184
+ const width = this.box.width - 4;
185
+ // Header
186
+ lines.push('{bold}{cyan-fg}Find File{/cyan-fg}{/bold}');
187
+ lines.push(''); // Space for input
188
+ lines.push('');
189
+ // Results
190
+ if (this.results.length === 0 && this.query) {
191
+ lines.push('{gray-fg}No matches{/gray-fg}');
192
+ }
193
+ else {
194
+ for (let i = 0; i < this.results.length; i++) {
195
+ const result = this.results[i];
196
+ const isSelected = i === this.selectedIndex;
197
+ // Truncate path if needed
198
+ let displayPath = result.path;
199
+ const maxLen = width - 4;
200
+ if (displayPath.length > maxLen) {
201
+ displayPath = '…' + displayPath.slice(-(maxLen - 1));
202
+ }
203
+ // Highlight matched characters
204
+ const highlighted = highlightMatch(this.query, displayPath);
205
+ if (isSelected) {
206
+ lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
207
+ }
208
+ else {
209
+ lines.push(` ${highlighted}`);
210
+ }
211
+ }
212
+ }
213
+ // Pad to fill space
214
+ while (lines.length < MAX_RESULTS + 3) {
215
+ lines.push('');
216
+ }
217
+ // Footer
218
+ lines.push('{gray-fg}Enter: select | Esc: cancel | Ctrl+j/k or ↑↓: navigate{/gray-fg}');
219
+ this.box.setContent(lines.join('\n'));
220
+ this.screen.render();
221
+ }
222
+ close() {
223
+ this.textbox.destroy();
224
+ this.box.destroy();
225
+ }
226
+ /**
227
+ * Focus the modal input.
228
+ */
229
+ focus() {
230
+ this.textbox.focus();
231
+ }
232
+ }
@@ -1,6 +1,17 @@
1
1
  import { formatDate } from '../../utils/formatDate.js';
2
2
  import { formatCommitDisplay } from '../../utils/commitFormat.js';
3
- import { shortenPath } from '../../utils/formatPath.js';
3
+ import { buildFileTree, flattenTree, buildTreePrefix } from '../../utils/fileTree.js';
4
+ // ANSI escape codes for raw terminal output (avoids blessed tag escaping issues)
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
15
  /**
5
16
  * Build the list of row items for the compare list view.
6
17
  */
@@ -15,26 +26,29 @@ export function buildCompareListRows(commits, files, commitsExpanded = true, fil
15
26
  });
16
27
  }
17
28
  }
18
- // Files section
29
+ // Files section with tree view
19
30
  if (files.length > 0) {
20
31
  if (commits.length > 0) {
21
32
  result.push({ type: 'spacer' });
22
33
  }
23
34
  result.push({ type: 'section-header', sectionType: 'files' });
24
35
  if (filesExpanded) {
25
- files.forEach((file, i) => {
26
- result.push({ type: 'file', fileIndex: i, file });
27
- });
36
+ // Build tree from files
37
+ const tree = buildFileTree(files);
38
+ const treeRows = flattenTree(tree);
39
+ for (const treeRow of treeRows) {
40
+ if (treeRow.type === 'directory') {
41
+ result.push({ type: 'directory', treeRow });
42
+ }
43
+ else {
44
+ const file = files[treeRow.fileIndex];
45
+ result.push({ type: 'file', fileIndex: treeRow.fileIndex, file, treeRow });
46
+ }
47
+ }
28
48
  }
29
49
  }
30
50
  return result;
31
51
  }
32
- /**
33
- * Escape blessed tags in content.
34
- */
35
- function escapeContent(content) {
36
- return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
37
- }
38
52
  /**
39
53
  * Format a commit row.
40
54
  */
@@ -45,63 +59,81 @@ function formatCommitRow(commit, isSelected, isFocused, width) {
45
59
  const baseWidth = 2 + 7 + 4 + dateStr.length + 2;
46
60
  const remainingWidth = Math.max(10, width - baseWidth);
47
61
  const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
48
- let line = ' ';
49
- line += `{yellow-fg}${commit.shortHash}{/yellow-fg} `;
62
+ let line = ` ${ANSI_YELLOW}${commit.shortHash}${ANSI_RESET} `;
50
63
  if (isHighlighted) {
51
- line += `{cyan-fg}{inverse}${escapeContent(displayMessage)}{/inverse}{/cyan-fg}`;
64
+ line += `${ANSI_CYAN}${ANSI_INVERSE}${displayMessage}${ANSI_RESET}`;
52
65
  }
53
66
  else {
54
- line += escapeContent(displayMessage);
67
+ line += displayMessage;
55
68
  }
56
- line += ` {gray-fg}(${dateStr}){/gray-fg}`;
69
+ line += ` ${ANSI_GRAY}(${dateStr})${ANSI_RESET}`;
57
70
  if (displayRefs) {
58
- line += ` {green-fg}${escapeContent(displayRefs)}{/green-fg}`;
71
+ line += ` ${ANSI_GREEN}${displayRefs}${ANSI_RESET}`;
59
72
  }
60
- return line;
73
+ return `{escape}${line}{/escape}`;
74
+ }
75
+ /**
76
+ * Format a directory row in tree view.
77
+ */
78
+ function formatDirectoryRow(treeRow, width) {
79
+ const prefix = buildTreePrefix(treeRow);
80
+ const icon = '▸ '; // Collapsed folder icon (we don't support expanding individual folders yet)
81
+ // Truncate name if needed
82
+ const maxNameLen = width - prefix.length - icon.length - 2;
83
+ let name = treeRow.name;
84
+ if (name.length > maxNameLen) {
85
+ name = name.slice(0, maxNameLen - 1) + '…';
86
+ }
87
+ const line = `${ANSI_GRAY}${prefix}${ANSI_RESET}${ANSI_BLUE}${icon}${name}${ANSI_RESET}`;
88
+ return `{escape}${line}{/escape}`;
61
89
  }
62
90
  /**
63
- * Format a file row.
91
+ * Format a file row in tree view.
64
92
  */
65
- function formatFileRow(file, isSelected, isFocused, maxPathLength) {
93
+ function formatFileRow(file, treeRow, isSelected, isFocused, width) {
66
94
  const isHighlighted = isSelected && isFocused;
67
95
  const isUncommitted = file.isUncommitted ?? false;
96
+ const prefix = buildTreePrefix(treeRow);
68
97
  const statusColors = {
69
- added: 'green',
70
- modified: 'yellow',
71
- deleted: 'red',
72
- renamed: 'blue',
98
+ added: ANSI_GREEN,
99
+ modified: ANSI_YELLOW,
100
+ deleted: ANSI_RED,
101
+ renamed: ANSI_BLUE,
73
102
  };
74
- const statusChars = {
75
- added: 'A',
76
- modified: 'M',
77
- deleted: 'D',
78
- renamed: 'R',
103
+ // File icon based on status
104
+ const statusIcons = {
105
+ added: '+',
106
+ modified: '',
107
+ deleted: '',
108
+ renamed: '→',
79
109
  };
80
- // Account for stats: " (+123 -456)" and possible "*" for uncommitted
81
- const statsLength = 5 + String(file.additions).length + String(file.deletions).length;
82
- const uncommittedLength = isUncommitted ? 14 : 0;
83
- const availableForPath = Math.max(10, maxPathLength - statsLength - uncommittedLength);
84
- let line = ' ';
85
- if (isUncommitted) {
86
- line += '{magenta-fg}{bold}*{/bold}{/magenta-fg}';
87
- }
88
- const statusColor = isUncommitted ? 'magenta' : statusColors[file.status];
89
- line += `{${statusColor}-fg}{bold}${statusChars[file.status]}{/bold}{/${statusColor}-fg} `;
90
- const displayPath = shortenPath(file.path, availableForPath);
110
+ const statusColor = isUncommitted ? ANSI_MAGENTA : statusColors[file.status];
111
+ const icon = statusIcons[file.status];
112
+ // Calculate available width for filename
113
+ const statsStr = `(+${file.additions} -${file.deletions})`;
114
+ const uncommittedStr = isUncommitted ? ' [uncommitted]' : '';
115
+ const fixedWidth = prefix.length + 2 + statsStr.length + uncommittedStr.length + 2;
116
+ const maxNameLen = Math.max(5, width - fixedWidth);
117
+ let name = treeRow.name;
118
+ if (name.length > maxNameLen) {
119
+ name = name.slice(0, maxNameLen - 1) + '…';
120
+ }
121
+ let line = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
122
+ line += `${statusColor}${icon}${ANSI_RESET} `;
91
123
  if (isHighlighted) {
92
- line += `{cyan-fg}{inverse}${escapeContent(displayPath)}{/inverse}{/cyan-fg}`;
124
+ line += `${ANSI_CYAN}${ANSI_INVERSE}${name}${ANSI_RESET}`;
93
125
  }
94
126
  else if (isUncommitted) {
95
- line += `{magenta-fg}${escapeContent(displayPath)}{/magenta-fg}`;
127
+ line += `${ANSI_MAGENTA}${name}${ANSI_RESET}`;
96
128
  }
97
129
  else {
98
- line += escapeContent(displayPath);
130
+ line += name;
99
131
  }
100
- line += ` {gray-fg}({/gray-fg}{green-fg}+${file.additions}{/green-fg} {red-fg}-${file.deletions}{/red-fg}{gray-fg}){/gray-fg}`;
132
+ line += ` ${ANSI_GRAY}(${ANSI_GREEN}+${file.additions}${ANSI_RESET} ${ANSI_RED}-${file.deletions}${ANSI_GRAY})${ANSI_RESET}`;
101
133
  if (isUncommitted) {
102
- line += ' {magenta-fg}[uncommitted]{/magenta-fg}';
134
+ line += ` ${ANSI_MAGENTA}[uncommitted]${ANSI_RESET}`;
103
135
  }
104
- return line;
136
+ return `{escape}${line}{/escape}`;
105
137
  }
106
138
  /**
107
139
  * Format the compare list view as blessed-compatible tagged string.
@@ -121,7 +153,7 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
121
153
  const isCommits = row.sectionType === 'commits';
122
154
  const count = isCommits ? commits.length : files.length;
123
155
  const label = isCommits ? 'Commits' : 'Files';
124
- lines.push(`{cyan-fg}{bold}▼ ${label}{/bold}{/cyan-fg} {gray-fg}(${count}){/gray-fg}`);
156
+ lines.push(`{escape}${ANSI_CYAN}${ANSI_BOLD}▼ ${label}${ANSI_RESET} ${ANSI_GRAY}(${count})${ANSI_RESET}{/escape}`);
125
157
  }
126
158
  else if (row.type === 'spacer') {
127
159
  lines.push('');
@@ -130,9 +162,12 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
130
162
  const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
131
163
  lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
132
164
  }
133
- else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
165
+ else if (row.type === 'directory' && row.treeRow) {
166
+ lines.push(formatDirectoryRow(row.treeRow, width));
167
+ }
168
+ else if (row.type === 'file' && row.file && row.fileIndex !== undefined && row.treeRow) {
134
169
  const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
135
- lines.push(formatFileRow(row.file, isSelected, isFocused, width - 5));
170
+ lines.push(formatFileRow(row.file, row.treeRow, isSelected, isFocused, width));
136
171
  }
137
172
  }
138
173
  return lines.join('\n');
@@ -141,24 +176,11 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
141
176
  * Get the total number of rows in the compare list view (for scroll calculation).
142
177
  */
143
178
  export function getCompareListTotalRows(commits, files, commitsExpanded = true, filesExpanded = true) {
144
- let count = 0;
145
- if (commits.length > 0) {
146
- count += 1; // header
147
- if (commitsExpanded)
148
- count += commits.length;
149
- }
150
- if (files.length > 0) {
151
- if (commits.length > 0)
152
- count += 1; // spacer
153
- count += 1; // header
154
- if (filesExpanded)
155
- count += files.length;
156
- }
157
- return count;
179
+ return buildCompareListRows(commits, files, commitsExpanded, filesExpanded).length;
158
180
  }
159
181
  /**
160
182
  * Map a row index to a selection.
161
- * Returns null if the row is a header or spacer.
183
+ * Returns null if the row is a header, spacer, or directory.
162
184
  */
163
185
  export function getCompareSelectionFromRow(rowIndex, commits, files, commitsExpanded = true, filesExpanded = true) {
164
186
  const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
@@ -1,7 +1,12 @@
1
1
  import { getTheme } from '../../themes.js';
2
2
  import { buildDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, } from '../../utils/displayRows.js';
3
- import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
4
3
  import { truncateAnsi } from '../../utils/ansiTruncate.js';
4
+ // ANSI escape codes for raw terminal output (avoids blessed tag escaping issues)
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';
5
10
  /**
6
11
  * Truncate string to fit within maxWidth, adding ellipsis if needed.
7
12
  */
@@ -44,7 +49,6 @@ function ansiFg(hex) {
44
49
  const b = parseInt(hex.slice(5, 7), 16);
45
50
  return `\x1b[38;2;${r};${g};${b}m`;
46
51
  }
47
- const ANSI_RESET = '\x1b[0m';
48
52
  /**
49
53
  * Format a single display row as blessed-compatible tagged string.
50
54
  */
@@ -58,10 +62,10 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
58
62
  if (match) {
59
63
  const maxPathLen = headerWidth - 6;
60
64
  const path = truncate(match[1], maxPathLen);
61
- return `{bold}{cyan-fg}\u2500\u2500 ${path} \u2500\u2500{/cyan-fg}{/bold}`;
65
+ return `{escape}${ANSI_BOLD}${ANSI_CYAN}\u2500\u2500 ${path} \u2500\u2500${ANSI_RESET}{/escape}`;
62
66
  }
63
67
  }
64
- return `{gray-fg}${escapeContent(truncate(content, headerWidth))}{/gray-fg}`;
68
+ return `{escape}${ANSI_GRAY}${truncate(content, headerWidth)}${ANSI_RESET}{/escape}`;
65
69
  }
66
70
  case 'diff-hunk': {
67
71
  const match = row.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
@@ -78,9 +82,9 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
78
82
  const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
79
83
  const contextMaxLen = headerWidth - rangeText.length - 1;
80
84
  const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
81
- return `{cyan-fg}${rangeText}{/cyan-fg}{gray-fg}${truncatedContext}{/gray-fg}`;
85
+ return `{escape}${ANSI_CYAN}${rangeText}${ANSI_GRAY}${truncatedContext}${ANSI_RESET}{/escape}`;
82
86
  }
83
- return `{cyan-fg}${escapeContent(truncate(row.content, headerWidth))}{/cyan-fg}`;
87
+ return `{escape}${ANSI_CYAN}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
84
88
  }
85
89
  case 'diff-add': {
86
90
  const isCont = row.isContinuation;
@@ -212,23 +216,21 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
212
216
  const lineNum = formatLineNum(row.lineNum, lineNumWidth);
213
217
  const prefix = `${lineNum} ${symbol} `;
214
218
  const rawContent = row.content || '';
219
+ // Use {escape} for raw ANSI output (consistent with add/del lines)
220
+ // This avoids blessed's tag escaping issues with braces
221
+ const prefixAnsi = `\x1b[90m${prefix}\x1b[0m`; // gray prefix
215
222
  // Use syntax highlighting if available (not for continuations)
216
223
  if (row.highlighted && !isCont) {
217
- const truncatedHighlight = wrapMode
218
- ? row.highlighted
219
- : truncateAnsi(row.highlighted, contentWidth);
220
- const highlightedContent = ansiToBlessed(truncatedHighlight);
221
- return `{gray-fg}${prefix}{/gray-fg}${highlightedContent}`;
224
+ const content = wrapMode ? row.highlighted : truncateAnsi(row.highlighted, contentWidth);
225
+ return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
222
226
  }
223
- const content = wrapMode
224
- ? escapeContent(rawContent)
225
- : escapeContent(truncate(rawContent, contentWidth));
226
- return `{gray-fg}${prefix}{/gray-fg}${content}`;
227
+ const content = wrapMode ? rawContent : truncate(rawContent, contentWidth);
228
+ return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
227
229
  }
228
230
  case 'commit-header':
229
- return `{yellow-fg}${escapeContent(truncate(row.content, headerWidth))}{/yellow-fg}`;
231
+ return `{escape}${ANSI_YELLOW}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
230
232
  case 'commit-message':
231
- return escapeContent(truncate(row.content, headerWidth));
233
+ return `{escape}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
232
234
  case 'spacer':
233
235
  return '';
234
236
  }