diffstalker 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. package/dist/utils/rowCalculations.js +0 -246
@@ -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,122 @@ 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}`;
61
74
  }
62
75
  /**
63
- * Format a file row.
76
+ * Format a directory row in tree view.
64
77
  */
65
- function formatFileRow(file, isSelected, isFocused, maxPathLength) {
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}`;
89
+ }
90
+ /**
91
+ * Format a file row in tree view.
92
+ */
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}';
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) + '…';
87
120
  }
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);
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}`;
137
+ }
138
+ /**
139
+ * Check if a row is currently selected.
140
+ */
141
+ function isRowSelected(row, selectedItem) {
142
+ if (!selectedItem)
143
+ return false;
144
+ if (row.type === 'commit' && row.commitIndex !== undefined) {
145
+ return selectedItem.type === 'commit' && selectedItem.index === row.commitIndex;
146
+ }
147
+ if (row.type === 'file' && row.fileIndex !== undefined) {
148
+ return selectedItem.type === 'file' && selectedItem.index === row.fileIndex;
149
+ }
150
+ return false;
151
+ }
152
+ /**
153
+ * Format a section header line (e.g. "▼ Commits (5)").
154
+ */
155
+ function formatSectionHeader(label, count) {
156
+ return `{escape}${ANSI_CYAN}${ANSI_BOLD}▼ ${label}${ANSI_RESET} ${ANSI_GRAY}(${count})${ANSI_RESET}{/escape}`;
157
+ }
158
+ /**
159
+ * Format a single compare list row, returning null for unrenderable rows.
160
+ */
161
+ function formatCompareRow(row, selectedItem, isFocused, commits, files, width) {
162
+ if (row.type === 'section-header') {
163
+ const isCommits = row.sectionType === 'commits';
164
+ return formatSectionHeader(isCommits ? 'Commits' : 'Files', isCommits ? commits.length : files.length);
165
+ }
166
+ if (row.type === 'spacer')
167
+ return '';
168
+ if (row.type === 'directory' && row.treeRow)
169
+ return formatDirectoryRow(row.treeRow, width);
170
+ const selected = isRowSelected(row, selectedItem);
171
+ if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
172
+ return formatCommitRow(row.commit, selected, isFocused, width);
173
+ }
174
+ if (row.type === 'file' && row.file && row.fileIndex !== undefined && row.treeRow) {
175
+ return formatFileRow(row.file, row.treeRow, selected, isFocused, width);
176
+ }
177
+ return null;
105
178
  }
106
179
  /**
107
180
  * Format the compare list view as blessed-compatible tagged string.
@@ -115,50 +188,20 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
115
188
  const visibleRows = maxHeight
116
189
  ? rows.slice(scrollOffset, scrollOffset + maxHeight)
117
190
  : rows.slice(scrollOffset);
118
- const lines = [];
119
- for (const row of visibleRows) {
120
- if (row.type === 'section-header') {
121
- const isCommits = row.sectionType === 'commits';
122
- const count = isCommits ? commits.length : files.length;
123
- const label = isCommits ? 'Commits' : 'Files';
124
- lines.push(`{cyan-fg}{bold}▼ ${label}{/bold}{/cyan-fg} {gray-fg}(${count}){/gray-fg}`);
125
- }
126
- else if (row.type === 'spacer') {
127
- lines.push('');
128
- }
129
- else if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
130
- const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
131
- lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
132
- }
133
- else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
134
- const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
135
- lines.push(formatFileRow(row.file, isSelected, isFocused, width - 5));
136
- }
137
- }
191
+ const lines = visibleRows
192
+ .map((row) => formatCompareRow(row, selectedItem, isFocused, commits, files, width))
193
+ .filter((line) => line !== null);
138
194
  return lines.join('\n');
139
195
  }
140
196
  /**
141
197
  * Get the total number of rows in the compare list view (for scroll calculation).
142
198
  */
143
199
  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;
200
+ return buildCompareListRows(commits, files, commitsExpanded, filesExpanded).length;
158
201
  }
159
202
  /**
160
203
  * Map a row index to a selection.
161
- * Returns null if the row is a header or spacer.
204
+ * Returns null if the row is a header, spacer, or directory.
162
205
  */
163
206
  export function getCompareSelectionFromRow(rowIndex, commits, files, commitsExpanded = true, filesExpanded = true) {
164
207
  const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);