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
@@ -23,6 +23,10 @@ const hotkeyGroups = [
23
23
  { key: 'c', description: 'Commit panel' },
24
24
  { key: 'r', description: 'Refresh' },
25
25
  { key: 'q', description: 'Quit' },
26
+ { key: 'P', description: 'Push to remote' },
27
+ { key: 'F', description: 'Fetch from remote' },
28
+ { key: 'R', description: 'Pull --rebase' },
29
+ { key: 'S', description: 'Stash save (global)' },
26
30
  ],
27
31
  },
28
32
  {
@@ -45,6 +49,7 @@ const hotkeyGroups = [
45
49
  {
46
50
  title: 'Toggles',
47
51
  entries: [
52
+ { key: 'h', description: 'Flat file view' },
48
53
  { key: 'm', description: 'Mouse mode' },
49
54
  { key: 'w', description: 'Wrap mode' },
50
55
  { key: 'f', description: 'Follow mode' },
@@ -57,6 +62,28 @@ const hotkeyGroups = [
57
62
  entries: [
58
63
  { key: 'Enter', description: 'Enter directory' },
59
64
  { key: 'Backspace', description: 'Go up' },
65
+ { key: '/', description: 'Find file' },
66
+ { key: 'Ctrl+P', description: 'Find file (any tab)' },
67
+ { key: 'g', description: 'Show changes only' },
68
+ ],
69
+ },
70
+ {
71
+ title: 'Commit Panel',
72
+ entries: [
73
+ { key: 'i/Enter', description: 'Edit message' },
74
+ { key: 'a', description: 'Toggle amend' },
75
+ { key: 'Ctrl+a', description: 'Toggle amend (typing)' },
76
+ { key: 'o', description: 'Pop stash' },
77
+ { key: 'l', description: 'Stash list modal' },
78
+ { key: 'b', description: 'Branch picker' },
79
+ { key: 'X', description: 'Soft reset HEAD~1' },
80
+ ],
81
+ },
82
+ {
83
+ title: 'History',
84
+ entries: [
85
+ { key: 'p', description: 'Cherry-pick commit' },
86
+ { key: 'v', description: 'Revert commit' },
60
87
  ],
61
88
  },
62
89
  {
@@ -67,8 +94,13 @@ const hotkeyGroups = [
67
94
  ],
68
95
  },
69
96
  {
70
- title: 'Diff',
71
- entries: [{ key: 'd', description: 'Discard changes' }],
97
+ title: 'Diff (pane focus)',
98
+ entries: [
99
+ { key: 'n', description: 'Next hunk' },
100
+ { key: 'N', description: 'Previous hunk' },
101
+ { key: 's', description: 'Toggle hunk staged/unstaged' },
102
+ { key: 'd', description: 'Discard changes' },
103
+ ],
72
104
  },
73
105
  ];
74
106
  /**
@@ -186,7 +218,7 @@ export class HotkeysModal {
186
218
  this.box.setContent(lines.join('\n'));
187
219
  this.screen.render();
188
220
  }
189
- renderGroups(groups, colWidth) {
221
+ renderGroups(groups, _colWidth) {
190
222
  const lines = [];
191
223
  for (const group of groups) {
192
224
  lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
@@ -0,0 +1,68 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * SoftResetConfirm modal for confirming soft reset HEAD~1.
4
+ */
5
+ export class SoftResetConfirm {
6
+ box;
7
+ screen;
8
+ onConfirm;
9
+ onCancel;
10
+ constructor(screen, headCommit, onConfirm, onCancel) {
11
+ this.screen = screen;
12
+ this.onConfirm = onConfirm;
13
+ this.onCancel = onCancel;
14
+ const width = Math.min(60, screen.width - 6);
15
+ const height = 9;
16
+ this.box = blessed.box({
17
+ parent: screen,
18
+ top: 'center',
19
+ left: 'center',
20
+ width,
21
+ height,
22
+ border: {
23
+ type: 'line',
24
+ },
25
+ style: {
26
+ border: {
27
+ fg: 'yellow',
28
+ },
29
+ },
30
+ tags: true,
31
+ keys: true,
32
+ });
33
+ this.setupKeyHandlers();
34
+ this.renderContent(headCommit, width);
35
+ }
36
+ setupKeyHandlers() {
37
+ this.box.key(['y', 'Y'], () => {
38
+ this.close();
39
+ this.onConfirm();
40
+ });
41
+ this.box.key(['n', 'N', 'escape', 'q'], () => {
42
+ this.close();
43
+ this.onCancel();
44
+ });
45
+ }
46
+ renderContent(commit, width) {
47
+ const lines = [];
48
+ const innerWidth = width - 6;
49
+ lines.push('{bold}{yellow-fg} Soft Reset HEAD~1?{/yellow-fg}{/bold}');
50
+ lines.push('');
51
+ const msg = commit.message.length > innerWidth
52
+ ? commit.message.slice(0, innerWidth - 3) + '\u2026'
53
+ : commit.message;
54
+ lines.push(`{yellow-fg}${commit.shortHash}{/yellow-fg} ${msg}`);
55
+ lines.push('');
56
+ lines.push('{gray-fg}Changes will return to staged state{/gray-fg}');
57
+ lines.push('');
58
+ lines.push('{gray-fg}Press {/gray-fg}{green-fg}y{/green-fg}{gray-fg} to confirm, {/gray-fg}{red-fg}n{/red-fg}{gray-fg} or Esc to cancel{/gray-fg}');
59
+ this.box.setContent(lines.join('\n'));
60
+ this.screen.render();
61
+ }
62
+ close() {
63
+ this.box.destroy();
64
+ }
65
+ focus() {
66
+ this.box.focus();
67
+ }
68
+ }
@@ -0,0 +1,98 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * StashListModal shows stash entries and allows popping one.
4
+ */
5
+ export class StashListModal {
6
+ box;
7
+ screen;
8
+ entries;
9
+ selectedIndex = 0;
10
+ onPop;
11
+ onCancel;
12
+ constructor(screen, entries, onPop, onCancel) {
13
+ this.screen = screen;
14
+ this.entries = entries;
15
+ this.onPop = onPop;
16
+ this.onCancel = onCancel;
17
+ // Create modal box
18
+ const width = Math.min(70, screen.width - 6);
19
+ const maxVisible = Math.min(entries.length, 15);
20
+ const height = maxVisible + 6;
21
+ this.box = blessed.box({
22
+ parent: screen,
23
+ top: 'center',
24
+ left: 'center',
25
+ width,
26
+ height,
27
+ border: {
28
+ type: 'line',
29
+ },
30
+ style: {
31
+ border: {
32
+ fg: 'cyan',
33
+ },
34
+ },
35
+ tags: true,
36
+ keys: true,
37
+ scrollable: true,
38
+ alwaysScroll: true,
39
+ });
40
+ this.setupKeyHandlers();
41
+ this.render();
42
+ }
43
+ setupKeyHandlers() {
44
+ this.box.key(['escape', 'q'], () => {
45
+ this.close();
46
+ this.onCancel();
47
+ });
48
+ this.box.key(['enter'], () => {
49
+ if (this.entries.length > 0) {
50
+ const index = this.entries[this.selectedIndex].index;
51
+ this.close();
52
+ this.onPop(index);
53
+ }
54
+ });
55
+ this.box.key(['up', 'k'], () => {
56
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
57
+ this.render();
58
+ });
59
+ this.box.key(['down', 'j'], () => {
60
+ this.selectedIndex = Math.min(this.entries.length - 1, this.selectedIndex + 1);
61
+ this.render();
62
+ });
63
+ }
64
+ render() {
65
+ const lines = [];
66
+ const width = this.box.width - 4;
67
+ lines.push('{bold}{cyan-fg} Stash List{/cyan-fg}{/bold}');
68
+ lines.push('');
69
+ if (this.entries.length === 0) {
70
+ lines.push('{gray-fg}No stash entries{/gray-fg}');
71
+ }
72
+ else {
73
+ for (let i = 0; i < this.entries.length; i++) {
74
+ const entry = this.entries[i];
75
+ const isSelected = i === this.selectedIndex;
76
+ const msg = entry.message.length > width - 10
77
+ ? entry.message.slice(0, width - 13) + '\u2026'
78
+ : entry.message;
79
+ if (isSelected) {
80
+ lines.push(`{cyan-fg}{bold}> {${i}} ${msg}{/bold}{/cyan-fg}`);
81
+ }
82
+ else {
83
+ lines.push(` {gray-fg}{${i}}{/gray-fg} ${msg}`);
84
+ }
85
+ }
86
+ }
87
+ lines.push('');
88
+ lines.push('{gray-fg}j/k: navigate | Enter: pop | Esc: cancel{/gray-fg}');
89
+ this.box.setContent(lines.join('\n'));
90
+ this.screen.render();
91
+ }
92
+ close() {
93
+ this.box.destroy();
94
+ }
95
+ focus() {
96
+ this.box.focus();
97
+ }
98
+ }
@@ -1,5 +1,5 @@
1
1
  import blessed from 'neo-blessed';
2
- import { themes, themeOrder, getTheme } from '../../themes.js';
2
+ import { themes, themeOrder } from '../../themes.js';
3
3
  /**
4
4
  * ThemePicker modal for selecting diff themes.
5
5
  */
@@ -85,7 +85,6 @@ export class ThemePicker {
85
85
  // Preview section
86
86
  lines.push('');
87
87
  lines.push('{gray-fg}Preview:{/gray-fg}');
88
- const previewTheme = getTheme(themeOrder[this.selectedIndex]);
89
88
  // Simple preview - just show add/del colors
90
89
  lines.push(` {green-fg}+ added line{/green-fg}`);
91
90
  lines.push(` {red-fg}- deleted line{/red-fg}`);
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Format the commit panel as blessed-compatible tagged string.
2
+ * Build all lines for the commit panel (used for both rendering and totalRows).
3
3
  */
4
- export function formatCommitPanel(state, stagedCount, width) {
4
+ export function buildCommitPanelLines(opts) {
5
+ const { state, stagedCount, width, branch, remoteState, stashList, headCommit } = opts;
5
6
  const lines = [];
6
7
  // Title
7
8
  let title = '{bold}Commit Message{/bold}';
@@ -11,7 +12,6 @@ export function formatCommitPanel(state, stagedCount, width) {
11
12
  lines.push(title);
12
13
  lines.push('');
13
14
  // Message input area
14
- const borderChar = state.inputFocused ? '\u2502' : '\u2502';
15
15
  const borderColor = state.inputFocused ? 'cyan' : 'gray';
16
16
  // Top border
17
17
  const innerWidth = Math.max(20, width - 6);
@@ -24,7 +24,7 @@ export function formatCommitPanel(state, stagedCount, width) {
24
24
  const truncatedMessage = displayMessage.length > innerWidth
25
25
  ? displayMessage.slice(0, innerWidth - 1) + '\u2026'
26
26
  : displayMessage.padEnd(innerWidth);
27
- lines.push(`{${borderColor}-fg}${borderChar}{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}${borderChar}{/${borderColor}-fg}`);
27
+ lines.push(`{${borderColor}-fg}\u2502{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}\u2502{/${borderColor}-fg}`);
28
28
  // Bottom border
29
29
  lines.push(`{${borderColor}-fg}\u2514${'─'.repeat(innerWidth + 2)}\u2518{/${borderColor}-fg}`);
30
30
  lines.push('');
@@ -45,10 +45,116 @@ export function formatCommitPanel(state, stagedCount, width) {
45
45
  lines.push('');
46
46
  // Help text
47
47
  const helpText = state.inputFocused
48
- ? 'Enter: commit | Esc: unfocus'
49
- : 'i/Enter: edit | Esc: cancel | a: toggle amend';
48
+ ? 'Enter: commit | Ctrl+a: amend | Esc: unfocus'
49
+ : 'i/Enter: edit | a: amend | Esc: back';
50
50
  lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
51
- return lines.join('\n');
51
+ // Stash section
52
+ const stashEntries = stashList ?? [];
53
+ lines.push('');
54
+ lines.push(`{gray-fg}${'─'.repeat(3)} Stash (${stashEntries.length}) ${'─'.repeat(3)}{/gray-fg}`);
55
+ if (stashEntries.length > 0) {
56
+ const maxShow = 5;
57
+ for (let i = 0; i < Math.min(stashEntries.length, maxShow); i++) {
58
+ const entry = stashEntries[i];
59
+ const msg = entry.message.length > width - 10
60
+ ? entry.message.slice(0, width - 13) + '\u2026'
61
+ : entry.message;
62
+ lines.push(`{gray-fg}{${i}}{/gray-fg}: ${msg}`);
63
+ }
64
+ if (stashEntries.length > maxShow) {
65
+ lines.push(`{gray-fg}... ${stashEntries.length - maxShow} more{/gray-fg}`);
66
+ }
67
+ }
68
+ else {
69
+ lines.push('{gray-fg}(empty){/gray-fg}');
70
+ }
71
+ lines.push('{gray-fg}S: save | o: pop | l: list{/gray-fg}');
72
+ // Branch section
73
+ if (branch) {
74
+ lines.push('');
75
+ lines.push(`{gray-fg}${'─'.repeat(3)} Branch ${'─'.repeat(3)}{/gray-fg}`);
76
+ let branchLine = `{bold}* ${branch.current}{/bold}`;
77
+ if (branch.tracking) {
78
+ branchLine += ` {gray-fg}\u2192{/gray-fg} ${branch.tracking}`;
79
+ }
80
+ lines.push(branchLine);
81
+ lines.push('{gray-fg}b: switch/create{/gray-fg}');
82
+ }
83
+ // Undo section
84
+ lines.push('');
85
+ lines.push(`{gray-fg}${'─'.repeat(3)} Undo ${'─'.repeat(3)}{/gray-fg}`);
86
+ if (headCommit) {
87
+ lines.push(`{gray-fg}HEAD: {yellow-fg}${headCommit.shortHash}{/yellow-fg} ${headCommit.message}{/gray-fg}`);
88
+ }
89
+ lines.push('{gray-fg}X: soft reset HEAD~1{/gray-fg}');
90
+ // Remote section
91
+ if (branch) {
92
+ lines.push('');
93
+ lines.push(`{gray-fg}${'─'.repeat(3)} Remote ${'─'.repeat(3)}{/gray-fg}`);
94
+ // Tracking info
95
+ if (branch.tracking) {
96
+ let tracking = `${branch.current} {gray-fg}\u2192{/gray-fg} ${branch.tracking}`;
97
+ if (branch.ahead > 0)
98
+ tracking += ` {green-fg}\u2191${branch.ahead}{/green-fg}`;
99
+ if (branch.behind > 0)
100
+ tracking += ` {red-fg}\u2193${branch.behind}{/red-fg}`;
101
+ lines.push(tracking);
102
+ }
103
+ else {
104
+ lines.push(`{gray-fg}${branch.current} (no remote tracking){/gray-fg}`);
105
+ }
106
+ // Remote status
107
+ if (remoteState?.inProgress && remoteState.operation) {
108
+ const labels = {
109
+ push: 'Pushing...',
110
+ fetch: 'Fetching...',
111
+ pull: 'Rebasing...',
112
+ stash: 'Stashing...',
113
+ stashPop: 'Popping stash...',
114
+ branchSwitch: 'Switching branch...',
115
+ branchCreate: 'Creating branch...',
116
+ softReset: 'Resetting...',
117
+ cherryPick: 'Cherry-picking...',
118
+ revert: 'Reverting...',
119
+ };
120
+ lines.push(`{yellow-fg}${labels[remoteState.operation] ?? ''}{/yellow-fg}`);
121
+ }
122
+ else if (remoteState?.error) {
123
+ const brief = remoteState.error.length > 50
124
+ ? remoteState.error.slice(0, 50) + '\u2026'
125
+ : remoteState.error;
126
+ lines.push(`{red-fg}${brief}{/red-fg}`);
127
+ }
128
+ else if (remoteState?.lastResult) {
129
+ lines.push(`{green-fg}${remoteState.lastResult}{/green-fg}`);
130
+ }
131
+ lines.push('{gray-fg}P: push | F: fetch | R: pull --rebase{/gray-fg}');
132
+ }
133
+ return lines;
134
+ }
135
+ /**
136
+ * Get total row count for the commit panel (for scroll calculations).
137
+ */
138
+ export function getCommitPanelTotalRows(opts) {
139
+ return buildCommitPanelLines(opts).length;
140
+ }
141
+ /**
142
+ * Format the commit panel as blessed-compatible tagged string.
143
+ */
144
+ export function formatCommitPanel(state, stagedCount, width, branch, remoteState, stashList, headCommit, scrollOffset = 0, visibleHeight) {
145
+ const allLines = buildCommitPanelLines({
146
+ state,
147
+ stagedCount,
148
+ width,
149
+ branch,
150
+ remoteState,
151
+ stashList,
152
+ headCommit,
153
+ });
154
+ if (visibleHeight && allLines.length > visibleHeight) {
155
+ return allLines.slice(scrollOffset, scrollOffset + visibleHeight).join('\n');
156
+ }
157
+ return allLines.join('\n');
52
158
  }
53
159
  /**
54
160
  * Format inactive commit panel.
@@ -135,6 +135,47 @@ function formatFileRow(file, treeRow, isSelected, isFocused, width) {
135
135
  }
136
136
  return `{escape}${line}{/escape}`;
137
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;
178
+ }
138
179
  /**
139
180
  * Format the compare list view as blessed-compatible tagged string.
140
181
  */
@@ -147,29 +188,9 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
147
188
  const visibleRows = maxHeight
148
189
  ? rows.slice(scrollOffset, scrollOffset + maxHeight)
149
190
  : rows.slice(scrollOffset);
150
- const lines = [];
151
- for (const row of visibleRows) {
152
- if (row.type === 'section-header') {
153
- const isCommits = row.sectionType === 'commits';
154
- const count = isCommits ? commits.length : files.length;
155
- const label = isCommits ? 'Commits' : 'Files';
156
- lines.push(`{escape}${ANSI_CYAN}${ANSI_BOLD}▼ ${label}${ANSI_RESET} ${ANSI_GRAY}(${count})${ANSI_RESET}{/escape}`);
157
- }
158
- else if (row.type === 'spacer') {
159
- lines.push('');
160
- }
161
- else if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
162
- const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
163
- lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
164
- }
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) {
169
- const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
170
- lines.push(formatFileRow(row.file, row.treeRow, isSelected, isFocused, width));
171
- }
172
- }
191
+ const lines = visibleRows
192
+ .map((row) => formatCompareRow(row, selectedItem, isFocused, commits, files, width))
193
+ .filter((line) => line !== null);
173
194
  return lines.join('\n');
174
195
  }
175
196
  /**