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
@@ -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
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared type for remote operation state (push/fetch/pull).
3
+ * Lives in types/ so both ui/ and core/ can import it.
4
+ */
5
+ export {};
@@ -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 { formatCommitPanel } from './widgets/CommitPanel.js';
6
+ import { formatDiff, formatCombinedDiff, formatHistoryDiff } from './widgets/DiffView.js';
7
+ import { formatCommitPanel, getCommitPanelTotalRows } 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,65 @@ 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, branch, remoteState, stashList, headCommit) {
31
35
  if (state.bottomTab === 'commit') {
32
- const content = formatCommitPanel(commitFlowState, stagedCount, width);
33
- return { content, totalRows: 0 };
36
+ const panelOpts = {
37
+ state: commitFlowState,
38
+ stagedCount,
39
+ width,
40
+ branch,
41
+ remoteState,
42
+ stashList,
43
+ headCommit,
44
+ };
45
+ const totalRows = getCommitPanelTotalRows(panelOpts);
46
+ const content = formatCommitPanel(commitFlowState, stagedCount, width, branch, remoteState, stashList, headCommit, state.diffScrollOffset, bottomPaneHeight);
47
+ return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
34
48
  }
35
49
  if (state.bottomTab === 'history') {
36
50
  const selectedCommit = historyState?.selectedCommit ?? null;
37
51
  const commitDiff = historyState?.commitDiff ?? null;
38
52
  const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
39
- return { content, totalRows };
53
+ return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
40
54
  }
41
55
  if (state.bottomTab === 'compare') {
42
56
  const compareDiff = compareSelectionState?.diff ?? null;
43
57
  if (compareDiff) {
44
58
  const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
45
- return { content, totalRows };
59
+ return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
46
60
  }
47
- return { content: '{gray-fg}Select a commit or file to view diff{/gray-fg}', totalRows: 0 };
61
+ return {
62
+ content: '{gray-fg}Select a commit or file to view diff{/gray-fg}',
63
+ totalRows: 0,
64
+ hunkCount: 0,
65
+ hunkBoundaries: [],
66
+ };
48
67
  }
49
68
  if (state.bottomTab === 'explorer') {
50
69
  const content = formatExplorerContent(explorerSelectedFile?.path ?? null, explorerSelectedFile?.content ?? null, width, state.explorerFileScrollOffset, bottomPaneHeight, explorerSelectedFile?.truncated ?? false, state.wrapMode);
51
- return { content, totalRows: 0 };
70
+ return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
71
+ }
72
+ // Flat mode: show combined unstaged+staged diff with section headers
73
+ if (state.flatViewMode && combinedFileDiffs) {
74
+ const result = formatCombinedDiff(combinedFileDiffs.unstaged, combinedFileDiffs.staged, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode, selectedHunkIndex);
75
+ return {
76
+ content: result.content,
77
+ totalRows: result.totalRows,
78
+ hunkCount: result.hunkCount,
79
+ hunkBoundaries: result.hunkBoundaries,
80
+ hunkMapping: result.hunkMapping,
81
+ };
52
82
  }
53
- // Default: diff tab
54
- const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
55
- return { content, totalRows };
83
+ // Default: diff tab — pass selectedHunkIndex for hunk gutter
84
+ const { content, totalRows, hunkCount, hunkBoundaries } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode, selectedHunkIndex, isFileStaged);
85
+ return { content, totalRows, hunkCount, hunkBoundaries };
56
86
  }
