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
@@ -17,15 +17,14 @@ export class CommandClient {
17
17
  return new Promise((resolve, reject) => {
18
18
  const socket = net.createConnection(this.socketPath);
19
19
  let buffer = '';
20
- let timeoutId;
21
- const cleanup = () => {
22
- clearTimeout(timeoutId);
23
- socket.destroy();
24
- };
25
- timeoutId = setTimeout(() => {
20
+ const timeoutId = setTimeout(() => {
26
21
  cleanup();
27
22
  reject(new Error(`Command timed out after ${this.timeout}ms`));
28
23
  }, this.timeout);
24
+ function cleanup() {
25
+ clearTimeout(timeoutId);
26
+ socket.destroy();
27
+ }
29
28
  socket.on('connect', () => {
30
29
  socket.write(JSON.stringify(command) + '\n');
31
30
  });
@@ -38,7 +37,7 @@ export class CommandClient {
38
37
  try {
39
38
  resolve(JSON.parse(json));
40
39
  }
41
- catch (err) {
40
+ catch {
42
41
  reject(new Error(`Invalid JSON response: ${json}`));
43
42
  }
44
43
  }
@@ -13,12 +13,13 @@ const DEFAULT_STATE = {
13
13
  compareSelectedIndex: 0,
14
14
  includeUncommitted: false,
15
15
  explorerSelectedIndex: 0,
16
+ selectedHunkIndex: 0,
16
17
  wrapMode: false,
17
18
  autoTabEnabled: false,
18
19
  mouseEnabled: true,
19
- showMiddleDots: false,
20
20
  hideHiddenFiles: true,
21
21
  hideGitignored: true,
22
+ flatViewMode: false,
22
23
  splitRatio: 0.4,
23
24
  activeModal: null,
24
25
  pendingDiscard: null,
@@ -111,6 +112,22 @@ export class UIState extends EventEmitter {
111
112
  setExplorerSelectedIndex(index) {
112
113
  this.update({ explorerSelectedIndex: Math.max(0, index) });
113
114
  }
115
+ // Hunk selection
116
+ setSelectedHunkIndex(index) {
117
+ this.update({ selectedHunkIndex: Math.max(0, index) });
118
+ }
119
+ /**
120
+ * Silently clamp selectedHunkIndex to valid range without emitting events.
121
+ * Called during render to sync state with actual hunk count.
122
+ */
123
+ clampSelectedHunkIndex(hunkCount) {
124
+ if (hunkCount <= 0) {
125
+ this._state.selectedHunkIndex = 0;
126
+ }
127
+ else if (this._state.selectedHunkIndex >= hunkCount) {
128
+ this._state.selectedHunkIndex = hunkCount - 1;
129
+ }
130
+ }
114
131
  // Display toggles
115
132
  toggleWrapMode() {
116
133
  this.update({ wrapMode: !this._state.wrapMode, diffScrollOffset: 0 });
@@ -121,15 +138,15 @@ export class UIState extends EventEmitter {
121
138
  toggleMouse() {
122
139
  this.update({ mouseEnabled: !this._state.mouseEnabled });
123
140
  }
124
- toggleMiddleDots() {
125
- this.update({ showMiddleDots: !this._state.showMiddleDots });
126
- }
127
141
  toggleHideHiddenFiles() {
128
142
  this.update({ hideHiddenFiles: !this._state.hideHiddenFiles });
129
143
  }
130
144
  toggleHideGitignored() {
131
145
  this.update({ hideGitignored: !this._state.hideGitignored });
132
146
  }
147
+ toggleFlatViewMode() {
148
+ this.update({ flatViewMode: !this._state.flatViewMode, fileListScrollOffset: 0 });
149
+ }
133
150
  // Split ratio
134
151
  adjustSplitRatio(delta) {
135
152
  const newRatio = Math.min(0.85, Math.max(0.15, this._state.splitRatio + delta));
@@ -179,4 +196,22 @@ export class UIState extends EventEmitter {
179
196
  this.setPane(currentPane === 'explorer' ? 'diff' : 'explorer');
180
197
  }
181
198
  }
199
+ // Reset repo-specific state when switching repositories
200
+ resetForNewRepo() {
201
+ this._state = {
202
+ ...this._state,
203
+ selectedIndex: 0,
204
+ fileListScrollOffset: 0,
205
+ diffScrollOffset: 0,
206
+ historySelectedIndex: 0,
207
+ historyScrollOffset: 0,
208
+ compareSelectedIndex: 0,
209
+ compareScrollOffset: 0,
210
+ explorerSelectedIndex: 0,
211
+ explorerScrollOffset: 0,
212
+ explorerFileScrollOffset: 0,
213
+ selectedHunkIndex: 0,
214
+ };
215
+ this.emit('change', this._state);
216
+ }
182
217
  }
@@ -0,0 +1,76 @@
1
+ import { formatFileList } from './widgets/FileList.js';
2
+ import { formatFlatFileList } from './widgets/FlatFileList.js';
3
+ import { formatHistoryView } from './widgets/HistoryView.js';
4
+ import { formatCompareListView } from './widgets/CompareListView.js';
5
+ import { formatExplorerView } from './widgets/ExplorerView.js';
6
+ import { formatDiff, formatCombinedDiff, formatHistoryDiff } from './widgets/DiffView.js';
7
+ import { formatCommitPanel } from './widgets/CommitPanel.js';
8
+ import { formatExplorerContent } from './widgets/ExplorerContent.js';
9
+ /**
10
+ * Render the top pane content for the current tab.
11
+ */
12
+ export function renderTopPane(state, files, historyCommits, compareDiff, compareSelection, explorerState, width, topPaneHeight, hunkCounts, flatFiles) {
13
+ if (state.bottomTab === 'history') {
14
+ return formatHistoryView(historyCommits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, topPaneHeight);
15
+ }
16
+ if (state.bottomTab === 'compare') {
17
+ const commits = compareDiff?.commits ?? [];
18
+ const compareFiles = compareDiff?.files ?? [];
19
+ return formatCompareListView(commits, compareFiles, compareSelection, state.currentPane === 'compare', width, state.compareScrollOffset, topPaneHeight);
20
+ }
21
+ if (state.bottomTab === 'explorer') {
22
+ const displayRows = explorerState?.displayRows ?? [];
23
+ return formatExplorerView(displayRows, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
24
+ }
25
+ // Default: diff/commit tab file list
26
+ if (state.flatViewMode && flatFiles) {
27
+ return formatFlatFileList(flatFiles, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, topPaneHeight);
28
+ }
29
+ return formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, topPaneHeight, hunkCounts);
30
+ }
31
+ /**
32
+ * Render the bottom pane content for the current tab.
33
+ */
34
+ export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight, selectedHunkIndex, isFileStaged, combinedFileDiffs) {
35
+ if (state.bottomTab === 'commit') {
36
+ const content = formatCommitPanel(commitFlowState, stagedCount, width);
37
+ return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
38
+ }
39
+ if (state.bottomTab === 'history') {
40
+ const selectedCommit = historyState?.selectedCommit ?? null;
41
+ const commitDiff = historyState?.commitDiff ?? null;
42
+ const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
43
+ return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
44
+ }
45
+ if (state.bottomTab === 'compare') {
46
+ const compareDiff = compareSelectionState?.diff ?? null;
47
+ if (compareDiff) {
48
+ const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
49
+ return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
50
+ }
51
+ return {
52
+ content: '{gray-fg}Select a commit or file to view diff{/gray-fg}',
53
+ totalRows: 0,
54
+ hunkCount: 0,
55
+ hunkBoundaries: [],
56
+ };
57
+ }
58
+ if (state.bottomTab === 'explorer') {
59
+ const content = formatExplorerContent(explorerSelectedFile?.path ?? null, explorerSelectedFile?.content ?? null, width, state.explorerFileScrollOffset, bottomPaneHeight, explorerSelectedFile?.truncated ?? false, state.wrapMode);
60
+ return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
61
+ }
62
+ // Flat mode: show combined unstaged+staged diff with section headers
63
+ if (state.flatViewMode && combinedFileDiffs) {
64
+ const result = formatCombinedDiff(combinedFileDiffs.unstaged, combinedFileDiffs.staged, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode, selectedHunkIndex);
65
+ return {
66
+ content: result.content,
67
+ totalRows: result.totalRows,
68
+ hunkCount: result.hunkCount,
69
+ hunkBoundaries: result.hunkBoundaries,
70
+ hunkMapping: result.hunkMapping,
71
+ };
72
+ }
73
+ // Default: diff tab — pass selectedHunkIndex for hunk gutter
74
+ const { content, totalRows, hunkCount, hunkBoundaries } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode, selectedHunkIndex, isFileStaged);
75
+ return { content, totalRows, hunkCount, hunkBoundaries };
76
+ }
@@ -0,0 +1,193 @@
1
+ import blessed from 'neo-blessed';
2
+ import { Fzf } from 'fzf';
3
+ const MAX_RESULTS = 15;
4
+ /**
5
+ * Highlight matched characters in a display path using fzf position data.
6
+ * The positions set refers to indices in the original full path,
7
+ * so we need an offset when the display path is truncated.
8
+ */
9
+ function highlightMatch(displayPath, positions, offset) {
10
+ let result = '';
11
+ for (let i = 0; i < displayPath.length; i++) {
12
+ if (positions.has(i + offset)) {
13
+ result += `{yellow-fg}${displayPath[i]}{/yellow-fg}`;
14
+ }
15
+ else {
16
+ result += displayPath[i];
17
+ }
18
+ }
19
+ return result;
20
+ }
21
+ /**
22
+ * FileFinder modal for fuzzy file search.
23
+ */
24
+ export class FileFinder {
25
+ box;
26
+ textbox;
27
+ screen;
28
+ fzf;
29
+ allPaths;
30
+ results = [];
31
+ selectedIndex = 0;
32
+ query = '';
33
+ onSelect;
34
+ onCancel;
35
+ constructor(screen, allPaths, onSelect, onCancel) {
36
+ this.screen = screen;
37
+ this.allPaths = allPaths;
38
+ this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, forward: false });
39
+ this.onSelect = onSelect;
40
+ this.onCancel = onCancel;
41
+ // Create modal box
42
+ const width = Math.min(80, screen.width - 10);
43
+ const height = MAX_RESULTS + 6; // results + input + header + borders + padding
44
+ this.box = blessed.box({
45
+ parent: screen,
46
+ top: 'center',
47
+ left: 'center',
48
+ width,
49
+ height,
50
+ border: {
51
+ type: 'line',
52
+ },
53
+ style: {
54
+ border: {
55
+ fg: 'cyan',
56
+ },
57
+ },
58
+ tags: true,
59
+ keys: false, // We'll handle keys ourselves
60
+ });
61
+ // Create text input
62
+ this.textbox = blessed.textarea({
63
+ parent: this.box,
64
+ top: 1,
65
+ left: 1,
66
+ width: width - 4,
67
+ height: 1,
68
+ inputOnFocus: true,
69
+ style: {
70
+ fg: 'white',
71
+ bg: 'default',
72
+ },
73
+ });
74
+ // Setup key handlers
75
+ this.setupKeyHandlers();
76
+ // Initial render with all files
77
+ this.updateResults();
78
+ this.render();
79
+ }
80
+ setupKeyHandlers() {
81
+ // Handle escape to cancel
82
+ this.textbox.key(['escape'], () => {
83
+ this.close();
84
+ this.onCancel();
85
+ });
86
+ // Handle enter to select
87
+ this.textbox.key(['enter'], () => {
88
+ if (this.results.length > 0) {
89
+ const selected = this.results[this.selectedIndex];
90
+ this.close();
91
+ this.onSelect(selected.item);
92
+ }
93
+ });
94
+ // Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
95
+ this.textbox.key(['C-j', 'down'], () => {
96
+ this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
97
+ this.render();
98
+ });
99
+ this.textbox.key(['C-k', 'up'], () => {
100
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
101
+ this.render();
102
+ });
103
+ // Handle tab for next result
104
+ this.textbox.key(['tab'], () => {
105
+ this.selectedIndex = (this.selectedIndex + 1) % Math.max(1, this.results.length);
106
+ this.render();
107
+ });
108
+ // Handle shift-tab for previous result
109
+ this.textbox.key(['S-tab'], () => {
110
+ this.selectedIndex =
111
+ (this.selectedIndex - 1 + this.results.length) % Math.max(1, this.results.length);
112
+ this.render();
113
+ });
114
+ // Update results on keypress
115
+ this.textbox.on('keypress', () => {
116
+ // Defer to next tick to get updated value
117
+ setImmediate(() => {
118
+ const newQuery = this.textbox.getValue() || '';
119
+ if (newQuery !== this.query) {
120
+ this.query = newQuery;
121
+ this.selectedIndex = 0;
122
+ this.updateResults();
123
+ this.render();
124
+ }
125
+ });
126
+ });
127
+ }
128
+ updateResults() {
129
+ if (!this.query) {
130
+ // fzf returns nothing for empty query, show first N files
131
+ this.results = this.allPaths
132
+ .slice(0, MAX_RESULTS)
133
+ .map((item) => ({ item, positions: new Set(), start: 0, end: 0, score: 0 }));
134
+ return;
135
+ }
136
+ this.results = this.fzf.find(this.query);
137
+ }
138
+ render() {
139
+ const lines = [];
140
+ const width = this.box.width - 4;
141
+ // Header
142
+ lines.push('{bold}{cyan-fg}Find File{/cyan-fg}{/bold}');
143
+ lines.push(''); // Space for input
144
+ lines.push('');
145
+ // Results
146
+ if (this.results.length === 0 && this.query) {
147
+ lines.push('{gray-fg}No matches{/gray-fg}');
148
+ }
149
+ else {
150
+ for (let i = 0; i < this.results.length; i++) {
151
+ const result = this.results[i];
152
+ const isSelected = i === this.selectedIndex;
153
+ // Truncate path if needed
154
+ const fullPath = result.item;
155
+ const maxLen = width - 4;
156
+ let displayPath = fullPath;
157
+ let offset = 0;
158
+ if (displayPath.length > maxLen) {
159
+ offset = displayPath.length - (maxLen - 1);
160
+ displayPath = '…' + displayPath.slice(offset);
161
+ // Account for the '…' prefix: display index 0 is '…', actual content starts at 1
162
+ offset = offset - 1;
163
+ }
164
+ // Highlight matched characters using fzf positions
165
+ const highlighted = highlightMatch(displayPath, result.positions, offset);
166
+ if (isSelected) {
167
+ lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
168
+ }
169
+ else {
170
+ lines.push(` ${highlighted}`);
171
+ }
172
+ }
173
+ }
174
+ // Pad to fill space
175
+ while (lines.length < MAX_RESULTS + 3) {
176
+ lines.push('');
177
+ }
178
+ // Footer
179
+ lines.push('{gray-fg}Enter: select | Esc: cancel | Ctrl+j/k or ↑↓: navigate{/gray-fg}');
180
+ this.box.setContent(lines.join('\n'));
181
+ this.screen.render();
182
+ }
183
+ close() {
184
+ this.textbox.destroy();
185
+ this.box.destroy();
186
+ }
187
+ /**
188
+ * Focus the modal input.
189
+ */
190
+ focus() {
191
+ this.textbox.focus();
192
+ }
193
+ }
@@ -45,6 +45,7 @@ const hotkeyGroups = [
45
45
  {
46
46
  title: 'Toggles',
47
47
  entries: [
48
+ { key: 'h', description: 'Flat file view' },
48
49
  { key: 'm', description: 'Mouse mode' },
49
50
  { key: 'w', description: 'Wrap mode' },
50
51
  { key: 'f', description: 'Follow mode' },
@@ -57,6 +58,9 @@ const hotkeyGroups = [
57
58
  entries: [
58
59
  { key: 'Enter', description: 'Enter directory' },
59
60
  { key: 'Backspace', description: 'Go up' },
61
+ { key: '/', description: 'Find file' },
62
+ { key: 'Ctrl+P', description: 'Find file (any tab)' },
63
+ { key: 'g', description: 'Show changes only' },
60
64
  ],
61
65
  },
62
66
  {
@@ -67,8 +71,13 @@ const hotkeyGroups = [
67
71
  ],
68
72
  },
69
73
  {
70
- title: 'Diff',
71
- entries: [{ key: 'd', description: 'Discard changes' }],
74
+ title: 'Diff (pane focus)',
75
+ entries: [
76
+ { key: 'n', description: 'Next hunk' },
77
+ { key: 'N', description: 'Previous hunk' },
78
+ { key: 's', description: 'Toggle hunk staged/unstaged' },
79
+ { key: 'd', description: 'Discard changes' },
80
+ ],
72
81
  },
73
82
  ];
74
83
  /**
@@ -186,7 +195,7 @@ export class HotkeysModal {
186
195
  this.box.setContent(lines.join('\n'));
187
196
  this.screen.render();
188
197
  }
189
- renderGroups(groups, colWidth) {
198
+ renderGroups(groups, _colWidth) {
190
199
  const lines = [];
191
200
  for (const group of groups) {
192
201
  lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
@@ -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}`);
@@ -11,7 +11,7 @@ export function formatCommitPanel(state, stagedCount, width) {
11
11
  lines.push(title);
12
12
  lines.push('');
13
13
  // Message input area
14
- const borderChar = state.inputFocused ? '\u2502' : '\u2502';
14
+ const borderChar = '\u2502';
15
15
  const borderColor = state.inputFocused ? 'cyan' : 'gray';
16
16
  // Top border
17
17
  const innerWidth = Math.max(20, width - 6);