diffwatch 1.0.8 → 1.1.0

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.
package/src/index.ts CHANGED
@@ -1,313 +1,433 @@
1
- #!/usr/bin/env node
2
- const blessed = require('neo-neo-blessed');
3
- import chalk from 'chalk';
4
- import { spawn } from 'child_process';
5
- import { GitHandler, FileStatus } from './utils/git';
6
- import { formatDiffWithDiff2Html } from './utils/diff-formatter';
7
-
8
- async function main() {
9
- const args = process.argv.slice(2);
10
- let repoPath = process.cwd();
11
-
12
- const showHelp = () => {
13
- console.log(`
14
- Usage: diffwatch [path] [options]
15
-
16
- Arguments:
17
- path Path to the git repository (default: current directory)
18
-
19
- Options:
20
- -h, --help Show help information
21
- `);
22
- process.exit(0);
23
- };
24
-
25
- const positionalArgs = [];
26
- for (let i = 0; i < args.length; i++) {
27
- if (args[i] === '-h' || args[i] === '--help') {
28
- showHelp();
29
- } else if (args[i].startsWith('-')) {
30
- // Ignore unknown flags or handle them if needed
31
- } else {
32
- positionalArgs.push(args[i]);
33
- }
34
- }
35
-
36
- if (positionalArgs.length > 0) {
37
- repoPath = positionalArgs[0];
38
- }
39
-
40
- try {
41
- process.chdir(repoPath);
42
- } catch (err) {
43
- console.error(`Error: Could not change directory to ${repoPath}`);
44
- process.exit(1);
45
- }
46
-
47
- const gitHandler = new GitHandler(repoPath);
48
-
49
- if (!(await gitHandler.isRepo())) {
50
- console.log(chalk.red(`Error: ${repoPath} is not a git repository.`));
51
- process.exit(1);
52
- }
53
-
54
- // Force xterm-256color to encourage better mouse support on Windows terminals
55
- if (process.platform === 'win32' && !process.env.TERM) {
56
- process.env.TERM = 'xterm-256color';
57
- }
58
-
59
- const screen = blessed.screen({
60
- smartCSR: true,
61
- title: 'diffwatch',
62
- mouse: true,
63
- });
64
-
65
- // Explicitly enable mouse tracking
66
- screen.program.enableMouse();
67
-
68
- const fileList = blessed.list({
69
- top: 0,
70
- left: 0,
71
- width: '30%',
72
- height: '99%',
73
- label: 'Files (0)',
74
- keys: true,
75
- vi: true,
76
- mouse: true,
77
- tags: true,
78
- scrollbar: {
79
- ch: ' ',
80
- track: { bg: 'white' },
81
- style: { bg: 'blue' },
82
- },
83
- style: {
84
- selected: { fg: 'black', bg: 'white' },
85
- border: { fg: 'white' },
86
- },
87
- border: { type: 'line' },
88
- });
89
-
90
- const diffView = blessed.scrollabletext({
91
- top: 0,
92
- left: '30%',
93
- width: '70%',
94
- height: '99%',
95
- label: ' Diff () ',
96
- keys: true,
97
- vi: true,
98
- mouse: true,
99
- scrollbar: {
100
- ch: ' ',
101
- track: { bg: 'white' },
102
- style: { bg: 'blue' },
103
- },
104
- style: {
105
- border: { fg: 'white' },
106
- },
107
- border: { type: 'line' },
108
- tags: false,
109
- });
110
-
111
- const searchBox = blessed.box({
112
- top: 'center',
113
- left: 'center',
114
- width: '50%',
115
- height: 3,
116
- label: ' Search ',
117
- border: { type: 'line' },
118
- style: { border: { fg: 'yellow' } },
119
- hidden: true,
120
- });
121
-
122
- const searchInput = blessed.textbox({
123
- parent: searchBox,
124
- top: 0,
125
- left: 0,
126
- width: '100%-2',
127
- height: 1,
128
- keys: true,
129
- inputOnFocus: true,
130
- style: { fg: 'white', bg: 'black' },
131
- });
132
-
133
- // Confirmation dialog for revert
134
- const confirmDialog = blessed.box({
135
- top: 'center',
136
- left: 'center',
137
- width: '38%',
138
- label: ' Confirm Revert ',
139
- height: 3,
140
- content: 'Press ENTER key to confirm revert or ESC to cancel.',
141
- border: { type: 'line' },
142
- style: {
143
- fg: 'yellow',
144
- bg: 'black',
145
- border: { fg: 'yellow' }
146
- },
147
- hidden: true,
148
- });
149
-
150
- // Footer box to show shortcuts - aligned with the panes
151
- const footer = blessed.box({
152
- bottom: 0,
153
- left: 0,
154
- width: '100%',
155
- height: 1,
156
- align: 'center',
157
- content: chalk.green('←→') + ' Switch |' + chalk.green(' ⏎') + ' Open | ' + chalk.green('S') + ' Search | ' + chalk.green('R') + ' Revert | ' + chalk.green('Q') + ' Quit ',
158
- });
159
-
160
- // Adjust footer to align with the panes
161
- // Note: We'll adjust this after screen initialization
162
-
163
- screen.append(fileList);
164
- screen.append(diffView);
165
- screen.append(searchBox);
166
- screen.append(confirmDialog);
167
- screen.append(footer);
168
-
169
- const updateBorders = () => {
170
- fileList.style.border.fg = screen.focused === fileList ? 'yellow' : 'white';
171
- diffView.style.border.fg = screen.focused === diffView ? 'yellow' : 'white';
172
- screen.render();
173
- };
174
-
175
- let currentFiles: FileStatus[] = [];
176
- let lastSelectedPath: string | null = null;
177
- let diffUpdateTimeout: NodeJS.Timeout | null = null;
178
- let currentSearchTerm: string = '';
179
-
180
- const scheduleDiffUpdate = () => {
181
- if (diffUpdateTimeout) clearTimeout(diffUpdateTimeout);
182
- diffUpdateTimeout = setTimeout(async () => {
183
- await updateDiff();
184
- }, 150); // 150ms debounce
185
- };
186
-
187
- const handleScroll = (direction: 'up' | 'down') => {
188
- if (screen.focused === fileList) {
189
- if (direction === 'up') {
190
- fileList.up(1);
191
- } else {
192
- fileList.down(1);
193
- }
194
- scheduleDiffUpdate();
195
- screen.render();
196
- } else if (screen.focused === diffView) {
197
- const scrollAmount = direction === 'up' ? -2 : 2;
198
- diffView.scroll(scrollAmount);
199
- screen.render();
200
- }
201
- };
202
-
203
- // Remove default wheel listeners to enforce "scroll focused only" behavior
204
- fileList.removeAllListeners('wheeldown');
205
- fileList.removeAllListeners('wheelup');
206
- diffView.removeAllListeners('wheeldown');
207
- diffView.removeAllListeners('wheelup');
208
-
209
- // Attach custom scroll handlers to widgets (captures wheel even if hovering specific widget)
210
- // We use widget-level listeners now that screen.mouse is true.
211
- // We attach to both to ensure the event is caught regardless of where the mouse is.
212
- // The handleScroll function will then decide WHAT to scroll based on focus.
213
-
214
- fileList.on('wheeldown', () => handleScroll('down'));
215
- fileList.on('wheelup', () => handleScroll('up'));
216
-
217
- diffView.on('wheeldown', () => handleScroll('down'));
218
- diffView.on('wheelup', () => handleScroll('up'));
219
-
220
- // Also listen on screen for events that might miss the widgets (margins, borders)
221
- screen.on('wheeldown', () => handleScroll('down'));
222
- screen.on('wheelup', () => handleScroll('up'));
223
-
224
- const openInEditor = (filePath: string) => {
225
- try {
226
- if (process.platform === 'win32') {
227
- // On Windows, use 'start' to open with default program
228
- spawn('cmd', ['/c', 'start', '', filePath], { stdio: 'ignore', detached: true }).unref();
229
- } else {
230
- // On Unix-like systems, try EDITOR, fallback to xdg-open
231
- const editor = process.env.EDITOR || process.env.VISUAL || 'xdg-open';
232
- spawn(editor, [filePath], { stdio: 'ignore', detached: true }).unref();
233
- }
234
- } catch (error) {
235
- console.error(`Failed to open ${filePath}: ${error}`);
236
- }
237
- };
238
-
239
- const updateDiff = async () => {
240
- const selectedIndex = fileList.selected;
241
- const selectedFile = currentFiles[selectedIndex];
242
- if (selectedFile) {
243
- let content = '';
244
- let label = ` Diff (${selectedFile.path}) `;
245
-
246
- if (selectedFile.status !== 'unchanged' && selectedFile.status !== 'deleted') {
247
- const diff = await gitHandler.getDiff(selectedFile.path);
248
- content = formatDiffWithDiff2Html(diff, currentSearchTerm);
249
- }
250
-
251
- if (!content && selectedFile.status !== 'deleted') {
252
- content = await gitHandler.getFileContent(selectedFile.path);
253
- // Highlight search term in full content
254
- if (currentSearchTerm) {
255
- const regex = new RegExp(`(${currentSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
256
- content = content.replace(regex, `\x1b[43m\x1b[30m$1\x1b[0m`);
257
- }
258
- label = ` File (${selectedFile.path}) `;
259
- } else if (!content && selectedFile.status === 'deleted') {
260
- content = chalk.red('File was deleted.');
261
- label = ` Diff (${selectedFile.path}) `;
262
- }
263
-
264
- const currentContent = diffView.content;
265
- const currentLabel = diffView.label;
266
-
267
- // Only update if content or label changed to reduce flickering
268
- if (content !== currentContent || label !== currentLabel) {
269
- const savedScroll = diffView.scrollTop;
270
- const isNewFile = selectedFile.path !== lastSelectedPath;
271
-
272
- diffView.setContent(content);
273
- diffView.setLabel(label);
274
-
275
- if (isNewFile) {
276
- diffView.scrollTo(0);
277
- } else {
278
- diffView.scrollTop = savedScroll;
279
- }
280
- }
281
- lastSelectedPath = selectedFile.path;
282
- } else {
283
- const newContent = 'Select a file to view diff.';
284
- const newLabel = ' Diff () ';
285
- if (diffView.content !== newContent || diffView.label !== newLabel) {
286
- diffView.setContent(newContent);
287
- diffView.setLabel(newLabel);
288
- diffView.scrollTo(0);
289
- }
290
- lastSelectedPath = null;
291
- }
292
- screen.render();
293
- };
294
-
295
- const updateFileList = async () => {
296
- // Preserve selected file path and scroll positions
297
- const selectedPath = currentFiles[fileList.selected]?.path;
298
- const diffScroll = diffView.scrollTop;
299
-
300
- let files: FileStatus[];
301
-
302
- if (currentSearchTerm) {
303
- files = await gitHandler.searchFiles(currentSearchTerm);
304
- } else {
305
- files = await gitHandler.getStatus();
306
- }
307
-
308
- currentFiles = files;
309
-
310
- const items = files.map(f => {
1
+ #!/usr/bin/env node
2
+ const blessed = require('neo-neo-blessed');
3
+ import chalk from 'chalk';
4
+ import { spawn } from 'child_process';
5
+ import { GitHandler, FileStatus } from './utils/git';
6
+ import { formatDiffWithDiff2Html } from './utils/diff-formatter';
7
+ import { readFileSync } from 'fs';
8
+ import { dirname, join } from 'path';
9
+
10
+ const packageJson = JSON.parse(readFileSync(join(dirname(__filename), '..', 'package.json'), 'utf-8'));
11
+
12
+ async function main() {
13
+ const args = process.argv.slice(2);
14
+ let repoPath = process.cwd();
15
+
16
+ const showHelp = () => {
17
+ console.log(`
18
+ Usage: diffwatch [path]
19
+
20
+ Arguments:
21
+ path Path to the git repository (default: current directory)
22
+
23
+ Options:
24
+ -h, --help Show help information
25
+ -v, --version Show version information
26
+ `);
27
+ process.exit(0);
28
+ };
29
+
30
+ const showVersion = () => {
31
+ console.log(`diffwatch v${packageJson.version}`);
32
+ process.exit(0);
33
+ };
34
+
35
+ const positionalArgs = [];
36
+ for (let i = 0; i < args.length; i++) {
37
+ if (args[i] === '-h' || args[i] === '--help') {
38
+ showHelp();
39
+ } else if (args[i] === '-v' || args[i] === '--version') {
40
+ showVersion();
41
+ } else if (args[i].startsWith('-')) {
42
+ // Ignore unknown flags or handle them if needed
43
+ } else {
44
+ positionalArgs.push(args[i]);
45
+ }
46
+ }
47
+
48
+ if (positionalArgs.length > 0) {
49
+ repoPath = positionalArgs[0];
50
+ }
51
+
52
+ try {
53
+ process.chdir(repoPath);
54
+ } catch (err) {
55
+ console.error(`Error: Could not change directory to ${repoPath}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ const gitHandler = new GitHandler(repoPath);
60
+
61
+ if (!(await gitHandler.isRepo())) {
62
+ console.log(chalk.red(`Error: ${repoPath} is not a git repository.`));
63
+ process.exit(1);
64
+ }
65
+
66
+ // Force xterm-256color to encourage better mouse support on Windows terminals
67
+ if (process.platform === 'win32' && !process.env.TERM) {
68
+ process.env.TERM = 'xterm-256color';
69
+ }
70
+
71
+ const screen = blessed.screen({
72
+ smartCSR: true,
73
+ title: 'diffwatch',
74
+ mouse: true,
75
+ });
76
+
77
+ // Explicitly enable mouse tracking
78
+ screen.program.enableMouse();
79
+
80
+ let fileList: any = blessed.list({
81
+ top: 0,
82
+ left: 0,
83
+ width: '30%',
84
+ height: '99%',
85
+ label: 'Files (0)',
86
+ keys: true,
87
+ vi: true,
88
+ mouse: true,
89
+ tags: true,
90
+ scrollbar: {
91
+ ch: ' ',
92
+ track: { bg: 'white' },
93
+ style: { bg: 'blue' },
94
+ },
95
+ style: {
96
+ selected: { fg: 'black', bg: 'white' },
97
+ border: { fg: 'white' },
98
+ },
99
+ border: { type: 'line' },
100
+ });
101
+
102
+ const createFileList = () => {
103
+ const newList = blessed.list({
104
+ top: 0,
105
+ left: 0,
106
+ width: '30%',
107
+ height: '99%',
108
+ label: 'Files (0)',
109
+ keys: true,
110
+ vi: true,
111
+ mouse: true,
112
+ tags: true,
113
+ scrollbar: {
114
+ ch: ' ',
115
+ track: { bg: 'white' },
116
+ style: { bg: 'blue' },
117
+ },
118
+ style: {
119
+ selected: { fg: 'black', bg: 'white' },
120
+ border: { fg: 'white' },
121
+ },
122
+ border: { type: 'line' },
123
+ });
124
+
125
+ newList.on('select item', () => {
126
+ scheduleDiffUpdate();
127
+ });
128
+
129
+ newList.key(['up', 'down'], () => {
130
+ scheduleDiffUpdate();
131
+ });
132
+
133
+ newList.on('wheeldown', () => handleScroll('down'));
134
+ newList.on('wheelup', () => handleScroll('up'));
135
+
136
+ return newList;
137
+ };
138
+
139
+ const diffView = blessed.scrollabletext({
140
+ top: 0,
141
+ left: '30%',
142
+ width: '70%',
143
+ height: '99%',
144
+ label: ' Diff () ',
145
+ keys: true,
146
+ vi: true,
147
+ mouse: true,
148
+ scrollbar: {
149
+ ch: ' ',
150
+ track: { bg: 'white' },
151
+ style: { bg: 'blue' },
152
+ },
153
+ style: {
154
+ border: { fg: 'white' },
155
+ },
156
+ border: { type: 'line' },
157
+ tags: false,
158
+ });
159
+
160
+ const searchBox = blessed.box({
161
+ top: 'center',
162
+ left: '40%', // Center of right pane (30% + 70%/2 = 65%)
163
+ width: '50%',
164
+ height: 3,
165
+ label: ' Search ',
166
+ border: { type: 'line' },
167
+ style: { border: { fg: 'yellow' } },
168
+ hidden: true,
169
+ });
170
+
171
+ const searchInput = blessed.textbox({
172
+ parent: searchBox,
173
+ top: 0,
174
+ left: 0,
175
+ width: '100%-2',
176
+ height: 1,
177
+ keys: true,
178
+ inputOnFocus: true,
179
+ style: { fg: 'white', bg: 'black' },
180
+ });
181
+
182
+ // Confirmation dialog for revert
183
+ const confirmDialog = blessed.box({
184
+ top: 'center',
185
+ left: '45%', // Center of right pane (30% + 70%/2 = 65%)
186
+ width: '38%',
187
+ label: ' Confirm Revert ',
188
+ height: 3,
189
+ content: 'Press SPACE key to confirm revert or ESC to cancel.',
190
+ border: { type: 'line' },
191
+ style: {
192
+ fg: 'yellow',
193
+ bg: 'black',
194
+ border: { fg: 'yellow' }
195
+ },
196
+ hidden: true,
197
+ });
198
+
199
+ const notificationBox = blessed.box({
200
+ top: 'center',
201
+ left: '45%', // Center of right pane (30% + 70%/2 = 65%)
202
+ width: '38%',
203
+ height: 3,
204
+ label: ' Notification ',
205
+ border: { type: 'line' },
206
+ style: {
207
+ fg: 'green',
208
+ bg: 'black',
209
+ border: { fg: 'green' }
210
+ },
211
+ hidden: true,
212
+ });
213
+
214
+ const showNotification = (message: string, color: 'green' | 'red' = 'green') => {
215
+ const previouslyFocused = screen.focused;
216
+ notificationBox.setContent(message);
217
+ notificationBox.style = {
218
+ fg: color,
219
+ bg: 'black',
220
+ border: { fg: color }
221
+ };
222
+ notificationBox.show();
223
+ notificationBox.setFront();
224
+ screen.render();
225
+
226
+ setTimeout(() => {
227
+ notificationBox.hide();
228
+ if (previouslyFocused && previouslyFocused !== notificationBox) {
229
+ previouslyFocused.focus();
230
+ } else {
231
+ fileList.focus();
232
+ }
233
+ updateBorders();
234
+ screen.render();
235
+ }, 3000);
236
+ };
237
+
238
+ // Branch display in bottom right
239
+ const branchBox = blessed.box({
240
+ bottom: 0,
241
+ right: 0,
242
+ width: 'shrink',
243
+ height: 1,
244
+ align: 'left',
245
+ content: chalk.cyan('Branch: '),
246
+ style: {
247
+ fg: 'white',
248
+ },
249
+ });
250
+
251
+ // Footer box to show shortcuts - aligned with the panes
252
+ const footer = blessed.box({
253
+ bottom: 0,
254
+ left: 0,
255
+ width: '100%',
256
+ height: 1,
257
+ content: chalk.green('←→') + ' Switch |' + chalk.green(' ⏎') + ' Open | ' + chalk.green('S') + ' Search | ' + chalk.green('R') + ' Revert | ' + chalk.green('Q') + ' Quit ',
258
+ });
259
+
260
+ screen.append(fileList);
261
+ screen.append(diffView);
262
+ screen.append(searchBox);
263
+ screen.append(notificationBox);
264
+ screen.append(confirmDialog);
265
+ screen.append(footer);
266
+ screen.append(branchBox);
267
+
268
+ const updateBorders = () => {
269
+ fileList.style.border.fg = screen.focused === fileList ? 'yellow' : 'white';
270
+ diffView.style.border.fg = screen.focused === diffView ? 'yellow' : 'white';
271
+ screen.render();
272
+ };
273
+
274
+ let currentFiles: FileStatus[] = [];
275
+ let lastSelectedPath: string | null = null;
276
+ let diffUpdateTimeout: NodeJS.Timeout | null = null;
277
+ let currentSearchTerm: string = '';
278
+ let currentBranch: string = '';
279
+ let lastFilePaths: string[] = [];
280
+ let isUpdatingList = false;
281
+
282
+ const scheduleDiffUpdate = () => {
283
+ if (diffUpdateTimeout) clearTimeout(diffUpdateTimeout);
284
+ diffUpdateTimeout = setTimeout(async () => {
285
+ await updateDiff();
286
+ }, 150); // 150ms debounce
287
+ };
288
+
289
+ const handleScroll = (direction: 'up' | 'down') => {
290
+ const focused = screen.focused;
291
+ if (focused === fileList) {
292
+ if (direction === 'up') {
293
+ focused.up(1);
294
+ } else {
295
+ focused.down(1);
296
+ }
297
+ scheduleDiffUpdate();
298
+ screen.render();
299
+ } else if (focused === diffView) {
300
+ const scrollAmount = direction === 'up' ? -2 : 2;
301
+ diffView.scroll(scrollAmount);
302
+ screen.render();
303
+ }
304
+ };
305
+
306
+ // Remove default wheel listeners to enforce "scroll focused only" behavior
307
+ fileList.removeAllListeners('wheeldown');
308
+ fileList.removeAllListeners('wheelup');
309
+ diffView.removeAllListeners('wheeldown');
310
+ diffView.removeAllListeners('wheelup');
311
+
312
+ // Attach custom scroll handlers to widgets (captures wheel even if hovering specific widget)
313
+ // We use widget-level listeners now that screen.mouse is true.
314
+ // We attach to both to ensure the event is caught regardless of where the mouse is.
315
+ // We use widget-level listeners now that screen.mouse is true.
316
+ // We attach to both to ensure the event is caught regardless of where the mouse is.
317
+ // The handleScroll function will then decide WHAT to scroll based on focus.
318
+
319
+ diffView.on('wheeldown', () => handleScroll('down'));
320
+ diffView.on('wheelup', () => handleScroll('up'));
321
+
322
+ // Also listen on screen for events that might miss the widgets (margins, borders)
323
+ screen.on('wheeldown', () => handleScroll('down'));
324
+ screen.on('wheelup', () => handleScroll('up'));
325
+
326
+ const openInEditor = (filePath: string) => {
327
+ try {
328
+ if (process.platform === 'win32') {
329
+ // On Windows, use 'start' to open with default program
330
+ spawn('cmd', ['/c', 'start', '', filePath], { stdio: 'ignore', detached: true }).unref();
331
+ } else {
332
+ // On Unix-like systems, try EDITOR, fallback to xdg-open
333
+ const editor = process.env.EDITOR || process.env.VISUAL || 'xdg-open';
334
+ spawn(editor, [filePath], { stdio: 'ignore', detached: true }).unref();
335
+ }
336
+ } catch (error) {
337
+ console.error(`Failed to open ${filePath}: ${error}`);
338
+ }
339
+ };
340
+
341
+ const updateBranch = async () => {
342
+ const branch = await gitHandler.getBranch();
343
+ if (branch !== currentBranch) {
344
+ currentBranch = branch;
345
+ branchBox.setContent(chalk.cyan('Branch: ') + chalk.yellow(branch));
346
+ screen.render();
347
+ }
348
+ };
349
+
350
+ const updateDiff = async () => {
351
+ const selectedIndex = fileList.selected;
352
+ const selectedFile = currentFiles[selectedIndex];
353
+ if (!selectedFile) {
354
+ // No valid file selected, clear diff view
355
+ diffView.setContent('Select a file to view diff.');
356
+ diffView.setLabel(' Diff () ');
357
+ lastSelectedPath = null;
358
+ screen.render();
359
+ return;
360
+ }
361
+ if (selectedFile) {
362
+ let content = '';
363
+ let label = ` Diff (${selectedFile.path}) `;
364
+
365
+ if (selectedFile.status !== 'unchanged' && selectedFile.status !== 'deleted') {
366
+ const diff = await gitHandler.getDiff(selectedFile.path);
367
+ content = formatDiffWithDiff2Html(diff, currentSearchTerm);
368
+ }
369
+
370
+ if (!content && selectedFile.status !== 'deleted') {
371
+ content = await gitHandler.getFileContent(selectedFile.path);
372
+ // Highlight search term in full content
373
+ if (currentSearchTerm) {
374
+ const regex = new RegExp(`(${currentSearchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
375
+ content = content.replace(regex, `\x1b[43m\x1b[30m$1\x1b[0m`);
376
+ }
377
+ label = ` File (${selectedFile.path}) `;
378
+ } else if (!content && selectedFile.status === 'deleted') {
379
+ content = chalk.red('File was deleted.');
380
+ label = ` Diff (${selectedFile.path}) `;
381
+ }
382
+
383
+ const currentContent = diffView.content;
384
+ const currentLabel = diffView.label;
385
+
386
+ // Only update if content or label changed to reduce flickering
387
+ if (content !== currentContent || label !== currentLabel) {
388
+ const savedScroll = diffView.scrollTop;
389
+ const isNewFile = selectedFile.path !== lastSelectedPath;
390
+
391
+ diffView.setContent(content);
392
+ diffView.setLabel(label);
393
+
394
+ if (isNewFile) {
395
+ diffView.scrollTo(0);
396
+ } else {
397
+ diffView.scrollTop = savedScroll;
398
+ }
399
+ }
400
+ lastSelectedPath = selectedFile.path;
401
+ } else {
402
+ const newContent = 'Select a file to view diff.';
403
+ const newLabel = ' Diff () ';
404
+ if (diffView.content !== newContent || diffView.label !== newLabel) {
405
+ diffView.setContent(newContent);
406
+ diffView.setLabel(newLabel);
407
+ diffView.scrollTo(0);
408
+ }
409
+ lastSelectedPath = null;
410
+ }
411
+ screen.render();
412
+ };
413
+
414
+ const updateFileList = async () => {
415
+ if (isUpdatingList) return;
416
+ isUpdatingList = true;
417
+
418
+ const selectedPath = currentFiles[fileList.selected]?.path;
419
+
420
+ let files: FileStatus[];
421
+
422
+ if (currentSearchTerm) {
423
+ files = await gitHandler.searchFiles(currentSearchTerm);
424
+ } else {
425
+ files = await gitHandler.getStatus();
426
+ }
427
+
428
+ currentFiles = files;
429
+
430
+ const items = files.map((f) => {
311
431
  let color = '{white-fg}';
312
432
  if (f.status === 'added') color = '{green-fg}';
313
433
  else if (f.status === 'deleted') color = '{red-fg}';
@@ -315,167 +435,206 @@ Options:
315
435
  else if (f.status === 'unstaged') color = '{white-fg}';
316
436
  else if (f.status === 'unchanged') color = '{grey-fg}';
317
437
 
318
- return `${color}${f.path}{/}`;
319
- });
320
-
321
- fileList.setItems(items);
322
-
323
- const labelTitle = currentSearchTerm ? `Files (${files.length}) - Searching: "${currentSearchTerm}"` : `Files (${files.length})`;
324
- fileList.setLabel(labelTitle);
325
-
326
- if (items.length > 0) {
327
- // Restore selection by path if possible
328
- const newSelectedIndex = selectedPath ? currentFiles.findIndex(f => f.path === selectedPath) : -1;
329
- fileList.select(newSelectedIndex >= 0 ? newSelectedIndex : 0);
330
- // Cancel any pending diff update and update immediately
331
- if (diffUpdateTimeout) {
332
- clearTimeout(diffUpdateTimeout);
333
- diffUpdateTimeout = null;
334
- }
335
- await updateDiff();
336
- } else {
337
- // Clear the file list when there are no files
338
- fileList.clearItems();
339
- diffView.setContent(currentSearchTerm ? `No files match "${currentSearchTerm}".` : 'No changes detected.');
340
- diffView.setLabel(' Diff () ');
341
- }
342
-
343
- // Restore scroll positions if reasonably possible (reset if list changed drastically)
344
- // Actually, if we filter, the scroll position might be invalid.
345
- // Ideally we keep it 0 if it was 0 or just let the select() call handle scrolling to the item.
346
- // The previous implementation blindly restored scrollTop.
347
- // If the list shrunk, select() should have brought it into view.
348
- // We only explicitly restore if items.length > 0
349
- // But setting scroll to previous value might be wrong if the list is now shorter.
350
- // Safe to only restore diffView scroll as it depends on content, fileList is handled by select.
351
-
352
- diffView.scrollTop = diffScroll;
353
-
354
- screen.render();
355
- };
356
-
357
- fileList.on('select item', () => {
358
- scheduleDiffUpdate();
359
- });
438
+ let statusSymbol = '';
439
+ if (f.status === 'added') statusSymbol = 'A ';
440
+ else if (f.status === 'deleted') statusSymbol = 'D ';
441
+ else if (f.status === 'modified') statusSymbol = 'M ';
442
+ else if (f.status === 'unstaged') statusSymbol = '? ';
443
+ else if (f.status === 'unchanged') statusSymbol = ' ';
360
444
 
361
- fileList.key(['up', 'down'], () => {
362
- scheduleDiffUpdate();
363
- });
364
-
365
- screen.key(['escape', 'q', 'C-c'], () => {
366
- if (!searchBox.hidden) {
367
- searchBox.hide();
368
- screen.render();
369
- fileList.focus();
370
- } else if (!confirmDialog.hidden) {
371
- // Close the confirmation dialog if it's open
372
- confirmDialog.hide();
373
- fileList.focus();
374
- screen.render();
375
- } else {
376
- screen.destroy();
377
- process.exit(0);
378
- }
379
- });
380
-
381
- screen.key(['s'], () => {
382
- searchBox.show();
383
- searchBox.setFront();
384
- searchInput.setValue(currentSearchTerm);
385
- searchInput.focus();
386
- screen.render();
387
- });
388
-
389
- searchInput.on('submit', async (value: string) => {
390
- currentSearchTerm = (value || '').trim();
391
- searchBox.hide();
392
- fileList.focus();
393
- // Force immediate update
394
- await updateFileList();
395
- });
396
-
397
- searchInput.on('cancel', () => {
398
- searchBox.hide();
399
- fileList.focus();
400
- screen.render();
401
- });
402
-
403
- screen.key(['tab'], () => {
404
- if (screen.focused === fileList) {
405
- diffView.focus();
406
- } else {
407
- fileList.focus();
408
- }
409
- updateBorders();
410
- });
411
-
412
- screen.key(['left'], () => {
413
- fileList.focus();
414
- updateBorders();
415
- });
416
-
417
- screen.key(['right'], () => {
418
- diffView.focus();
419
- updateBorders();
420
- });
421
-
422
- screen.key(['enter'], () => {
423
- const selectedIndex = fileList.selected;
424
- const selectedFile = currentFiles[selectedIndex];
425
- if (selectedFile) {
426
- openInEditor(selectedFile.path);
427
- }
428
- });
429
-
430
- // Handle revert key press
431
- screen.key(['r'], async () => {
432
- const selectedIndex = fileList.selected;
433
- const selectedFile = currentFiles[selectedIndex];
434
- if (selectedFile) {
435
- confirmDialog.setContent(`Press SPACE key to confirm revert or ESC to cancel.`);
436
- confirmDialog.show();
437
- confirmDialog.focus();
438
- screen.render();
439
- }
440
- });
441
-
442
- // Handle confirmation dialog response with SPACE key
443
- confirmDialog.key(['space'], async () => {
444
- confirmDialog.hide();
445
- const selectedIndex = fileList.selected;
446
- const selectedFile = currentFiles[selectedIndex];
447
- if (selectedFile) {
448
- try {
449
- await gitHandler.revertFile(selectedFile.path);
450
- console.log(chalk.green(`File ${selectedFile.path} reverted successfully.`));
451
- // Refresh the file list after reverting
452
- await updateFileList();
453
- } catch (error) {
454
- const errorMessage = error instanceof Error ? error.message : String(error);
455
- console.error(chalk.red(`Error reverting file: ${errorMessage}`));
456
- }
457
- }
458
- fileList.focus();
459
- screen.render();
460
- });
461
-
462
- // Handle cancellation with ESC key
463
- confirmDialog.key(['escape', 'q'], () => {
464
- confirmDialog.hide();
465
- fileList.focus();
466
- screen.render();
467
- });
468
-
469
- setInterval(async () => {
470
- await updateFileList();
471
- }, 5000);
472
-
473
- await updateFileList();
474
- fileList.focus();
475
- updateBorders();
476
- }
477
-
478
- main().catch(err => {
479
- console.error(err);
480
- process.exit(1);
481
- });
445
+ return `${color}${statusSymbol}${f.path}{/}`;
446
+ });
447
+
448
+ const labelTitle = currentSearchTerm ? `Files (${files.length}) - Searching: "${currentSearchTerm}"` : `Files (${files.length})`;
449
+
450
+ const newFilePaths = files.map(f => f.path);
451
+ const selectedFileStillExists = selectedPath ? newFilePaths.includes(selectedPath) : false;
452
+ const listChanged = lastFilePaths.length !== newFilePaths.length ||
453
+ lastFilePaths.some(path => !newFilePaths.includes(path)) ||
454
+ newFilePaths.some(path => !lastFilePaths.includes(path));
455
+
456
+ if (items.length > 0) {
457
+ const newSelectedIndex = selectedPath ? currentFiles.findIndex(f => f.path === selectedPath) : -1;
458
+
459
+ const oldFileList = fileList;
460
+ const newFileList = createFileList();
461
+ newFileList.setLabel(labelTitle);
462
+ newFileList.setItems(items);
463
+ newFileList.select(newSelectedIndex >= 0 ? newSelectedIndex : 0);
464
+
465
+ if (!selectedFileStillExists || listChanged) {
466
+ lastSelectedPath = null;
467
+ }
468
+
469
+ lastFilePaths = newFilePaths;
470
+
471
+ if (diffUpdateTimeout) {
472
+ clearTimeout(diffUpdateTimeout);
473
+ diffUpdateTimeout = null;
474
+ }
475
+ await updateDiff();
476
+
477
+ oldFileList.destroy();
478
+ screen.remove(oldFileList);
479
+ fileList = newFileList;
480
+ screen.append(fileList);
481
+ } else {
482
+ const oldFileList = fileList;
483
+ const newFileList = createFileList();
484
+ newFileList.setLabel(labelTitle);
485
+ newFileList.setItems([]);
486
+
487
+ diffView.setContent(currentSearchTerm ? `No files match "${currentSearchTerm}".` : 'No changes detected.');
488
+ diffView.setLabel(' Diff () ');
489
+ lastSelectedPath = null;
490
+ lastFilePaths = [];
491
+
492
+ oldFileList.destroy();
493
+ screen.remove(oldFileList);
494
+ fileList = newFileList;
495
+ screen.append(fileList);
496
+ }
497
+
498
+ screen.render();
499
+ isUpdatingList = false;
500
+ };
501
+
502
+
503
+
504
+ screen.key(['s'], () => {
505
+ searchBox.show();
506
+ searchBox.setFront();
507
+ searchInput.setValue(currentSearchTerm);
508
+ searchInput.focus();
509
+ screen.render();
510
+ });
511
+
512
+ searchInput.on('submit', async (value: string) => {
513
+ currentSearchTerm = (value || '').trim();
514
+ searchBox.hide();
515
+ fileList.focus();
516
+ // Force immediate update
517
+ await updateFileList();
518
+ });
519
+
520
+ searchInput.on('cancel', () => {
521
+ searchBox.hide();
522
+ fileList.focus();
523
+ screen.render();
524
+ });
525
+
526
+ screen.key(['tab'], () => {
527
+ if (screen.focused === fileList) {
528
+ diffView.focus();
529
+ } else {
530
+ fileList.focus();
531
+ }
532
+ updateBorders();
533
+ });
534
+
535
+ screen.key(['left'], () => {
536
+ if (screen.focused !== fileList) {
537
+ fileList.focus();
538
+ updateBorders();
539
+ }
540
+ });
541
+
542
+ screen.key(['right'], () => {
543
+ if (screen.focused !== diffView) {
544
+ diffView.focus();
545
+ updateBorders();
546
+ }
547
+ });
548
+
549
+ screen.key(['enter'], () => {
550
+ const selectedIndex = fileList.selected;
551
+ const selectedFile = currentFiles[selectedIndex];
552
+ if (selectedFile) {
553
+ openInEditor(selectedFile.path);
554
+ }
555
+ });
556
+
557
+ // Handle revert key press
558
+ screen.key(['r'], async () => {
559
+ const selectedIndex = fileList.selected;
560
+ const selectedFile = currentFiles[selectedIndex];
561
+ if (selectedFile) {
562
+ confirmDialog.show();
563
+ confirmDialog.setFront();
564
+ confirmDialog.focus();
565
+ screen.render();
566
+ }
567
+ });
568
+
569
+ // Handle dialog keys
570
+ confirmDialog.key(['space'], async () => {
571
+ const selectedIndex = fileList.selected;
572
+ const selectedFile = currentFiles[selectedIndex];
573
+ if (selectedFile) {
574
+ try {
575
+ await gitHandler.revertFile(selectedFile.path);
576
+ showNotification(`File ${selectedFile.path} reverted successfully.`, 'green');
577
+ await updateFileList();
578
+ } catch (error) {
579
+ const errorMessage = error instanceof Error ? error.message : String(error);
580
+ showNotification(`Error reverting file: ${errorMessage}`, 'red');
581
+ }
582
+ }
583
+ confirmDialog.hide();
584
+ fileList.focus();
585
+ screen.render();
586
+ });
587
+
588
+ confirmDialog.key(['escape'], () => {
589
+ confirmDialog.hide();
590
+ fileList.focus();
591
+ screen.render();
592
+ });
593
+
594
+ await updateBranch();
595
+ await updateFileList();
596
+ fileList.focus();
597
+ updateBorders();
598
+
599
+ // Set up periodic updates to detect new git changes
600
+ const updateInterval = setInterval(async () => {
601
+ const currentlyFocused = screen.focused;
602
+ const wasFileListFocused = currentlyFocused === fileList;
603
+
604
+ await updateBranch();
605
+ await updateFileList();
606
+
607
+ // Restore focus to the same pane it was on before the update
608
+ if (wasFileListFocused) {
609
+ fileList.focus();
610
+ } else if (currentlyFocused === diffView) {
611
+ diffView.focus();
612
+ }
613
+ updateBorders();
614
+ }, 2000); // Check for changes every 2 seconds
615
+
616
+ // Clean up interval on exit
617
+ screen.key(['escape', 'q', 'C-c'], () => {
618
+ // Don't handle ESC if confirmation dialog has focus or is visible
619
+ if (screen.focused === confirmDialog || !confirmDialog.hidden) {
620
+ return false;
621
+ }
622
+
623
+ if (!searchBox.hidden) {
624
+ searchBox.hide();
625
+ screen.render();
626
+ fileList.focus();
627
+ return false;
628
+ } else {
629
+ // Clear interval only when actually exiting
630
+ clearInterval(updateInterval);
631
+ screen.destroy();
632
+ process.exit(0);
633
+ }
634
+ });
635
+ }
636
+
637
+ main().catch(err => {
638
+ console.error(err);
639
+ process.exit(1);
640
+ });