diffstalker 0.2.1 → 0.2.3

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 (42) 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/README.md +43 -35
  5. package/bun.lock +60 -4
  6. package/dist/App.js +495 -131
  7. package/dist/KeyBindings.js +134 -10
  8. package/dist/MouseHandlers.js +67 -20
  9. package/dist/core/ExplorerStateManager.js +37 -75
  10. package/dist/core/GitStateManager.js +252 -46
  11. package/dist/git/diff.js +99 -18
  12. package/dist/git/status.js +111 -54
  13. package/dist/git/test-helpers.js +67 -0
  14. package/dist/index.js +54 -43
  15. package/dist/ipc/CommandClient.js +6 -7
  16. package/dist/state/UIState.js +22 -0
  17. package/dist/types/remote.js +5 -0
  18. package/dist/ui/PaneRenderers.js +45 -15
  19. package/dist/ui/modals/BranchPicker.js +157 -0
  20. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  21. package/dist/ui/modals/FileFinder.js +45 -75
  22. package/dist/ui/modals/HotkeysModal.js +35 -3
  23. package/dist/ui/modals/SoftResetConfirm.js +68 -0
  24. package/dist/ui/modals/StashListModal.js +98 -0
  25. package/dist/ui/modals/ThemePicker.js +1 -2
  26. package/dist/ui/widgets/CommitPanel.js +113 -7
  27. package/dist/ui/widgets/CompareListView.js +44 -23
  28. package/dist/ui/widgets/DiffView.js +216 -170
  29. package/dist/ui/widgets/ExplorerView.js +50 -54
  30. package/dist/ui/widgets/FileList.js +62 -95
  31. package/dist/ui/widgets/FlatFileList.js +65 -0
  32. package/dist/ui/widgets/Footer.js +25 -15
  33. package/dist/ui/widgets/Header.js +51 -9
  34. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  35. package/dist/utils/ansiTruncate.js +0 -1
  36. package/dist/utils/displayRows.js +101 -21
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +0 -1
  40. package/metrics/v0.2.2.json +229 -0
  41. package/metrics/v0.2.3.json +243 -0
  42. package/package.json +10 -3
@@ -1,53 +1,5 @@
1
1
  import { categorizeFiles } from '../../utils/fileCategories.js';
2
- import { shortenPath } from '../../utils/formatPath.js';
3
- function getStatusChar(status) {
4
- switch (status) {
5
- case 'modified':
6
- return 'M';
7
- case 'added':
8
- return 'A';
9
- case 'deleted':
10
- return 'D';
11
- case 'untracked':
12
- return '?';
13
- case 'renamed':
14
- return 'R';
15
- case 'copied':
16
- return 'C';
17
- default:
18
- return ' ';
19
- }
20
- }
21
- function getStatusColor(status) {
22
- switch (status) {
23
- case 'modified':
24
- return 'yellow';
25
- case 'added':
26
- return 'green';
27
- case 'deleted':
28
- return 'red';
29
- case 'untracked':
30
- return 'gray';
31
- case 'renamed':
32
- return 'blue';
33
- case 'copied':
34
- return 'cyan';
35
- default:
36
- return 'white';
37
- }
38
- }
39
- function formatStats(insertions, deletions) {
40
- if (insertions === undefined && deletions === undefined)
41
- return '';
42
- const parts = [];
43
- if (insertions !== undefined && insertions > 0) {
44
- parts.push(`{green-fg}+${insertions}{/green-fg}`);
45
- }
46
- if (deletions !== undefined && deletions > 0) {
47
- parts.push(`{red-fg}-${deletions}{/red-fg}`);
48
- }
49
- return parts.length > 0 ? ' ' + parts.join(' ') : '';
50
- }
2
+ import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
51
3
  /**
52
4
  * Build the list of row items for the file list.
53
5
  */
@@ -81,10 +33,52 @@ export function buildFileListRows(files) {
81
33
  }
82
34
  return rows;
83
35
  }
