diffstalker 0.2.1 → 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.
@@ -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,11 +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
20
  hideHiddenFiles: true,
20
21
  hideGitignored: true,
22
+ flatViewMode: false,
21
23
  splitRatio: 0.4,
22
24
  activeModal: null,
23
25
  pendingDiscard: null,
@@ -110,6 +112,22 @@ export class UIState extends EventEmitter {
110
112
  setExplorerSelectedIndex(index) {
111
113
  this.update({ explorerSelectedIndex: Math.max(0, index) });
112
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
+ }
113
131
  // Display toggles
114
132
  toggleWrapMode() {
115
133
  this.update({ wrapMode: !this._state.wrapMode, diffScrollOffset: 0 });
@@ -126,6 +144,9 @@ export class UIState extends EventEmitter {
126
144
  toggleHideGitignored() {
127
145
  this.update({ hideGitignored: !this._state.hideGitignored });
128
146
  }
147
+ toggleFlatViewMode() {
148
+ this.update({ flatViewMode: !this._state.flatViewMode, fileListScrollOffset: 0 });
149
+ }
129
150
  // Split ratio
130
151
  adjustSplitRatio(delta) {
131
152
  const newRatio = Math.min(0.85, Math.max(0.15, this._state.splitRatio + delta));
@@ -189,6 +210,7 @@ export class UIState extends EventEmitter {
189
210
  explorerSelectedIndex: 0,
190
211
  explorerScrollOffset: 0,
191
212
  explorerFileScrollOffset: 0,
213
+ selectedHunkIndex: 0,
192
214
  };
193
215
  this.emit('change', this._state);
194
216
  }
@@ -1,14 +1,15 @@
1
1
  import { formatFileList } from './widgets/FileList.js';
2
+ import { formatFlatFileList } from './widgets/FlatFileList.js';
2
3
  import { formatHistoryView } from './widgets/HistoryView.js';
3
4
  import { formatCompareListView } from './widgets/CompareListView.js';
4
5
  import { formatExplorerView } from './widgets/ExplorerView.js';
5
- import { formatDiff, formatHistoryDiff } from './widgets/DiffView.js';
6
+ import { formatDiff, formatCombinedDiff, formatHistoryDiff } from './widgets/DiffView.js';
6
7
  import { formatCommitPanel } from './widgets/CommitPanel.js';
7
8
  import { formatExplorerContent } from './widgets/ExplorerContent.js';
8
9
  /**
9
10
  * Render the top pane content for the current tab.
10
11
  */
11
- export function renderTopPane(state, files, historyCommits, compareDiff, compareSelection, explorerState, width, topPaneHeight) {
12
+ export function renderTopPane(state, files, historyCommits, compareDiff, compareSelection, explorerState, width, topPaneHeight, hunkCounts, flatFiles) {
12
13
  if (state.bottomTab === 'history') {
13
14
  return formatHistoryView(historyCommits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, topPaneHeight);
14
15
  }
@@ -21,36 +22,55 @@ export function renderTopPane(state, files, historyCommits, compareDiff, compare
21
22
  const displayRows = explorerState?.displayRows ?? [];
22
23
  return formatExplorerView(displayRows, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
23
24
  }
24
- // Default: diff tab file list
25
- return formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, topPaneHeight);
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);
26
30
  }
27
31
  /**
28
32
  * Render the bottom pane content for the current tab.
29
33
  */
30
- export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight) {
34
+ export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight, selectedHunkIndex, isFileStaged, combinedFileDiffs) {
31
35
  if (state.bottomTab === 'commit') {
32
36
  const content = formatCommitPanel(commitFlowState, stagedCount, width);
33
- return { content, totalRows: 0 };
37
+ return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
34
38
  }
35
39
  if (state.bottomTab === 'history') {
36
40
  const selectedCommit = historyState?.selectedCommit ?? null;
37
41
  const commitDiff = historyState?.commitDiff ?? null;
38
42
  const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
39
- return { content, totalRows };
43
+ return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
40
44
  }
41
45
  if (state.bottomTab === 'compare') {
42
46
  const compareDiff = compareSelectionState?.diff ?? null;
43
47
  if (compareDiff) {
44
48
  const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
45
- return { content, totalRows };
49
+ return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
46
50
  }
47
- return { content: '{gray-fg}Select a commit or file to view diff{/gray-fg}', totalRows: 0 };
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
+ };
48
57
  }
49
58
  if (state.bottomTab === 'explorer') {
50
59
  const content = formatExplorerContent(explorerSelectedFile?.path ?? null, explorerSelectedFile?.content ?? null, width, state.explorerFileScrollOffset, bottomPaneHeight, explorerSelectedFile?.truncated ?? false, state.wrapMode);
51
- return { content, totalRows: 0 };
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
+ };
52
72
  }
53
- // Default: diff tab
54
- const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
55
- return { content, totalRows };
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 };
56
76
  }
