diffwatch 2.0.3 → 2.0.5

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/README.md CHANGED
@@ -20,6 +20,22 @@ A TUI app for watching git repository file changes with diffs.
20
20
 
21
21
  ## Installation
22
22
 
23
+ ### Prerequisites
24
+
25
+ **Bun runtime is required** to run DiffWatch. If you don't have it installed, install it first:
26
+
27
+ **macOS/Linux:**
28
+ ```bash
29
+ curl -fsSL https://bun.sh/install | bash
30
+ ```
31
+
32
+ **Windows (PowerShell):**
33
+ ```powershell
34
+ irm bun.sh/install.ps1 | iex
35
+ ```
36
+
37
+ Or visit [bun.sh](https://bun.sh) for other installation methods.
38
+
23
39
  ### Option 1: Install from npm (recommended)
24
40
 
25
41
  ```bash
package/bin/diffwatch.js CHANGED
@@ -1,22 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from 'child_process';
3
+ import { spawn, execSync } from 'child_process';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { dirname, join } from 'path';
6
- import { platform } from 'os';
6
+ import { existsSync } from 'fs';
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = dirname(__filename);
10
10
 
11
- let binary = 'diffwatch';
12
- if (platform() === 'win32') {
13
- binary += '.exe';
11
+ // Check if bun is installed
12
+ let bunPath;
13
+ try {
14
+ const result = execSync('where bun', { encoding: 'utf-8' }).trim();
15
+ bunPath = result.split('\n')[0]; // Take first result
16
+ } catch (error) {
17
+ // Check if bun is in common locations on Windows
18
+ if (process.platform === 'win32') {
19
+ const possiblePaths = [
20
+ join(process.env.LOCALAPPDATA, 'Bun', 'bun.exe'),
21
+ join(process.env.ProgramFiles, 'bun', 'bun.exe'),
22
+ ];
23
+ for (const p of possiblePaths) {
24
+ if (existsSync(p)) {
25
+ bunPath = p;
26
+ break;
27
+ }
28
+ }
29
+ }
14
30
  }
15
31
 
16
- const appPath = join(__dirname, '..', 'dist', binary);
32
+ if (!bunPath) {
33
+ console.error('Error: Bun is not installed.');
34
+ console.error('');
35
+ console.error('DiffWatch requires Bun to run. Please install Bun:');
36
+ console.error('');
37
+ console.error(' curl -fsSL https://bun.sh/install | bash');
38
+ console.error('');
39
+ console.error('Or visit: https://bun.sh');
40
+ process.exit(1);
41
+ }
42
+
43
+ const srcPath = join(__dirname, '..', 'src', 'index.tsx');
17
44
 
18
- const child = spawn(appPath, process.argv.slice(2), {
45
+ const child = spawn(bunPath, ['run', srcPath, ...process.argv.slice(2)], {
19
46
  stdio: 'inherit',
47
+ shell: true, // Required for Windows
20
48
  cwd: process.cwd()
21
49
  });
22
50
 
@@ -26,5 +54,5 @@ child.on('error', (err) => {
26
54
  });
27
55
 
28
56
  child.on('exit', (code) => {
29
- process.exit(code);
57
+ process.exit(code || 0);
30
58
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffwatch",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "A TUI app for watching git repository file changes with diffs.",
5
5
  "keywords": [
6
6
  "git",
@@ -26,15 +26,20 @@
26
26
  "engines": {
27
27
  "node": ">=18.0.0"
28
28
  },
29
+ "os": [
30
+ "win32",
31
+ "darwin",
32
+ "linux"
33
+ ],
29
34
  "bin": {
30
- "diffwatch": "./bin/diffwatch.js"
35
+ "diffwatch": "bin/diffwatch.js"
31
36
  },
32
37
  "files": [
33
- "dist/**/*",
34
38
  "bin/**/*",
39
+ "src/**/*",
40
+ "package.json",
35
41
  "README.md",
36
- "LICENSE",
37
- "package.json"
42
+ "LICENSE"
38
43
  ],
39
44
  "type": "module",
40
45
  "devDependencies": {
@@ -59,7 +64,7 @@
59
64
  "trash": "^9.0.0"
60
65
  },
61
66
  "scripts": {
62
- "build": "bun run build:version && bun build src/index.tsx --compile --outfile dist/diffwatch",
67
+ "build": "bun run build:version",
63
68
  "build:version": "node -e \"console.log('export const BUILD_VERSION = \\\"' + require('./package.json').version + '\\\";')\" > src/version.ts",
64
69
  "dev": "bun run src/index.tsx",
65
70
  "start": "bun run src/index.tsx",
package/src/App.tsx ADDED
@@ -0,0 +1,420 @@
1
+ import { useState, useEffect, useMemo, useRef } from 'react';
2
+ import { useRenderer, useKeyboard } from '@opentui/react';
3
+ import { spawn } from 'child_process';
4
+ import * as path from 'path';
5
+ import { getChangedFiles, getCurrentBranch, getBranchCount, getCommitHistory, type FileStatus, type CommitInfo, type ChangedFile, revertFile, deleteFileSafely, runGit, searchFiles } from './utils/git';
6
+ import { FileList } from './components/FileList';
7
+ import { DiffViewer } from './components/DiffViewer';
8
+ import { HistoryViewer } from './components/HistoryViewer';
9
+ import { StatusBar } from './components/StatusBar';
10
+ import { POLLING_INTERVAL } from './constants';
11
+
12
+ export default function App() {
13
+ const renderer = useRenderer();
14
+ const [allFiles, setAllFiles] = useState<FileStatus[]>([]);
15
+ const [selectedIndex, setSelectedIndex] = useState(0);
16
+ const [focused, setFocused] = useState<'fileList' | 'diffView'>('fileList');
17
+ const [repoPath] = useState(process.cwd());
18
+ const [branch, setBranch] = useState('...');
19
+ const [branchCount, setBranchCount] = useState(0);
20
+ const [confirmRevert, setConfirmRevert] = useState(false);
21
+ const [confirmDelete, setConfirmDelete] = useState(false);
22
+ const [searchMode, setSearchMode] = useState(false);
23
+ const [searchQuery, setSearchQuery] = useState('');
24
+ const [searchActive, setSearchActive] = useState(false);
25
+ const [searchResults, setSearchResults] = useState<FileStatus[] | null>(null);
26
+ const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
27
+ const [errorNotification, setErrorNotification] = useState<string | null>(null);
28
+ const [historyMode, setHistoryMode] = useState(false);
29
+ const [commits, setCommits] = useState<CommitInfo[]>([]);
30
+
31
+ const refresh = async (skipIfSearching = false) => {
32
+ // Skip refresh if search is active and skipIfSearching is true
33
+ if (skipIfSearching && searchActive) {
34
+ return;
35
+ }
36
+
37
+ const newFiles = await getChangedFiles(repoPath);
38
+ setAllFiles(newFiles);
39
+ if (selectedIndex >= newFiles.length) {
40
+ setSelectedIndex(Math.max(0, newFiles.length - 1));
41
+ }
42
+ };
43
+
44
+ // Filter files based on search
45
+ const filteredFiles = useMemo(() => {
46
+ if (!searchActive || !searchQuery.trim()) {
47
+ return allFiles;
48
+ }
49
+ // Return search results if available, otherwise empty array
50
+ return searchResults ?? [];
51
+ }, [searchActive, searchQuery, allFiles, searchResults]);
52
+
53
+ // Execute search and update files when search is submitted
54
+ useEffect(() => {
55
+ const doSearch = async () => {
56
+ if (!searchActive || !searchQuery.trim()) {
57
+ setSearchResults(null);
58
+ return;
59
+ }
60
+
61
+
62
+ try {
63
+ // Search within the current allFiles only (by content, not filename)
64
+ const matchedFiles = await searchFiles(searchQuery, allFiles, repoPath);
65
+ setSearchResults(matchedFiles);
66
+ } catch (error) {
67
+ const errorMessage = error instanceof Error ? error.message : String(error);
68
+ setErrorNotification(`Search failed: ${errorMessage}`);
69
+ setSearchResults(null);
70
+ }
71
+ };
72
+
73
+ // Debounce search
74
+ const timeout = setTimeout(doSearch, 300);
75
+ return () => clearTimeout(timeout);
76
+ }, [searchActive, searchQuery, repoPath, allFiles]);
77
+
78
+ // Auto-hide notifications after 3 seconds
79
+ useEffect(() => {
80
+ if (notification) {
81
+ const timeout = setTimeout(() => setNotification(null), 3000);
82
+ return () => clearTimeout(timeout);
83
+ }
84
+ }, [notification]);
85
+
86
+ // Load initial data
87
+ useEffect(() => {
88
+ refresh();
89
+ getCurrentBranch(repoPath).then(setBranch);
90
+ getBranchCount(repoPath).then(setBranchCount);
91
+ }, [repoPath]);
92
+
93
+ // Refs to store interval IDs to prevent memory leaks
94
+ const gitPollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
95
+ const branchPollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
96
+
97
+ // Poll for git changes periodically
98
+ useEffect(() => {
99
+ // Clear any existing interval to prevent duplicates
100
+ if (gitPollingIntervalRef.current) {
101
+ clearInterval(gitPollingIntervalRef.current);
102
+ }
103
+
104
+ gitPollingIntervalRef.current = setInterval(async () => {
105
+ await refresh(true);
106
+ }, POLLING_INTERVAL);
107
+
108
+ return () => {
109
+ if (gitPollingIntervalRef.current) {
110
+ clearInterval(gitPollingIntervalRef.current);
111
+ gitPollingIntervalRef.current = null;
112
+ }
113
+ };
114
+ }, [repoPath, searchActive]);
115
+
116
+ // Poll for branch changes
117
+ useEffect(() => {
118
+ // Clear any existing interval to prevent duplicates
119
+ if (branchPollingIntervalRef.current) {
120
+ clearInterval(branchPollingIntervalRef.current);
121
+ }
122
+
123
+ branchPollingIntervalRef.current = setInterval(async () => {
124
+ const currentBranch = await getCurrentBranch(repoPath);
125
+ const count = await getBranchCount(repoPath);
126
+ setBranch(currentBranch);
127
+ setBranchCount(count);
128
+ }, POLLING_INTERVAL);
129
+
130
+ return () => {
131
+ if (branchPollingIntervalRef.current) {
132
+ clearInterval(branchPollingIntervalRef.current);
133
+ branchPollingIntervalRef.current = null;
134
+ }
135
+ };
136
+ }, [repoPath]);
137
+
138
+ // Load commit history when history mode is opened
139
+ useEffect(() => {
140
+ if (historyMode) {
141
+ getCommitHistory(repoPath).then(setCommits);
142
+ }
143
+ }, [historyMode, repoPath]);
144
+
145
+ // Open file in default editor
146
+ const openFile = (filePath: string) => {
147
+ try {
148
+ const absPath = filePath.startsWith('/') || filePath.match(/^[A-Za-z]:\\/)
149
+ ? path.normalize(filePath)
150
+ : path.normalize(path.join(repoPath, filePath));
151
+ const isWindows = process.platform === 'win32';
152
+ const isMac = process.platform === 'darwin';
153
+
154
+ if (isWindows) {
155
+ // On Windows, use cmd to execute start command to properly handle paths with spaces
156
+ // Pass the path directly without additional quotes since shell: false passes args directly
157
+ spawn('cmd', ['/c', 'start', '""', absPath], {
158
+ detached: true,
159
+ stdio: 'ignore',
160
+ cwd: repoPath,
161
+ shell: false // Don't use shell to avoid double-quoting issues
162
+ }).unref();
163
+ } else {
164
+ // On macOS and Linux, use the open command with shell: true so it handles spaces properly
165
+ const command = isMac ? 'open' : 'xdg-open';
166
+ spawn(command, [absPath], {
167
+ detached: true,
168
+ stdio: 'ignore',
169
+ cwd: repoPath,
170
+ shell: true // Use shell to handle spaces in path
171
+ }).unref();
172
+ }
173
+ return; // Exit early after handling the file opening
174
+ } catch (e) {
175
+ const errorMessage = e instanceof Error ? e.message : String(e);
176
+ setErrorNotification(`Failed to open file: ${errorMessage}`);
177
+ }
178
+ };
179
+
180
+ // Keyboard handling
181
+ useKeyboard(async (key) => {
182
+ // Dismiss error notification if present
183
+ if (errorNotification) {
184
+ setErrorNotification(null);
185
+ return;
186
+ }
187
+
188
+ // If history mode is open, skip file-related shortcuts
189
+ if (historyMode) {
190
+ if (key.name === 'q') {
191
+ renderer.destroy();
192
+ process.exit(0);
193
+ }
194
+ // Allow other keys but don't process file-related shortcuts
195
+ return;
196
+ }
197
+
198
+ // Search mode
199
+ if (searchMode) {
200
+ if (key.name === 'escape') {
201
+ setSearchMode(false);
202
+ setSearchQuery('');
203
+ setSearchActive(false);
204
+ setSelectedIndex(0);
205
+ } else if (key.name === 'backspace') {
206
+ setSearchQuery(q => q.slice(0, -1));
207
+ } else if (key.name === 'enter' || key.name === 'return') {
208
+ setSearchActive(searchQuery.trim().length > 0);
209
+ setSearchMode(false);
210
+ setSelectedIndex(0);
211
+ } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
212
+ // Only add ASCII printable characters (space to tilde) and extended text
213
+ if (/^[\x20\x21-\x7E\u00A0-\uFFFF]$/.test(key.sequence)) {
214
+ setSearchQuery(q => q + key.sequence);
215
+ }
216
+ }
217
+ return;
218
+ }
219
+
220
+ // Confirm revert mode
221
+ if (confirmRevert) {
222
+ if (key.name === 'y') {
223
+ const file = filteredFiles[selectedIndex];
224
+ if (file) {
225
+ try {
226
+ if (file.status === 'new') {
227
+ await deleteFileSafely(file.path);
228
+ setNotification({ message: `File ${file.path} deleted.`, type: 'success' });
229
+ } else {
230
+ await revertFile(file.path);
231
+ setNotification({ message: `File ${file.path} reverted.`, type: 'success' });
232
+ }
233
+ await refresh();
234
+ } catch (error) {
235
+ const errorMessage = error instanceof Error ? error.message : String(error);
236
+ setErrorNotification(`Revert failed: ${errorMessage}`);
237
+ }
238
+ }
239
+ }
240
+ setConfirmRevert(false);
241
+ return;
242
+ }
243
+
244
+ // Confirm delete mode
245
+ if (confirmDelete) {
246
+ if (key.name === 'y') {
247
+ const file = filteredFiles[selectedIndex];
248
+ if (file) {
249
+ try {
250
+ const deleted = await deleteFileSafely(file.path);
251
+ if (deleted) {
252
+ setNotification({ message: `File ${file.path} deleted.`, type: 'success' });
253
+ await refresh();
254
+ } else {
255
+ setErrorNotification(`Failed to delete ${file.path}.`);
256
+ }
257
+ } catch (error) {
258
+ const errorMessage = error instanceof Error ? error.message : String(error);
259
+ setErrorNotification(`Delete failed: ${errorMessage}`);
260
+ }
261
+ }
262
+ }
263
+ setConfirmDelete(false);
264
+ return;
265
+ }
266
+
267
+ // Global shortcuts
268
+ if (key.name === 'q') {
269
+ renderer.destroy();
270
+ process.exit(0);
271
+ }
272
+
273
+ if (key.name === 's' && (filteredFiles.length > 0 || searchActive)) {
274
+ setSearchMode(true);
275
+ }
276
+
277
+ if (key.name === 'h') {
278
+ setHistoryMode(true);
279
+ }
280
+
281
+ if (key.name === 'tab' || key.name === 'left' || key.name === 'right') {
282
+ setFocused(f => f === 'fileList' ? 'diffView' : 'fileList');
283
+ }
284
+
285
+ if (focused === 'fileList') {
286
+ if (key.name === 'up') {
287
+ setSelectedIndex(i => Math.max(0, i - 1));
288
+ }
289
+ if (key.name === 'down') {
290
+ setSelectedIndex(i => Math.min(filteredFiles.length - 1, i + 1));
291
+ }
292
+ if (key.name === 'enter' || key.name === 'return') {
293
+ const file = filteredFiles[selectedIndex];
294
+ if (file) {
295
+ openFile(file.path);
296
+ }
297
+ }
298
+ if (key.name === 'd' && (filteredFiles.length > 0 || searchActive)) {
299
+ setConfirmDelete(true);
300
+ }
301
+ if (key.name === 'r' && (filteredFiles.length > 0 || searchActive)) {
302
+ setConfirmRevert(true);
303
+ }
304
+ }
305
+ });
306
+
307
+ return (
308
+ <box flexDirection="column" height="100%">
309
+ <box flexDirection="row" flexGrow={1}>
310
+ <FileList
311
+ files={filteredFiles}
312
+ selectedIndex={selectedIndex}
313
+ focused={focused === 'fileList'}
314
+ searchQuery={searchActive ? searchQuery : undefined}
315
+ onSelect={(index) => {
316
+ setSelectedIndex(index);
317
+ setFocused('fileList');
318
+ }}
319
+ onScroll={(delta) => {
320
+ setSelectedIndex(i => {
321
+ const next = i + (delta > 0 ? 1 : -1);
322
+ return Math.max(0, Math.min(filteredFiles.length - 1, next));
323
+ });
324
+ }}
325
+ />
326
+ <DiffViewer
327
+ filename={filteredFiles[selectedIndex]?.path}
328
+ focused={focused === 'diffView'}
329
+ searchQuery={searchActive ? searchQuery : undefined}
330
+ status={filteredFiles[selectedIndex]?.status || 'modified'}
331
+ repoPath={repoPath}
332
+ />
333
+ </box>
334
+ <StatusBar branch={branch} branchCount={branchCount} fileCount={filteredFiles.length} searchActive={searchActive} />
335
+ {searchMode && (
336
+ <box
337
+ position="absolute"
338
+ top="50%"
339
+ left="50%"
340
+ width={50}
341
+ height={3}
342
+ backgroundColor="black"
343
+ border
344
+ title=" Search "
345
+ flexDirection="column"
346
+ >
347
+ <text>{searchQuery}</text>
348
+ </box>
349
+ )}
350
+ {notification && (
351
+ <box
352
+ position="absolute"
353
+ top="50%"
354
+ left="50%"
355
+ width={50}
356
+ height={3}
357
+ backgroundColor="black"
358
+ border
359
+ title={notification.type === 'success' ? " Success " : " Error "}
360
+ >
361
+ <text fg={notification.type === 'success' ? 'green' : 'brightRed'}>
362
+ {notification.message}
363
+ </text>
364
+ </box>
365
+ )}
366
+ {errorNotification && (
367
+ <box
368
+ position="absolute"
369
+ top="50%"
370
+ left="50%"
371
+ width={60}
372
+ height={5}
373
+ backgroundColor="red"
374
+ border
375
+ flexDirection="column"
376
+ title=" Error "
377
+ >
378
+ <text fg="white">{errorNotification}</text>
379
+ <text fg="white">Press any key to dismiss</text>
380
+ </box>
381
+ )}
382
+ {confirmRevert && (
383
+ <box
384
+ position="absolute"
385
+ top="50%"
386
+ left="50%"
387
+ width={50}
388
+ height={3}
389
+ backgroundColor="yellow"
390
+ border
391
+ flexDirection="column"
392
+ title=" Confirm Revert "
393
+ >
394
+ <text fg="red">Press Y to revert or any other key to cancel</text>
395
+ </box>
396
+ )}
397
+ {confirmDelete && (
398
+ <box
399
+ position="absolute"
400
+ top="50%"
401
+ left="50%"
402
+ width={50}
403
+ height={3}
404
+ backgroundColor="red"
405
+ border
406
+ flexDirection="column"
407
+ title=" Confirm Delete "
408
+ >
409
+ <text fg="white">Press Y to delete or any other key to cancel</text>
410
+ </box>
411
+ )}
412
+ {historyMode && (
413
+ <HistoryViewer
414
+ commits={commits}
415
+ onClose={() => setHistoryMode(false)}
416
+ />
417
+ )}
418
+ </box>
419
+ );
420
+ }