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 +16 -0
- package/bin/diffwatch.js +36 -8
- package/package.json +11 -6
- package/src/App.tsx +420 -0
- package/src/components/DiffViewer.tsx +258 -0
- package/src/components/FileList.tsx +95 -0
- package/src/components/HistoryViewer.tsx +170 -0
- package/src/components/StatusBar.tsx +27 -0
- package/src/constants.ts +1 -0
- package/src/index.tsx +106 -0
- package/src/utils/git.ts +402 -0
- package/src/version.ts +1 -0
- package/dist/diffwatch.exe +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
|
2
|
+
import { useKeyboard } from '@opentui/react';
|
|
3
|
+
import { getRawDiff } from '../utils/git';
|
|
4
|
+
import * as Diff2Html from 'diff2html';
|
|
5
|
+
import * as fsPromises from 'fs/promises';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { POLLING_INTERVAL } from '../constants';
|
|
8
|
+
import { isBinaryFile } from 'isbinaryfile';
|
|
9
|
+
|
|
10
|
+
interface DiffViewerProps {
|
|
11
|
+
filename?: string;
|
|
12
|
+
focused: boolean;
|
|
13
|
+
searchQuery?: string;
|
|
14
|
+
status?: 'modified' | 'new' | 'deleted' | 'renamed' | 'unknown' | 'unstaged' | 'unchanged' | 'ignored';
|
|
15
|
+
repoPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function DiffViewer({ filename, focused, searchQuery, status, repoPath }: DiffViewerProps) {
|
|
19
|
+
const [rawDiff, setRawDiff] = useState('');
|
|
20
|
+
const [fileContent, setFileContent] = useState('');
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
const [isBinary, setIsBinary] = useState(false);
|
|
23
|
+
const scrollRef = useRef<any>(null);
|
|
24
|
+
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
25
|
+
|
|
26
|
+
const loadContent = async (showLoading = true) => {
|
|
27
|
+
if (!filename) {
|
|
28
|
+
setRawDiff('');
|
|
29
|
+
setFileContent('');
|
|
30
|
+
setIsBinary(false);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (showLoading) setLoading(true);
|
|
35
|
+
|
|
36
|
+
if (status === 'new' || status === 'unchanged') {
|
|
37
|
+
try {
|
|
38
|
+
const absPath = path.isAbsolute(filename) ? filename : path.join(repoPath || process.cwd(), filename);
|
|
39
|
+
const content = await fsPromises.readFile(absPath, 'utf-8');
|
|
40
|
+
const binary = await isBinaryFile(absPath);
|
|
41
|
+
if (binary) {
|
|
42
|
+
setFileContent('');
|
|
43
|
+
setRawDiff('');
|
|
44
|
+
setIsBinary(true);
|
|
45
|
+
} else {
|
|
46
|
+
setFileContent(content);
|
|
47
|
+
setRawDiff('');
|
|
48
|
+
setIsBinary(false);
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
setFileContent('');
|
|
52
|
+
setIsBinary(false);
|
|
53
|
+
}
|
|
54
|
+
if (showLoading) setLoading(false);
|
|
55
|
+
} else {
|
|
56
|
+
const diff = await getRawDiff(filename);
|
|
57
|
+
setRawDiff(diff);
|
|
58
|
+
setFileContent('');
|
|
59
|
+
setIsBinary(false);
|
|
60
|
+
if (showLoading) setLoading(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
loadContent(true);
|
|
66
|
+
}, [filename, status, repoPath]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!filename) return;
|
|
70
|
+
|
|
71
|
+
// Clear any existing interval to prevent duplicates
|
|
72
|
+
if (pollingIntervalRef.current) {
|
|
73
|
+
clearInterval(pollingIntervalRef.current);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pollingIntervalRef.current = setInterval(async () => {
|
|
77
|
+
await loadContent(false);
|
|
78
|
+
}, POLLING_INTERVAL);
|
|
79
|
+
|
|
80
|
+
// Cleanup function
|
|
81
|
+
return () => {
|
|
82
|
+
if (pollingIntervalRef.current) {
|
|
83
|
+
clearInterval(pollingIntervalRef.current);
|
|
84
|
+
pollingIntervalRef.current = null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}, [filename, status, repoPath]);
|
|
88
|
+
|
|
89
|
+
const diffData = useMemo(() => {
|
|
90
|
+
if (!rawDiff) return null;
|
|
91
|
+
const parsed = Diff2Html.parse(rawDiff, {
|
|
92
|
+
matching: 'lines'
|
|
93
|
+
});
|
|
94
|
+
return parsed[0] || null;
|
|
95
|
+
}, [rawDiff]);
|
|
96
|
+
|
|
97
|
+
useKeyboard((key) => {
|
|
98
|
+
if (!focused || !scrollRef.current) return;
|
|
99
|
+
|
|
100
|
+
if (key.name === 'up') {
|
|
101
|
+
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 1);
|
|
102
|
+
} else if (key.name === 'down') {
|
|
103
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 1;
|
|
104
|
+
} else if (key.name === 'pageup') {
|
|
105
|
+
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 10);
|
|
106
|
+
} else if (key.name === 'pagedown') {
|
|
107
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 10;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const hasChanges = diffData && diffData.blocks.length > 0;
|
|
112
|
+
const isNewFile = status === 'new';
|
|
113
|
+
const contentLines = useMemo(() => {
|
|
114
|
+
if (!fileContent) return [];
|
|
115
|
+
return fileContent.split('\n');
|
|
116
|
+
}, [fileContent]);
|
|
117
|
+
|
|
118
|
+
// Soft wrap lines at approximately 80 characters
|
|
119
|
+
const MAX_LINE_WIDTH = 80;
|
|
120
|
+
|
|
121
|
+
const wrapLine = (text: string, maxLength: number): string[] => {
|
|
122
|
+
if (text.length <= maxLength) return [text];
|
|
123
|
+
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
let remaining = text;
|
|
126
|
+
|
|
127
|
+
while (remaining.length > 0) {
|
|
128
|
+
if (remaining.length <= maxLength) {
|
|
129
|
+
lines.push(remaining);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Find best break point (prefer spaces)
|
|
134
|
+
let breakIndex = maxLength;
|
|
135
|
+
const lastSpace = remaining.lastIndexOf(' ', maxLength);
|
|
136
|
+
|
|
137
|
+
if (lastSpace > maxLength * 0.5) {
|
|
138
|
+
breakIndex = lastSpace + 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push(remaining.substring(0, breakIndex));
|
|
142
|
+
remaining = remaining.substring(breakIndex);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return lines;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<box
|
|
150
|
+
border
|
|
151
|
+
title={` Diff (${filename || 'None'}) `}
|
|
152
|
+
width="70%"
|
|
153
|
+
borderColor={focused ? 'yellow' : 'grey'}
|
|
154
|
+
flexDirection="column"
|
|
155
|
+
>
|
|
156
|
+
{loading ? (
|
|
157
|
+
<text>Loading diff...</text>
|
|
158
|
+
) : !filename ? (
|
|
159
|
+
<text>Select a file to view changes.</text>
|
|
160
|
+
) : isBinary ? (
|
|
161
|
+
<text fg="gray">Binary file - content not displayed.</text>
|
|
162
|
+
) : isNewFile ? (
|
|
163
|
+
<scrollbox flexGrow={1} ref={scrollRef}>
|
|
164
|
+
{contentLines.flatMap((line, i) => {
|
|
165
|
+
const wrappedLines = wrapLine(line, MAX_LINE_WIDTH);
|
|
166
|
+
|
|
167
|
+
return wrappedLines.map((wrappedLine, wrapIdx) => {
|
|
168
|
+
const isFirstLine = wrapIdx === 0;
|
|
169
|
+
let renderedParts: React.ReactNode = wrappedLine;
|
|
170
|
+
|
|
171
|
+
if (searchQuery && line.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
172
|
+
// Sanitize search query for regex to prevent ReDoS attacks
|
|
173
|
+
const sanitizedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
174
|
+
const parts = wrappedLine.split(new RegExp(`(${sanitizedQuery})`, 'gi'));
|
|
175
|
+
renderedParts = parts.map((part, idx) =>
|
|
176
|
+
part.toLowerCase() === searchQuery.toLowerCase()
|
|
177
|
+
? <span key={idx} fg="black" bg="yellow">{part}</span>
|
|
178
|
+
: part
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<box key={`${i}-${wrapIdx}`} flexDirection="row" height={1}>
|
|
184
|
+
{isFirstLine ? (
|
|
185
|
+
<text fg="gray" width={6}>{`${String(i + 1).padStart(4)}: `}</text>
|
|
186
|
+
) : (
|
|
187
|
+
<text fg="gray" width={6}> </text>
|
|
188
|
+
)}
|
|
189
|
+
<text fg="green" flexGrow={1}>
|
|
190
|
+
{renderedParts}
|
|
191
|
+
</text>
|
|
192
|
+
</box>
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
})}
|
|
196
|
+
</scrollbox>
|
|
197
|
+
) : !hasChanges ? (
|
|
198
|
+
<text fg="gray">No changes detected.</text>
|
|
199
|
+
) : (
|
|
200
|
+
<scrollbox flexGrow={1} ref={scrollRef}>
|
|
201
|
+
{diffData.blocks.map((block, bIdx) => (
|
|
202
|
+
<box key={bIdx} flexDirection="column" marginBottom={1}>
|
|
203
|
+
<text fg="cyan">{` ${block.header}`}</text>
|
|
204
|
+
<text fg="cyan">{` ${block.header}`}</text>
|
|
205
|
+
{block.lines.map((line, lIdx) => {
|
|
206
|
+
let fg = 'white';
|
|
207
|
+
let prefix = ' ';
|
|
208
|
+
if (line.type === 'insert') { fg = 'green'; prefix = '+'; }
|
|
209
|
+
else if (line.type === 'delete') { fg = 'brightRed'; prefix = '-'; }
|
|
210
|
+
|
|
211
|
+
const ln = line.type === 'insert' ? line.newNumber : line.oldNumber;
|
|
212
|
+
const content = line.content.substring(1);
|
|
213
|
+
|
|
214
|
+
const lnText = ln ? `${String(ln).padStart(4)}: ` : ' ';
|
|
215
|
+
|
|
216
|
+
// Wrap content if it's too long
|
|
217
|
+
const wrappedContent = wrapLine(content, MAX_LINE_WIDTH);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<>
|
|
221
|
+
{wrappedContent.map((wrappedLine, wrapIdx) => {
|
|
222
|
+
const isFirstLine = wrapIdx === 0;
|
|
223
|
+
|
|
224
|
+
let renderedParts: React.ReactNode;
|
|
225
|
+
if (searchQuery && content.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
226
|
+
// Sanitize search query for regex to prevent ReDoS attacks
|
|
227
|
+
const sanitizedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
228
|
+
const parts = wrappedLine.split(new RegExp(`(${sanitizedQuery})`, 'gi'));
|
|
229
|
+
renderedParts = parts.map((part, i) =>
|
|
230
|
+
part.toLowerCase() === searchQuery.toLowerCase()
|
|
231
|
+
? <span key={i} fg="black" bg="yellow">{part}</span>
|
|
232
|
+
: part
|
|
233
|
+
);
|
|
234
|
+
} else {
|
|
235
|
+
renderedParts = wrappedLine;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<box key={`${bIdx}-${lIdx}-${wrapIdx}`} flexDirection="row" height={1}>
|
|
240
|
+
<text fg="gray" width={lnText.length}>
|
|
241
|
+
{isFirstLine ? lnText : ' '}
|
|
242
|
+
</text>
|
|
243
|
+
<text fg={fg} flexGrow={1}>
|
|
244
|
+
{isFirstLine ? `${prefix}${renderedParts}` : ` ${renderedParts}`}
|
|
245
|
+
</text>
|
|
246
|
+
</box>
|
|
247
|
+
);
|
|
248
|
+
})}
|
|
249
|
+
</>
|
|
250
|
+
);
|
|
251
|
+
})}
|
|
252
|
+
</box>
|
|
253
|
+
))}
|
|
254
|
+
</scrollbox>
|
|
255
|
+
)}
|
|
256
|
+
</box>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useRef, useEffect } from 'react';
|
|
2
|
+
import { type FileStatus } from '../utils/git';
|
|
3
|
+
|
|
4
|
+
interface FileListProps {
|
|
5
|
+
files: FileStatus[];
|
|
6
|
+
selectedIndex: number;
|
|
7
|
+
focused: boolean;
|
|
8
|
+
searchQuery?: string;
|
|
9
|
+
onSelect?: (index: number) => void;
|
|
10
|
+
onScroll?: (delta: number) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function FileList({ files, selectedIndex, focused, searchQuery, onSelect, onScroll }: FileListProps) {
|
|
14
|
+
const scrollRef = useRef<any>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (scrollRef.current) {
|
|
18
|
+
const viewportHeight = scrollRef.current.viewport?.height || 10;
|
|
19
|
+
const scrollTop = scrollRef.current.scrollTop;
|
|
20
|
+
|
|
21
|
+
if (selectedIndex < scrollTop) {
|
|
22
|
+
scrollRef.current.scrollTop = selectedIndex;
|
|
23
|
+
} else if (selectedIndex >= scrollTop + viewportHeight) {
|
|
24
|
+
scrollRef.current.scrollTop = selectedIndex - viewportHeight + 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}, [selectedIndex]);
|
|
28
|
+
|
|
29
|
+
const title = searchQuery
|
|
30
|
+
? ` Files (${files.length}) - "${searchQuery}" `
|
|
31
|
+
: ` Files (${files.length}) `;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<box
|
|
35
|
+
border
|
|
36
|
+
title={title}
|
|
37
|
+
width="30%"
|
|
38
|
+
height="100%"
|
|
39
|
+
borderColor={focused ? 'yellow' : 'grey'}
|
|
40
|
+
flexDirection="column"
|
|
41
|
+
>
|
|
42
|
+
<scrollbox
|
|
43
|
+
flexGrow={1}
|
|
44
|
+
ref={scrollRef}
|
|
45
|
+
onMouseScroll={(e: any) => {
|
|
46
|
+
// OpenTUI MouseButton: WHEEL_UP = 4, WHEEL_DOWN = 5
|
|
47
|
+
const delta = e.button === 4 ? -1 : (e.button === 5 ? 1 : 0);
|
|
48
|
+
if (delta !== 0) onScroll?.(delta);
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{files.map((file, i) => {
|
|
52
|
+
const isSelected = i === selectedIndex;
|
|
53
|
+
const colorMap: Record<string, string> = {
|
|
54
|
+
modified: 'yellow',
|
|
55
|
+
new: 'green',
|
|
56
|
+
deleted: 'brightRed',
|
|
57
|
+
renamed: 'blue',
|
|
58
|
+
unstaged: 'cyan',
|
|
59
|
+
unmerged: 'magenta',
|
|
60
|
+
ignored: 'grey',
|
|
61
|
+
unknown: 'white'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const color = colorMap[file.status] || 'white';
|
|
65
|
+
const symbolMap: Record<string, string> = {
|
|
66
|
+
modified: 'M',
|
|
67
|
+
new: 'A',
|
|
68
|
+
deleted: 'D',
|
|
69
|
+
renamed: 'R',
|
|
70
|
+
unstaged: 'M',
|
|
71
|
+
unmerged: 'U',
|
|
72
|
+
ignored: '!',
|
|
73
|
+
unknown: '?'
|
|
74
|
+
};
|
|
75
|
+
const symbol = symbolMap[file.status] || '?';
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<box
|
|
79
|
+
key={file.path}
|
|
80
|
+
height={1}
|
|
81
|
+
backgroundColor={isSelected ? 'white' : undefined}
|
|
82
|
+
onMouseDown={() => onSelect?.(i)}
|
|
83
|
+
>
|
|
84
|
+
<text
|
|
85
|
+
fg={isSelected ? 'black' : color}
|
|
86
|
+
>
|
|
87
|
+
{` ${symbol} ${file.path}`}
|
|
88
|
+
</text>
|
|
89
|
+
</box>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</scrollbox>
|
|
93
|
+
</box>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useKeyboard } from '@opentui/react';
|
|
3
|
+
import type { CommitInfo, ChangedFile } from '../utils/git';
|
|
4
|
+
import { getFilesInCommit } from '../utils/git';
|
|
5
|
+
|
|
6
|
+
interface HistoryViewerProps {
|
|
7
|
+
commits: CommitInfo[];
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function HistoryViewer({ commits, onClose }: HistoryViewerProps) {
|
|
12
|
+
const [selectedRow, setSelectedRow] = useState(0);
|
|
13
|
+
const [showFileDialog, setShowFileDialog] = useState(false);
|
|
14
|
+
const [fileDialogFiles, setFileDialogFiles] = useState<ChangedFile[]>([]);
|
|
15
|
+
const [loadingFiles, setLoadingFiles] = useState(false);
|
|
16
|
+
|
|
17
|
+
const scrollRef = useRef<any>(null);
|
|
18
|
+
|
|
19
|
+
useKeyboard((key) => {
|
|
20
|
+
if (key.name === 'escape') {
|
|
21
|
+
if (showFileDialog) {
|
|
22
|
+
// Close file dialog if open
|
|
23
|
+
setShowFileDialog(false);
|
|
24
|
+
} else {
|
|
25
|
+
onClose();
|
|
26
|
+
}
|
|
27
|
+
} else if (key.name === 'space' && commits.length > 0) {
|
|
28
|
+
// Show file dialog for selected commit (Space)
|
|
29
|
+
const commit = commits[selectedRow];
|
|
30
|
+
if (commit) {
|
|
31
|
+
setLoadingFiles(true);
|
|
32
|
+
getFilesInCommit(commit.hash).then(files => {
|
|
33
|
+
setFileDialogFiles(files);
|
|
34
|
+
setShowFileDialog(true);
|
|
35
|
+
setLoadingFiles(false);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} else if (scrollRef.current) {
|
|
39
|
+
if (key.name === 'up') {
|
|
40
|
+
if (showFileDialog) return; // Don't navigate rows when dialog is open
|
|
41
|
+
setSelectedRow(prev => Math.max(0, prev - 1));
|
|
42
|
+
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 1);
|
|
43
|
+
} else if (key.name === 'down') {
|
|
44
|
+
if (showFileDialog) return; // Don't navigate rows when dialog is open
|
|
45
|
+
setSelectedRow(prev => Math.min(commits.length - 1, prev + 1));
|
|
46
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 1;
|
|
47
|
+
} else if (key.name === 'pageup') {
|
|
48
|
+
if (showFileDialog) return; // Don't navigate rows when dialog is open
|
|
49
|
+
const newRow = Math.max(0, selectedRow - 10);
|
|
50
|
+
setSelectedRow(newRow);
|
|
51
|
+
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 10);
|
|
52
|
+
} else if (key.name === 'pagedown') {
|
|
53
|
+
if (showFileDialog) return; // Don't navigate rows when dialog is open
|
|
54
|
+
const newRow = Math.min(commits.length - 1, selectedRow + 10);
|
|
55
|
+
setSelectedRow(newRow);
|
|
56
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 10;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const formatMessage = (message: string, maxLength: number = 60): string => {
|
|
62
|
+
if (message.length <= maxLength) {
|
|
63
|
+
return message;
|
|
64
|
+
}
|
|
65
|
+
return message.substring(0, maxLength - 3) + '...';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const getStatusSymbol = (status: ChangedFile['status']): string => {
|
|
69
|
+
// Return the first character of the Git status code directly
|
|
70
|
+
return status.charAt(0) || '~';
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<box
|
|
75
|
+
position="absolute"
|
|
76
|
+
top={0}
|
|
77
|
+
left={0}
|
|
78
|
+
width="100%"
|
|
79
|
+
height="100%"
|
|
80
|
+
backgroundColor="black"
|
|
81
|
+
flexDirection="column"
|
|
82
|
+
>
|
|
83
|
+
<box height={1} backgroundColor="gray" flexDirection="row">
|
|
84
|
+
<text width={10} fg="white"><strong> Hash</strong></text>
|
|
85
|
+
<text width={25} fg="white"><strong>Author</strong></text>
|
|
86
|
+
<text width={20} fg="white"><strong>Date</strong></text>
|
|
87
|
+
<text fg="white" flexGrow={1}><strong>Message</strong></text>
|
|
88
|
+
</box>
|
|
89
|
+
<scrollbox flexGrow={1} ref={scrollRef}>
|
|
90
|
+
{commits.length === 0 ? (
|
|
91
|
+
<text fg="gray">No commit history found.</text>
|
|
92
|
+
) : (
|
|
93
|
+
commits.map((commit, index) => (
|
|
94
|
+
<box
|
|
95
|
+
key={`${commit.hash}-${index}`}
|
|
96
|
+
flexDirection="row"
|
|
97
|
+
height={1}
|
|
98
|
+
backgroundColor={index === selectedRow ? "blue" : "black"}
|
|
99
|
+
onMouseDown={() => {
|
|
100
|
+
// If file dialog is open, close it first
|
|
101
|
+
if (showFileDialog) {
|
|
102
|
+
setShowFileDialog(false);
|
|
103
|
+
} else {
|
|
104
|
+
// Otherwise, select the row
|
|
105
|
+
setSelectedRow(index);
|
|
106
|
+
}
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<text width={10} fg="cyan"> {commit.hash}</text>
|
|
110
|
+
<text width={25} fg={index === selectedRow ? "white" : "green"}>{commit.author.substring(0, 24)}</text>
|
|
111
|
+
<text width={20} fg={index === selectedRow ? "white" : "yellow"}>{commit.date.substring(0, 19)}</text>
|
|
112
|
+
<text fg={index === selectedRow ? "white" : "white"} flexGrow={1}>{formatMessage(commit.message)}</text>
|
|
113
|
+
</box>
|
|
114
|
+
))
|
|
115
|
+
)}
|
|
116
|
+
</scrollbox>
|
|
117
|
+
<box height={1} backgroundColor="gray" flexDirection="row" justifyContent="center">
|
|
118
|
+
<text fg="yellow">
|
|
119
|
+
<strong>↑↓</strong> Navigate | <strong>PgUp/PgDn</strong> Page | <strong>Space</strong> Show Files | <strong>Esc</strong> Close
|
|
120
|
+
</text>
|
|
121
|
+
</box>
|
|
122
|
+
|
|
123
|
+
{/* File Dialog */}
|
|
124
|
+
{showFileDialog && (
|
|
125
|
+
<box
|
|
126
|
+
position="absolute"
|
|
127
|
+
top="20%"
|
|
128
|
+
left="20%"
|
|
129
|
+
width="60%"
|
|
130
|
+
height="60%"
|
|
131
|
+
backgroundColor="black"
|
|
132
|
+
border
|
|
133
|
+
flexDirection="column"
|
|
134
|
+
title={` Files Changed in ${commits[selectedRow]?.hash || ''} `}
|
|
135
|
+
>
|
|
136
|
+
{loadingFiles ? (
|
|
137
|
+
<text>Loading files...</text>
|
|
138
|
+
) : fileDialogFiles.length === 0 ? (
|
|
139
|
+
<text>No files changed in this commit.</text>
|
|
140
|
+
) : (
|
|
141
|
+
<>
|
|
142
|
+
<scrollbox flexGrow={1}>
|
|
143
|
+
{fileDialogFiles.map((file, index) => (
|
|
144
|
+
<box key={index} flexDirection="row" height={1}>
|
|
145
|
+
<text width={8} fg={
|
|
146
|
+
file.status.charAt(0) === 'A' ? 'green' : // Added
|
|
147
|
+
file.status.charAt(0) === 'D' ? 'red' : // Deleted
|
|
148
|
+
file.status.charAt(0) === 'R' ? 'yellow' : // Renamed
|
|
149
|
+
file.status.charAt(0) === 'C' ? 'blue' : // Copied
|
|
150
|
+
file.status.charAt(0) === 'T' ? 'magenta' : // Type change
|
|
151
|
+
'cyan' // Modified or other
|
|
152
|
+
}>
|
|
153
|
+
{' '}{file.status.charAt(0)} {/* Show 2 spaces padding before the raw Git status code */}
|
|
154
|
+
</text>
|
|
155
|
+
<text fg="white" flexGrow={1}> {file.path}</text>
|
|
156
|
+
</box>
|
|
157
|
+
))}
|
|
158
|
+
</scrollbox>
|
|
159
|
+
<box height={1} backgroundColor="gray" flexDirection="row" justifyContent="center">
|
|
160
|
+
<text fg="yellow">
|
|
161
|
+
<strong>Esc</strong> Close
|
|
162
|
+
</text>
|
|
163
|
+
</box>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
</box>
|
|
167
|
+
)}
|
|
168
|
+
</box>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface StatusBarProps {
|
|
2
|
+
branch: string;
|
|
3
|
+
branchCount: number;
|
|
4
|
+
fileCount: number;
|
|
5
|
+
searchActive?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function StatusBar({ branch, branchCount, fileCount, searchActive = false }: StatusBarProps) {
|
|
9
|
+
// Show file-related options if either there are files OR search is active
|
|
10
|
+
const showFileOptions = fileCount > 0 || searchActive;
|
|
11
|
+
return (
|
|
12
|
+
<box height={1} flexDirection="row" justifyContent="space-between">
|
|
13
|
+
<text>
|
|
14
|
+
<span fg="green">←→</span> Switch
|
|
15
|
+
{showFileOptions && (
|
|
16
|
+
<>
|
|
17
|
+
{' '}| <span fg="green">⏎</span> Open | <span fg="green">S</span> Search | <span fg="green">R</span> Revert | <span fg="green">D</span> Delete
|
|
18
|
+
</>
|
|
19
|
+
)}
|
|
20
|
+
{' '}| <span fg="green">H</span> History | <span fg="green">Q</span> Quit
|
|
21
|
+
</text>
|
|
22
|
+
<text>
|
|
23
|
+
<span fg="cyan">Branch:</span> <span fg="yellow">{branch}</span> <span fg="gray">({branchCount})</span>
|
|
24
|
+
</text>
|
|
25
|
+
</box>
|
|
26
|
+
);
|
|
27
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const POLLING_INTERVAL = 2000;
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createCliRenderer } from '@opentui/core';
|
|
2
|
+
import { createRoot } from '@opentui/react';
|
|
3
|
+
import App from './App';
|
|
4
|
+
import { isGitRepository } from './utils/git';
|
|
5
|
+
import { BUILD_VERSION } from './version';
|
|
6
|
+
|
|
7
|
+
function getVersion(): string {
|
|
8
|
+
return BUILD_VERSION || 'unknown';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function showHelp(): void {
|
|
12
|
+
const version = getVersion();
|
|
13
|
+
console.log(`DiffWatch v${version}\n`);
|
|
14
|
+
console.log('A TUI app for watching git repository file changes with diffs.\n');
|
|
15
|
+
console.log('USAGE:');
|
|
16
|
+
console.log(' diffwatch [OPTIONS]\n');
|
|
17
|
+
console.log('OPTIONS:');
|
|
18
|
+
console.log(' --path <path> Specify git repository path to watch');
|
|
19
|
+
console.log(' -p <path> Same as --path');
|
|
20
|
+
console.log(' --help, -h Show this help message');
|
|
21
|
+
console.log(' --version, -v Show version number\n');
|
|
22
|
+
console.log('KEYBOARD SHORTCUTS:');
|
|
23
|
+
console.log(' ↑/↓ Navigate file list');
|
|
24
|
+
console.log(' Tab/←/→ Switch between file list and diff view');
|
|
25
|
+
console.log(' Enter Open selected file in default editor');
|
|
26
|
+
console.log(' D Delete selected file');
|
|
27
|
+
console.log(' R Revert changes to selected file');
|
|
28
|
+
console.log(' S Enter search mode');
|
|
29
|
+
console.log(' H View commit history');
|
|
30
|
+
console.log(' Q Quit application\n');
|
|
31
|
+
console.log('For more information, visit https://github.com/sarfraznawaz2005/diffwatch');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function showVersion(): void {
|
|
36
|
+
const version = getVersion();
|
|
37
|
+
console.log(`DiffWatch v${version}`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(): { path?: string } {
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const result: { path?: string } = {};
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
const arg = args[i];
|
|
47
|
+
|
|
48
|
+
if (arg === '--help' || arg === '-h') {
|
|
49
|
+
showHelp();
|
|
50
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
51
|
+
showVersion();
|
|
52
|
+
} else if (arg === '--path' || arg === '-p') {
|
|
53
|
+
if (i + 1 < args.length) {
|
|
54
|
+
result.path = args[i + 1];
|
|
55
|
+
i++;
|
|
56
|
+
} else {
|
|
57
|
+
console.error('Error: --path requires a path argument');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
console.error(`Error: Unknown option '${arg}'`);
|
|
62
|
+
console.error('Use --help for usage information');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function main() {
|
|
71
|
+
const { path: customPath } = parseArgs();
|
|
72
|
+
|
|
73
|
+
if (customPath) {
|
|
74
|
+
try {
|
|
75
|
+
process.chdir(customPath);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(`Error: Invalid path '${customPath}'`);
|
|
78
|
+
console.error('The specified directory does not exist.');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if current directory is a git repository BEFORE creating renderer
|
|
84
|
+
const isGitRepo = await isGitRepository();
|
|
85
|
+
|
|
86
|
+
if (!isGitRepo) {
|
|
87
|
+
const pathInfo = customPath ? `\nSpecified path: ${customPath}` : '';
|
|
88
|
+
const errorMessage = [
|
|
89
|
+
'Error: Not a git repository',
|
|
90
|
+
'',
|
|
91
|
+
'Please run this application from within a git repository.',
|
|
92
|
+
pathInfo
|
|
93
|
+
].filter(Boolean).join('\n') + '\n';
|
|
94
|
+
|
|
95
|
+
console.error(errorMessage);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const renderer = await createCliRenderer();
|
|
100
|
+
const root = createRoot(renderer);
|
|
101
|
+
|
|
102
|
+
root.render(<App />);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
main().catch(console.error);
|
|
106
|
+
|