@@ -1,56 +1,19 @@
1
1
  import blessed from 'neo-blessed';
2
+ import { Fzf } from 'fzf';
2
3
  const MAX_RESULTS = 15;
3
4
  /**
4
- * Simple fuzzy match scoring.
5
- * Returns -1 if no match, otherwise a score (higher is better).
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.
6
8
  */
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();
9
+ function highlightMatch(displayPath, positions, offset) {
45
10
  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++;
11
+ for (let i = 0; i < displayPath.length; i++) {
12
+ if (positions.has(i + offset)) {
13
+ result += `{yellow-fg}${displayPath[i]}{/yellow-fg}`;
51
14
  }
52
15
  else {
53
- result += path[i];
16
+ result += displayPath[i];
54
17
  }
55
18
  }
56
19
  return result;
@@ -62,6 +25,7 @@ export class FileFinder {
62
25
  box;
63
26
  textbox;
64
27
  screen;
28
+ fzf;
65
29
  allPaths;
66
30
  results = [];
67
31
  selectedIndex = 0;
@@ -71,6 +35,7 @@ export class FileFinder {
71
35
  constructor(screen, allPaths, onSelect, onCancel) {
72
36
  this.screen = screen;
73
37
  this.allPaths = allPaths;
38
+ this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, forward: false });
74
39
  this.onSelect = onSelect;
75
40
  this.onCancel = onCancel;
76
41
  // Create modal box
@@ -123,7 +88,7 @@ export class FileFinder {
123
88
  if (this.results.length > 0) {
124
89
  const selected = this.results[this.selectedIndex];
125
90
  this.close();
126
- this.onSelect(selected.path);
91
+ this.onSelect(selected.item);
127
92
  }
128
93
  });
129
94
  // Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
@@ -162,22 +127,13 @@ export class FileFinder {
162
127
  }
163
128
  updateResults() {
164
129
  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 }));
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 }));
167
134
  return;
168
135
  }
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);
136
+ this.results = this.fzf.find(this.query);
181
137
  }
182
138
  render() {
183
139
  const lines = [];
@@ -195,13 +151,18 @@ export class FileFinder {
195
151
  const result = this.results[i];
196
152
  const isSelected = i === this.selectedIndex;
197
153
  // Truncate path if needed
198
- let displayPath = result.path;
154
+ const fullPath = result.item;
199
155
  const maxLen = width - 4;
156
+ let displayPath = fullPath;
157
+ let offset = 0;
200
158
  if (displayPath.length > maxLen) {
201
- displayPath = '…' + displayPath.slice(-(maxLen - 1));
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;
202
163
  }
203
- // Highlight matched characters
204
- const highlighted = highlightMatch(this.query, displayPath);
164
+ // Highlight matched characters using fzf positions
165
+ const highlighted = highlightMatch(displayPath, result.positions, offset);
205
166
  if (isSelected) {
206
167
  lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
207
168
  }
@@ -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);
@@ -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
  /**