36
+ /**
37
+ * Format a single file row as blessed-compatible tagged string.
38
+ */
39
+ /**
40
+ * Format hunk count indicator for a file, e.g. "@2/3".
41
+ * Returns empty string if not applicable.
42
+ */
43
+ function formatHunkIndicator(file, hunkCounts) {
44
+ if (!hunkCounts)
45
+ return '';
46
+ const stagedHunks = hunkCounts.staged.get(file.path) ?? 0;
47
+ const unstagedHunks = hunkCounts.unstaged.get(file.path) ?? 0;
48
+ const total = stagedHunks + unstagedHunks;
49
+ if (total === 0)
50
+ return '';
51
+ const thisCount = file.staged ? stagedHunks : unstagedHunks;
52
+ // Show just @total when all hunks are in this state, otherwise @n/total
53
+ if (thisCount === total)
54
+ return ` {cyan-fg}@${total}{/cyan-fg}`;
55
+ return ` {cyan-fg}@${thisCount}/${total}{/cyan-fg}`;
56
+ }
57
+ function formatFileRow(file, fileIndex, selectedIndex, isFocused, maxPathLength, hunkCounts) {
58
+ const isSelected = fileIndex === selectedIndex;
59
+ const statusChar = getStatusChar(file.status);
60
+ const statusColor = getStatusColor(file.status);
61
+ const actionButton = file.staged ? '[-]' : '[+]';
62
+ const buttonColor = file.staged ? 'red' : 'green';
63
+ // Calculate available space for path
64
+ const stats = formatStats(file.insertions, file.deletions);
65
+ const hunkIndicator = formatHunkIndicator(file, hunkCounts);
66
+ const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
67
+ const hunkLength = hunkIndicator.replace(/\{[^}]+\}/g, '').length;
68
+ const availableForPath = maxPathLength - statsLength - hunkLength;
69
+ let line = formatSelectionIndicator(isSelected, isFocused);
70
+ line += `{${buttonColor}-fg}${actionButton}{/${buttonColor}-fg} `;
71
+ line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
72
+ line += formatFilePath(file.path, isSelected, isFocused, availableForPath);
73
+ line += formatOriginalPath(file.originalPath);
74
+ line += stats;
75
+ line += hunkIndicator;
76
+ return line;
77
+ }
84
78
  /**
85
79
  * Format the file list as blessed-compatible tagged string.
86
80
  */
