diffstalker 0.2.2 → 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.
@@ -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 {};
@@ -4,7 +4,7 @@ import { formatHistoryView } from './widgets/HistoryView.js';
4
4
  import { formatCompareListView } from './widgets/CompareListView.js';
5
5
  import { formatExplorerView } from './widgets/ExplorerView.js';
6
6
  import { formatDiff, formatCombinedDiff, formatHistoryDiff } from './widgets/DiffView.js';
7
- import { formatCommitPanel } from './widgets/CommitPanel.js';
7
+ import { formatCommitPanel, getCommitPanelTotalRows } from './widgets/CommitPanel.js';
8
8
  import { formatExplorerContent } from './widgets/ExplorerContent.js';
9
9
  /**
10
10
  * Render the top pane content for the current tab.
@@ -31,10 +31,20 @@ export function renderTopPane(state, files, historyCommits, compareDiff, compare
31
31
  /**
32
32
  * Render the bottom pane content for the current tab.
33
33
  */
34
- export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight, selectedHunkIndex, isFileStaged, combinedFileDiffs) {
34
+ export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight, selectedHunkIndex, isFileStaged, combinedFileDiffs, branch, remoteState, stashList, headCommit) {
35
35
  if (state.bottomTab === 'commit') {
36
- const content = formatCommitPanel(commitFlowState, stagedCount, width);
37
- return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
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: [] };
38
48
  }
39
49
  if (state.bottomTab === 'history') {
40
50
  const selectedCommit = historyState?.selectedCommit ?? null;
@@ -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,8 +1,9 @@
1
1
  import blessed from 'neo-blessed';
2
2
  import { Fzf } from 'fzf';
3
3
  const MAX_RESULTS = 15;
4
+ const DEBOUNCE_MS = 15;
4
5
  /**
5
- * Highlight matched characters in a display path using fzf position data.
6
+ * Highlight matched characters in a display path.
6
7
  * The positions set refers to indices in the original full path,
7
8
  * so we need an offset when the display path is truncated.
8
9
  */
@@ -25,19 +26,20 @@ export class FileFinder {
25
26
  box;
26
27
  textbox;
27
28
  screen;
28
- fzf;
29
29
  allPaths;
30
30
  results = [];
31
31
  selectedIndex = 0;
32
32
  query = '';
33
33
  onSelect;
34
34
  onCancel;
35
+ debounceTimer = null;
36
+ fzf;
35
37
  constructor(screen, allPaths, onSelect, onCancel) {
36
38
  this.screen = screen;
37
39
  this.allPaths = allPaths;
38
- this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, forward: false });
39
40
  this.onSelect = onSelect;
40
41
  this.onCancel = onCancel;
42
+ this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, casing: 'smart-case' });
41
43
  // Create modal box
42
44
  const width = Math.min(80, screen.width - 10);
43
45
  const height = MAX_RESULTS + 6; // results + input + header + borders + padding
@@ -73,9 +75,9 @@ export class FileFinder {
73
75
  });
74
76
  // Setup key handlers
75
77
  this.setupKeyHandlers();
76
- // Initial render with all files
78
+ // Initial render with first N files
77
79
  this.updateResults();
78
- this.render();
80
+ this.renderContent();
79
81
  }
80
82
  setupKeyHandlers() {
81
83
  // Handle escape to cancel
@@ -88,54 +90,59 @@ export class FileFinder {
88
90
  if (this.results.length > 0) {
89
91
  const selected = this.results[this.selectedIndex];
90
92
  this.close();
91
- this.onSelect(selected.item);
93
+ this.onSelect(selected.path);
92
94
  }
93
95
  });
94
96
  // Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
95
97
  this.textbox.key(['C-j', 'down'], () => {
96
98
  this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
97
- this.render();
99
+ this.renderContent();
98
100
  });
99
101
  this.textbox.key(['C-k', 'up'], () => {
100
102
  this.selectedIndex = Math.max(0, this.selectedIndex - 1);
101
- this.render();
103
+ this.renderContent();
102
104
  });
103
105
  // Handle tab for next result
104
106
  this.textbox.key(['tab'], () => {
105
107
  this.selectedIndex = (this.selectedIndex + 1) % Math.max(1, this.results.length);
106
- this.render();
108
+ this.renderContent();
107
109
  });
108
110
  // Handle shift-tab for previous result
109
111
  this.textbox.key(['S-tab'], () => {
110
112
  this.selectedIndex =
111
113
  (this.selectedIndex - 1 + this.results.length) % Math.max(1, this.results.length);
112
- this.render();
114
+ this.renderContent();
113
115
  });
114
- // Update results on keypress
116
+ // Update results on keypress with debounce
115
117
  this.textbox.on('keypress', () => {
116
- // Defer to next tick to get updated value
117
- setImmediate(() => {
118
+ if (this.debounceTimer)
119
+ clearTimeout(this.debounceTimer);
120
+ this.debounceTimer = setTimeout(() => {
118
121
  const newQuery = this.textbox.getValue() || '';
119
122
  if (newQuery !== this.query) {
120
123
  this.query = newQuery;
121
124
  this.selectedIndex = 0;
122
125
  this.updateResults();
123
- this.render();
126
+ this.renderContent();
124
127
  }
125
- });
128
+ }, DEBOUNCE_MS);
126
129
  });
127
130
  }
128
131
  updateResults() {
129
132
  if (!this.query) {
130
- // fzf returns nothing for empty query, show first N files
131
133
  this.results = this.allPaths
132
134
  .slice(0, MAX_RESULTS)
133
- .map((item) => ({ item, positions: new Set(), start: 0, end: 0, score: 0 }));
135
+ .map((p) => ({ path: p, positions: new Set(), score: 0 }));
134
136
  return;
135
137
  }
136
- this.results = this.fzf.find(this.query);
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
+ }));
137
144
  }
138
- render() {
145
+ renderContent() {
139
146
  const lines = [];
140
147
  const width = this.box.width - 4;
141
148
  // Header
@@ -151,7 +158,7 @@ export class FileFinder {
151
158
  const result = this.results[i];
152
159
  const isSelected = i === this.selectedIndex;
153
160
  // Truncate path if needed
154
- const fullPath = result.item;
161
+ const fullPath = result.path;
155
162
  const maxLen = width - 4;
156
163
  let displayPath = fullPath;
157
164
  let offset = 0;
@@ -161,7 +168,7 @@ export class FileFinder {
161
168
  // Account for the '…' prefix: display index 0 is '…', actual content starts at 1
162
169
  offset = offset - 1;
163
170
  }
164
- // Highlight matched characters using fzf positions
171
+ // Highlight matched characters
165
172
  const highlighted = highlightMatch(displayPath, result.positions, offset);
166
173
  if (isSelected) {
167
174
  lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
@@ -181,6 +188,8 @@ export class FileFinder {
181
188
  this.screen.render();
182
189
  }
183
190
  close() {
191
+ if (this.debounceTimer)
192
+ clearTimeout(this.debounceTimer);
184
193
  this.textbox.destroy();
185
194
  this.box.destroy();
186
195
  }
@@ -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
  {
@@ -63,6 +67,25 @@ const hotkeyGroups = [
63
67
  { key: 'g', description: 'Show changes only' },
64
68
  ],
65
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' },
87
+ ],
88
+ },
66
89
  {
67
90
  title: 'Compare',
68
91
  entries: [
@@ -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
+ }