@@ -0,0 +1,157 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * BranchPicker modal for switching or creating branches.
4
+ * Text input at top for filtering; branch list below.
5
+ * If typed name matches no existing branch, shows "Create: <name>" as first option.
6
+ */
7
+ export class BranchPicker {
8
+ box;
9
+ textbox;
10
+ screen;
11
+ branches;
12
+ filteredBranches = [];
13
+ selectedIndex = 0;
14
+ query = '';
15
+ showCreate = false;
16
+ onSwitch;
17
+ onCreate;
18
+ onCancel;
19
+ constructor(screen, branches, onSwitch, onCreate, onCancel) {
20
+ this.screen = screen;
21
+ this.branches = branches;
22
+ this.onSwitch = onSwitch;
23
+ this.onCreate = onCreate;
24
+ this.onCancel = onCancel;
25
+ this.filteredBranches = branches;
26
+ const width = Math.min(60, screen.width - 6);
27
+ const maxVisible = Math.min(branches.length + 1, 15);
28
+ const height = maxVisible + 7;
29
+ this.box = blessed.box({
30
+ parent: screen,
31
+ top: 'center',
32
+ left: 'center',
33
+ width,
34
+ height,
35
+ border: {
36
+ type: 'line',
37
+ },
38
+ style: {
39
+ border: {
40
+ fg: 'cyan',
41
+ },
42
+ },
43
+ tags: true,
44
+ keys: false,
45
+ });
46
+ this.textbox = blessed.textarea({
47
+ parent: this.box,
48
+ top: 1,
49
+ left: 1,
50
+ width: width - 4,
51
+ height: 1,
52
+ inputOnFocus: true,
53
+ style: {
54
+ fg: 'white',
55
+ bg: 'default',
56
+ },
57
+ });
58
+ this.setupKeyHandlers();
59
+ this.render();
60
+ }
61
+ setupKeyHandlers() {
62
+ this.textbox.key(['escape'], () => {
63
+ this.close();
64
+ this.onCancel();
65
+ });
66
+ this.textbox.key(['enter'], () => {
67
+ if (this.showCreate && this.selectedIndex === 0) {
68
+ this.close();
69
+ this.onCreate(this.query.trim());
70
+ }
71
+ else {
72
+ const adjustedIndex = this.showCreate ? this.selectedIndex - 1 : this.selectedIndex;
73
+ const branch = this.filteredBranches[adjustedIndex];
74
+ if (branch && !branch.current) {
75
+ this.close();
76
+ this.onSwitch(branch.name);
77
+ }
78
+ }
79
+ });
80
+ this.textbox.key(['C-j', 'down'], () => {
81
+ const maxIndex = this.filteredBranches.length + (this.showCreate ? 1 : 0) - 1;
82
+ this.selectedIndex = Math.min(maxIndex, this.selectedIndex + 1);
83
+ this.render();
84
+ });
85
+ this.textbox.key(['C-k', 'up'], () => {
86
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
87
+ this.render();
88
+ });
89
+ this.textbox.on('keypress', () => {
90
+ setImmediate(() => {
91
+ const newQuery = this.textbox.getValue() || '';
92
+ if (newQuery !== this.query) {
93
+ this.query = newQuery;
94
+ this.selectedIndex = 0;
95
+ this.updateFilter();
96
+ this.render();
97
+ }
98
+ });
99
+ });
100
+ }
101
+ updateFilter() {
102
+ const q = this.query.trim().toLowerCase();
103
+ if (!q) {
104
+ this.filteredBranches = this.branches;
105
+ this.showCreate = false;
106
+ }
107
+ else {
108
+ this.filteredBranches = this.branches.filter((b) => b.name.toLowerCase().includes(q));
109
+ // Show create option if no exact match
110
+ this.showCreate = !this.branches.some((b) => b.name === q);
111
+ }
112
+ }
113
+ render() {
114
+ const lines = [];
115
+ lines.push('{bold}{cyan-fg}Switch / Create Branch{/cyan-fg}{/bold}');
116
+ lines.push(''); // Space for input
117
+ lines.push('');
118
+ if (this.showCreate) {
119
+ const isSelected = this.selectedIndex === 0;
120
+ if (isSelected) {
121
+ lines.push(`{green-fg}{bold}> Create: ${this.query.trim()}{/bold}{/green-fg}`);
122
+ }
123
+ else {
124
+ lines.push(` {green-fg}Create: ${this.query.trim()}{/green-fg}`);
125
+ }
126
+ }
127
+ for (let i = 0; i < this.filteredBranches.length; i++) {
128
+ const branch = this.filteredBranches[i];
129
+ const listIndex = this.showCreate ? i + 1 : i;
130
+ const isSelected = listIndex === this.selectedIndex;
131
+ let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
132
+ if (branch.current) {
133
+ line += '* ';
134
+ }
135
+ line += branch.name;
136
+ if (isSelected)
137
+ line += '{/bold}{/cyan-fg}';
138
+ if (branch.current)
139
+ line += ' {gray-fg}(current){/gray-fg}';
140
+ lines.push(line);
141
+ }
142
+ if (this.filteredBranches.length === 0 && !this.showCreate) {
143
+ lines.push('{gray-fg}No matching branches{/gray-fg}');
144
+ }
145
+ lines.push('');
146
+ lines.push('{gray-fg}Enter: select | Esc: cancel | Ctrl+j/k: navigate{/gray-fg}');
147
+ this.box.setContent(lines.join('\n'));
148
+ this.screen.render();
149
+ }
150
+ close() {
151
+ this.textbox.destroy();
152
+ this.box.destroy();
153
+ }
154
+ focus() {
155
+ this.textbox.focus();
156
+ }
157
+ }
@@ -0,0 +1,66 @@
1
+ import blessed from 'neo-blessed';
2
+ /**
3
+ * CommitActionConfirm modal for confirming cherry-pick or revert.
4
+ */
5
+ export class CommitActionConfirm {
6
+ box;
7
+ screen;
8
+ onConfirm;
9
+ onCancel;
10
+ constructor(screen, verb, commit, 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 = 8;
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(verb, commit, 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(verb, commit, width) {
47
+ const lines = [];
48
+ const innerWidth = width - 6;
49
+ lines.push(`{bold}{yellow-fg} ${verb} commit?{/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}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}');
57
+ this.box.setContent(lines.join('\n'));
58
+ this.screen.render();
59
+ }
60
+ close() {
61
+ this.box.destroy();
62
+ }
63
+ focus() {
64
+ this.box.focus();
65
+ }
66
+ }
@@ -1,56 +1,20 @@
1
1
  import blessed from 'neo-blessed';
2
+ import { Fzf } from 'fzf';
2
3
  const MAX_RESULTS = 15;
4
+ const DEBOUNCE_MS = 15;
3
5
  /**
4
- * Simple fuzzy match scoring.
5
- * Returns -1 if no match, otherwise a score (higher is better).
6
+ * Highlight matched characters in a display path.
7
+ * The positions set refers to indices in the original full path,
8
+ * so we need an offset when the display path is truncated.
6
9
  */
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();
10
+ function highlightMatch(displayPath, positions, offset) {
45
11
  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++;
12
+ for (let i = 0; i < displayPath.length; i++) {
13
+ if (positions.has(i + offset)) {
14
+ result += `{yellow-fg}${displayPath[i]}{/yellow-fg}`;
51
15
  }
52
16
  else {
53
- result += path[i];
17
+ result += displayPath[i];
54
18
  }
55
19
  }
56
20
  return result;
@@ -68,11 +32,14 @@ export class FileFinder {
68
32
  query = '';
69
33
  onSelect;
70
34
  onCancel;
35
+ debounceTimer = null;
36
+ fzf;
71
37
  constructor(screen, allPaths, onSelect, onCancel) {
72
38
  this.screen = screen;
73
39
  this.allPaths = allPaths;
74
40
  this.onSelect = onSelect;
75
41
  this.onCancel = onCancel;
42
+ this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, casing: 'smart-case' });
76
43
  // Create modal box
77
44
  const width = Math.min(80, screen.width - 10);
78
45
  const height = MAX_RESULTS + 6; // results + input + header + borders + padding
@@ -108,9 +75,9 @@ export class FileFinder {
108
75
  });
109
76
  // Setup key handlers
110
77
  this.setupKeyHandlers();
111
- // Initial render with all files
78
+ // Initial render with first N files
112
79
  this.updateResults();
113
- this.render();
80
+ this.renderContent();
114
81
  }
115
82
  setupKeyHandlers() {
116
83
  // Handle escape to cancel
@@ -129,57 +96,53 @@ export class FileFinder {
129
96
  // Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
130
97
  this.textbox.key(['C-j', 'down'], () => {
131
98
  this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
132
- this.render();
99
+ this.renderContent();
133
100
  });
134
101
  this.textbox.key(['C-k', 'up'], () => {
135
102
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
136
- this.render();
103
+ this.renderContent();
137
104
  });
138
105
  // Handle tab for next result
139
106
  this.textbox.key(['tab'], () => {
140
107
  this.selectedIndex = (this.selectedIndex + 1) % Math.max(1, this.results.length);
141
- this.render();
108
+ this.renderContent();
142
109
  });
143
110
  // Handle shift-tab for previous result
144
111
  this.textbox.key(['S-tab'], () => {
145
112
  this.selectedIndex =
146
113
  (this.selectedIndex - 1 + this.results.length) % Math.max(1, this.results.length);
147
- this.render();
114
+ this.renderContent();
148
115
  });
149
- // Update results on keypress
116
+ // Update results on keypress with debounce
150
117
  this.textbox.on('keypress', () => {
151
- // Defer to next tick to get updated value
152
- setImmediate(() => {
118
+ if (this.debounceTimer)
119
+ clearTimeout(this.debounceTimer);
120
+ this.debounceTimer = setTimeout(() => {
153
121
  const newQuery = this.textbox.getValue() || '';
154
122
  if (newQuery !== this.query) {
155
123
  this.query = newQuery;
156
124
  this.selectedIndex = 0;
157
125
  this.updateResults();
158
- this.render();
126
+ this.renderContent();
159
127
  }
160
- });
128
+ }, DEBOUNCE_MS);
161
129
  });
162
130
  }
163
131
  updateResults() {
164
132
  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 }));
133
+ this.results = this.allPaths
134
+ .slice(0, MAX_RESULTS)
135
+ .map((p) => ({ path: p, positions: new Set(), score: 0 }));
167
136
  return;
168
137
  }
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);
138
+ const entries = this.fzf.find(this.query);
139
+ this.results = entries.map((entry) => ({
140
+ path: entry.item,
141
+ score: entry.score,
142
+ positions: entry.positions,
143
+ }));
181
144
  }
182
- render() {
145
+ renderContent() {
183
146
  const lines = [];
184
147
  const width = this.box.width - 4;
185
148
  // Header
@@ -195,13 +158,18 @@ export class FileFinder {
195
158
  const result = this.results[i];
196
159
  const isSelected = i === this.selectedIndex;
197
160
  // Truncate path if needed
198
- let displayPath = result.path;
161
+ const fullPath = result.path;
199
162
  const maxLen = width - 4;
163
+ let displayPath = fullPath;
164
+ let offset = 0;
200
165
  if (displayPath.length > maxLen) {
201
- displayPath = '…' + displayPath.slice(-(maxLen - 1));
166
+ offset = displayPath.length - (maxLen - 1);
167
+ displayPath = '…' + displayPath.slice(offset);
168
+ // Account for the '…' prefix: display index 0 is '…', actual content starts at 1
169
+ offset = offset - 1;
202
170
  }
203
171
  // Highlight matched characters
204
- const highlighted = highlightMatch(this.query, displayPath);
172
+ const highlighted = highlightMatch(displayPath, result.positions, offset);
205
173
  if (isSelected) {
206
174
  lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
207
175
  }
@@ -220,6 +188,8 @@ export class FileFinder {
220
188
  this.screen.render();
221
189
  }
222
190
  close() {
191
+ if (this.debounceTimer)
192
+ clearTimeout(this.debounceTimer);
223
193
  this.textbox.destroy();
224
194
  this.box.destroy();
225
195
  }