87
- export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
81
+ export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, hunkCounts) {
88
82
  if (files.length === 0) {
89
83
  return '{gray-fg} No changes{/gray-fg}';
90
84
  }
@@ -95,53 +89,26 @@ export function formatFileList(files, selectedIndex, isFocused, width, scrollOff
95
89
  ? rows.slice(scrollOffset, scrollOffset + maxHeight)
96
90
  : rows.slice(scrollOffset);
97
91
  const lines = [];
92
+ let seenFirstHeader = false;
98
93
  for (const row of visibleRows) {
99
- if (row.type === 'header') {
100
- lines.push(`{bold}{${row.headerColor}-fg}${row.content}{/${row.headerColor}-fg}{/bold}`);
101
- }
102
- else if (row.type === 'spacer') {
103
- lines.push('');
104
- }
105
- else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
106
- const file = row.file;
107
- const isSelected = row.fileIndex === selectedIndex;
108
- const isHighlighted = isSelected && isFocused;
109
- const statusChar = getStatusChar(file.status);
110
- const statusColor = getStatusColor(file.status);
111
- const actionButton = file.staged ? '[-]' : '[+]';
112
- const buttonColor = file.staged ? 'red' : 'green';
113
- // Calculate available space for path
114
- const stats = formatStats(file.insertions, file.deletions);
115
- const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
116
- const availableForPath = maxPathLength - statsLength;
117
- const displayPath = shortenPath(file.path, availableForPath);
118
- // Build the line
119
- let line = '';
120
- // Selection indicator
121
- if (isHighlighted) {
122
- line += '{cyan-fg}{bold}\u25b8 {/bold}{/cyan-fg}';
123
- }
124
- else {
125
- line += ' ';
126
- }
127
- // Action button
128
- line += `{${buttonColor}-fg}${actionButton}{/${buttonColor}-fg} `;
129
- // Status character
130
- line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
131
- // File path (with highlighting)
132
- if (isHighlighted) {
133
- line += `{cyan-fg}{inverse}${displayPath}{/inverse}{/cyan-fg}`;
134
- }
135
- else {
136
- line += displayPath;
137
- }
138
- // Original path for renames
139
- if (file.originalPath) {
140
- line += ` {gray-fg}\u2190 ${shortenPath(file.originalPath, 30)}{/gray-fg}`;
94
+ switch (row.type) {
95
+ case 'header': {
96
+ let headerLine = `{bold}{${row.headerColor}-fg}${row.content}{/${row.headerColor}-fg}{/bold}`;
97
+ if (!seenFirstHeader) {
98
+ seenFirstHeader = true;
99
+ headerLine += ' {gray-fg}(h:flat){/gray-fg}';
100
+ }
101
+ lines.push(headerLine);
102
+ break;
141
103
  }
142
- // Stats
143
- line += stats;
144
- lines.push(line);
104
+ case 'spacer':
105
+ lines.push('');
106
+ break;
107
+ case 'file':
108
+ if (row.file && row.fileIndex !== undefined) {
109
+ lines.push(formatFileRow(row.file, row.fileIndex, selectedIndex, isFocused, maxPathLength, hunkCounts ?? null));
110
+ }
111
+ break;
145
112
  }
146
113
  }
147
114
  return lines.join('\n');
@@ -0,0 +1,65 @@
1
+ import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
2
+ function getStagingButton(state) {
3
+ switch (state) {
4
+ case 'unstaged':
5
+ return { text: '[+]', color: 'green' };
6
+ case 'staged':
7
+ return { text: '[-]', color: 'red' };
8
+ case 'partial':
9
+ return { text: '[~]', color: 'yellow' };
10
+ }
11
+ }
12
+ function formatFlatHunkIndicator(entry) {
13
+ if (entry.totalHunks === 0)
14
+ return '';
15
+ // Always show staged/total in flat view
16
+ return ` {cyan-fg}@${entry.stagedHunks}/${entry.totalHunks}{/cyan-fg}`;
17
+ }
18
+ function formatFlatFileRow(entry, index, selectedIndex, isFocused, maxPathLength) {
19
+ const isSelected = index === selectedIndex;
20
+ const statusChar = getStatusChar(entry.status);
21
+ const statusColor = getStatusColor(entry.status);
22
+ const button = getStagingButton(entry.stagingState);
23
+ const stats = formatStats(entry.insertions, entry.deletions);
24
+ const hunkIndicator = formatFlatHunkIndicator(entry);
25
+ const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
26
+ const hunkLength = hunkIndicator.replace(/\{[^}]+\}/g, '').length;
27
+ const availableForPath = maxPathLength - statsLength - hunkLength;
28
+ let line = formatSelectionIndicator(isSelected, isFocused);
29
+ line += `{${button.color}-fg}${button.text}{/${button.color}-fg} `;
30
+ line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
31
+ line += formatFilePath(entry.path, isSelected, isFocused, availableForPath);
32
+ line += formatOriginalPath(entry.originalPath);
33
+ line += stats;
34
+ line += hunkIndicator;
35
+ return line;
36
+ }
37
+ /**
38
+ * Format the flat file list as blessed-compatible tagged string.
39
+ * Row 0 is a header; files start at row 1.
40
+ */
41
+ export function formatFlatFileList(flatFiles, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
42
+ if (flatFiles.length === 0) {
43
+ return '{gray-fg} No changes{/gray-fg}';
44
+ }
45
+ const maxPathLength = width - 12;
46
+ // Build all rows: header + file rows
47
+ const allRows = [];
48
+ allRows.push('{bold}{gray-fg}All files (h):{/gray-fg}{/bold}');
49
+ for (let i = 0; i < flatFiles.length; i++) {
50
+ allRows.push(formatFlatFileRow(flatFiles[i], i, selectedIndex, isFocused, maxPathLength));
51
+ }
52
+ // Apply scroll offset and max height
53
+ const visibleRows = maxHeight
54
+ ? allRows.slice(scrollOffset, scrollOffset + maxHeight)
55
+ : allRows.slice(scrollOffset);
56
+ return visibleRows.join('\n');
57
+ }
58
+ /**
59
+ * Total rows in the flat file list (header + files).
60
+ */
61
+ export function getFlatFileListTotalRows(flatFiles) {
62
+ if (flatFiles.length === 0)
63
+ return 0;
64
+ return flatFiles.length + 1; // +1 for header
65
+ }
@@ -4,26 +4,36 @@
4
4
  function calculateVisibleLength(content) {
5
5
  return content.replace(/\{[^}]+\}/g, '').length;
6
6
  }
7
+ /**
8
+ * Format a toggle indicator: blue when on, gray when off.
9
+ */
10
+ function toggleIndicator(label, enabled) {
11
+ return enabled ? `{blue-fg}[${label}]{/blue-fg}` : `{gray-fg}[${label}]{/gray-fg}`;
12
+ }
13
+ /**
14
+ * Build the left-side indicators for the standard (non-hunk) footer.
15
+ */
16
+ function buildStandardIndicators(mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, activeTab) {
17
+ const parts = [];
18
+ parts.push(mouseEnabled ? '{yellow-fg}[scroll]{/yellow-fg}' : '{yellow-fg}m:[select]{/yellow-fg}');
19
+ parts.push(toggleIndicator('auto', autoTabEnabled));
20
+ parts.push(toggleIndicator('wrap', wrapMode));
21
+ parts.push(toggleIndicator('follow', followEnabled));
22
+ if (activeTab === 'explorer') {
23
+ parts.push(toggleIndicator('changes', showOnlyChanges));
24
+ }
25
+ return parts.join(' ');
26
+ }
7
27
  /**
8
28
  * Format footer content as blessed-compatible tagged string.
9
29
  */
10
- export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, width) {
30
+ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, width, currentPane) {
11
31
  // Left side: indicators
12
32
  let leftContent = '{gray-fg}?{/gray-fg} ';
13
- leftContent += mouseEnabled
14
- ? '{yellow-fg}[scroll]{/yellow-fg}'
15
- : '{yellow-fg}m:[select]{/yellow-fg}';
16
- leftContent += ' ';
17
- leftContent += autoTabEnabled ? '{blue-fg}[auto]{/blue-fg}' : '{gray-fg}[auto]{/gray-fg}';
18
- leftContent += ' ';
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}';
22
- if (activeTab === 'explorer') {
23
- leftContent += ' ';
24
- leftContent += showOnlyChanges
25
- ? '{blue-fg}[changes]{/blue-fg}'
26
- : '{gray-fg}[changes]{/gray-fg}';
33
+ leftContent += buildStandardIndicators(mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, activeTab);
34
+ // Show hunk key hints when diff pane is focused on diff tab
35
+ if (activeTab === 'diff' && currentPane === 'diff') {
36
+ leftContent += ' {gray-fg}n/N:hunk s:toggle{/gray-fg}';
27
37
  }
28
38
  // Right side: tabs
29
39
  const tabs = [
@@ -22,10 +22,23 @@ function formatBranch(branch) {
22
22
  }
23
23
  return result;
24
24
  }
25
+ function computeBranchVisibleLength(branch) {
26
+ let len = branch.current.length;
27
+ if (branch.tracking) {
28
+ len += 3 + branch.tracking.length;
29
+ }
30
+ if (branch.ahead > 0) {
31
+ len += 3 + String(branch.ahead).length;
32
+ }
33
+ if (branch.behind > 0) {
34
+ len += 3 + String(branch.behind).length;
35
+ }
36
+ return len;
37
+ }
25
38
  /**
26
39
  * Format header content as blessed-compatible tagged string.
27
40
  */
28
- export function formatHeader(repoPath, branch, isLoading, error, width) {
41
+ export function formatHeader(repoPath, branch, isLoading, error, width, remoteState) {
29
42
  if (!repoPath) {
30
43
  return '{gray-fg}Waiting for target path...{/gray-fg}';
31
44
  }
@@ -42,6 +55,39 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
42
55
  else if (error) {
43
56
  leftContent += ` {red-fg}(${error}){/red-fg}`;
44
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
+ }
45
91
  // Build right side content (branch info)
46
92
  const rightContent = branch ? formatBranch(branch) : '';
47
93
  if (rightContent) {
@@ -55,14 +101,10 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
55
101
  else if (error) {
56
102
  leftLen += error.length + 3; // " (error)"
57
103
  }
58
- const rightLen = branch
59
- ? branch.current.length +
60
- (branch.tracking ? 3 + branch.tracking.length : 0) +
61
- (branch.ahead > 0 ? 3 + String(branch.ahead).length : 0) +
62
- (branch.behind > 0 ? 3 + String(branch.behind).length : 0)
63
- : 0;
104
+ leftLen += remoteStatusLen;
105
+ const rightLen = branch ? computeBranchVisibleLength(branch) : 0;
64
106
  const padding = Math.max(1, width - leftLen - rightLen - 2);
65
- return leftContent + ' '.repeat(padding) + rightContent;
107
+ return leftContent + remoteStatus + ' '.repeat(padding) + rightContent;
66
108
  }
67
- return leftContent;
109
+ return leftContent + remoteStatus;
68
110
  }
@@ -0,0 +1,73 @@
1
+ import { shortenPath } from '../../utils/formatPath.js';
2
+ export function getStatusChar(status) {
3
+ switch (status) {
4
+ case 'modified':
5
+ return 'M';
6
+ case 'added':
7
+ return 'A';
8
+ case 'deleted':
9
+ return 'D';
10
+ case 'untracked':
11
+ return '?';
12
+ case 'renamed':
13
+ return 'R';
14
+ case 'copied':
15
+ return 'C';
16
+ default:
17
+ return ' ';
18
+ }
19
+ }
20
+ export function getStatusColor(status) {
21
+ switch (status) {
22
+ case 'modified':
23
+ return 'yellow';
24
+ case 'added':
25
+ return 'green';
26
+ case 'deleted':
27
+ return 'red';
28
+ case 'untracked':
29
+ return 'gray';
30
+ case 'renamed':
31
+ return 'blue';
32
+ case 'copied':
33
+ return 'cyan';
34
+ default:
35
+ return 'white';
36
+ }
37
+ }
38
+ export function formatStats(insertions, deletions) {
39
+ if (insertions === undefined && deletions === undefined)
40
+ return '';
41
+ const parts = [];
42
+ if (insertions !== undefined && insertions > 0) {
43
+ parts.push(`{green-fg}+${insertions}{/green-fg}`);
44
+ }
45
+ if (deletions !== undefined && deletions > 0) {
46
+ parts.push(`{red-fg}-${deletions}{/red-fg}`);
47
+ }
48
+ return parts.length > 0 ? ' ' + parts.join(' ') : '';
49
+ }
50
+ export function formatSelectionIndicator(isSelected, isFocused) {
51
+ if (isSelected && isFocused) {
52
+ return '{cyan-fg}{bold}\u25b8 {/bold}{/cyan-fg}';
53
+ }
54
+ else if (isSelected) {
55
+ return '{gray-fg}\u25b8 {/gray-fg}';
56
+ }
57
+ return ' ';
58
+ }
59
+ export function formatFilePath(path, isSelected, isFocused, maxLength) {
60
+ const displayPath = shortenPath(path, maxLength);
61
+ if (isSelected && isFocused) {
62
+ return `{cyan-fg}{inverse}${displayPath}{/inverse}{/cyan-fg}`;
63
+ }
64
+ else if (isSelected) {
65
+ return `{cyan-fg}${displayPath}{/cyan-fg}`;
66
+ }
67
+ return displayPath;
68
+ }
69
+ export function formatOriginalPath(originalPath) {
70
+ if (!originalPath)
71
+ return '';
72
+ return ` {gray-fg}\u2190 ${shortenPath(originalPath, 30)}{/gray-fg}`;
73
+ }
@@ -85,7 +85,6 @@ export function truncateAnsi(str, maxVisualLength, suffix = '…') {
85
85
  else {
86
86
  // Partial fit - truncate this segment
87
87
  result += segment.content.slice(0, remainingSpace);
88
- currentVisualLength += remainingSpace;
89
88
  truncated = true;
90
89
  break;
91
90
  }