diffwatch 2.0.9 → 2.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/package.json +1 -1
- package/src/components/DiffViewer.tsx +271 -271
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
|
2
|
-
import { useKeyboard, useRenderer } 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
|
-
const renderer = useRenderer();
|
|
26
|
-
|
|
27
|
-
const loadContent = async (showLoading = true) => {
|
|
28
|
-
if (!filename) {
|
|
29
|
-
setRawDiff('');
|
|
30
|
-
setFileContent('');
|
|
31
|
-
setIsBinary(false);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (showLoading) setLoading(true);
|
|
36
|
-
|
|
37
|
-
if (status === 'new' || status === 'unchanged') {
|
|
38
|
-
try {
|
|
39
|
-
const absPath = path.isAbsolute(filename) ? filename : path.join(repoPath || process.cwd(), filename);
|
|
40
|
-
const content = await fsPromises.readFile(absPath, 'utf-8');
|
|
41
|
-
const binary = await isBinaryFile(absPath);
|
|
42
|
-
if (binary) {
|
|
43
|
-
setFileContent('');
|
|
44
|
-
setRawDiff('');
|
|
45
|
-
setIsBinary(true);
|
|
46
|
-
} else {
|
|
47
|
-
setFileContent(content);
|
|
48
|
-
setRawDiff('');
|
|
49
|
-
setIsBinary(false);
|
|
50
|
-
}
|
|
51
|
-
} catch (e) {
|
|
52
|
-
setFileContent('');
|
|
53
|
-
setIsBinary(false);
|
|
54
|
-
}
|
|
55
|
-
if (showLoading) setLoading(false);
|
|
56
|
-
} else {
|
|
57
|
-
const diff = await getRawDiff(filename);
|
|
58
|
-
setRawDiff(diff);
|
|
59
|
-
setFileContent('');
|
|
60
|
-
setIsBinary(false);
|
|
61
|
-
if (showLoading) setLoading(false);
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
loadContent(true);
|
|
67
|
-
}, [filename, status, repoPath]);
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
if (!filename) return;
|
|
71
|
-
|
|
72
|
-
// Clear any existing interval to prevent duplicates
|
|
73
|
-
if (pollingIntervalRef.current) {
|
|
74
|
-
clearInterval(pollingIntervalRef.current);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
pollingIntervalRef.current = setInterval(async () => {
|
|
78
|
-
await loadContent(false);
|
|
79
|
-
}, POLLING_INTERVAL);
|
|
80
|
-
|
|
81
|
-
// Cleanup function
|
|
82
|
-
return () => {
|
|
83
|
-
if (pollingIntervalRef.current) {
|
|
84
|
-
clearInterval(pollingIntervalRef.current);
|
|
85
|
-
pollingIntervalRef.current = null;
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
}, [filename, status, repoPath]);
|
|
89
|
-
|
|
90
|
-
const diffData = useMemo(() => {
|
|
91
|
-
if (!rawDiff) return null;
|
|
92
|
-
const parsed = Diff2Html.parse(rawDiff, {
|
|
93
|
-
matching: 'lines'
|
|
94
|
-
});
|
|
95
|
-
return parsed[0] || null;
|
|
96
|
-
}, [rawDiff]);
|
|
97
|
-
|
|
98
|
-
useKeyboard((key) => {
|
|
99
|
-
if (!focused || !scrollRef.current) return;
|
|
100
|
-
|
|
101
|
-
if (key.name === 'up') {
|
|
102
|
-
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 1);
|
|
103
|
-
} else if (key.name === 'down') {
|
|
104
|
-
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 1;
|
|
105
|
-
} else if (key.name === 'pageup') {
|
|
106
|
-
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 10);
|
|
107
|
-
} else if (key.name === 'pagedown') {
|
|
108
|
-
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 10;
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
const hasChanges = diffData && diffData.blocks.length > 0;
|
|
113
|
-
const isNewFile = status === 'new';
|
|
114
|
-
const contentLines = useMemo(() => {
|
|
115
|
-
if (!fileContent) return [];
|
|
116
|
-
return fileContent.split('\n');
|
|
117
|
-
}, [fileContent]);
|
|
118
|
-
|
|
119
|
-
// Calculate the max line width based on available width in the diff viewer
|
|
120
|
-
// The diff viewer takes up 70% of the total width, and we need to account for:
|
|
121
|
-
// - Line numbers (6 chars: " 1234: ")
|
|
122
|
-
// - Prefix (+/-/space) (1 char)
|
|
123
|
-
// - Borders (2 chars: left and right)
|
|
124
|
-
// - Some padding (1 char)
|
|
125
|
-
// Total overhead: 6 (line numbers) + 1 (prefix) + 2 (borders) = 9 chars
|
|
126
|
-
const calculateMaxLineWidth = (): number => {
|
|
127
|
-
const estimatedWidth = Math.floor((renderer.width * 0.70) - 9); // 9 chars for line numbers, prefix, and borders
|
|
128
|
-
return Math.max(20, estimatedWidth); // minimum width of 20
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
// Soft wrap lines based on available width
|
|
132
|
-
const wrapLine = (text: string, maxLength: number): string[] => {
|
|
133
|
-
if (text.length <= maxLength) return [text];
|
|
134
|
-
|
|
135
|
-
const lines: string[] = [];
|
|
136
|
-
let remaining = text;
|
|
137
|
-
|
|
138
|
-
while (remaining.length > 0) {
|
|
139
|
-
if (remaining.length <= maxLength) {
|
|
140
|
-
lines.push(remaining);
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Find best break point (prefer spaces)
|
|
145
|
-
let breakIndex = maxLength;
|
|
146
|
-
const lastSpace = remaining.lastIndexOf(' ', maxLength);
|
|
147
|
-
|
|
148
|
-
if (lastSpace > maxLength * 0.5) {
|
|
149
|
-
breakIndex = lastSpace + 1;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
lines.push(remaining.substring(0, breakIndex));
|
|
153
|
-
remaining = remaining.substring(breakIndex);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return lines;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const maxLineWidth = calculateMaxLineWidth();
|
|
160
|
-
|
|
161
|
-
return (
|
|
162
|
-
<box
|
|
163
|
-
border
|
|
164
|
-
title={` Diff (${filename || 'None'}) `}
|
|
165
|
-
width="70%"
|
|
166
|
-
borderColor={focused ? 'yellow' : 'grey'}
|
|
167
|
-
flexDirection="column"
|
|
168
|
-
>
|
|
169
|
-
{loading ? (
|
|
170
|
-
<text>Loading diff...</text>
|
|
171
|
-
) : !filename ? (
|
|
172
|
-
<text>Select a file to view changes.</text>
|
|
173
|
-
) : isBinary ? (
|
|
174
|
-
<text fg="gray">Binary file - content not displayed.</text>
|
|
175
|
-
) : isNewFile ? (
|
|
176
|
-
<scrollbox flexGrow={1} ref={scrollRef}>
|
|
177
|
-
{contentLines.flatMap((line, i) => {
|
|
178
|
-
const wrappedLines = wrapLine(line, maxLineWidth);
|
|
179
|
-
|
|
180
|
-
return wrappedLines.map((wrappedLine, wrapIdx) => {
|
|
181
|
-
const isFirstLine = wrapIdx === 0;
|
|
182
|
-
let renderedParts: React.ReactNode = wrappedLine;
|
|
183
|
-
|
|
184
|
-
if (searchQuery && line.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
185
|
-
// Sanitize search query for regex to prevent ReDoS attacks
|
|
186
|
-
const sanitizedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
187
|
-
const parts = wrappedLine.split(new RegExp(`(${sanitizedQuery})`, 'gi'));
|
|
188
|
-
renderedParts = parts.map((part, idx) =>
|
|
189
|
-
part.toLowerCase() === searchQuery.toLowerCase()
|
|
190
|
-
? <span key={idx} fg="black" bg="yellow">{part}</span>
|
|
191
|
-
: part
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return (
|
|
196
|
-
<box key={`${i}-${wrapIdx}`} flexDirection="row" height={1}>
|
|
197
|
-
{isFirstLine ? (
|
|
198
|
-
<text fg="gray" width={6}>{`${String(i + 1).padStart(4)}: `}</text>
|
|
199
|
-
) : (
|
|
200
|
-
<text fg="gray" width={6}> </text>
|
|
201
|
-
)}
|
|
202
|
-
<text fg="
|
|
203
|
-
{renderedParts}
|
|
204
|
-
</text>
|
|
205
|
-
</box>
|
|
206
|
-
);
|
|
207
|
-
});
|
|
208
|
-
})}
|
|
209
|
-
</scrollbox>
|
|
210
|
-
) : !hasChanges ? (
|
|
211
|
-
<text fg="gray">No changes detected.</text>
|
|
212
|
-
) : (
|
|
213
|
-
<scrollbox flexGrow={1} ref={scrollRef}>
|
|
214
|
-
{diffData.blocks.map((block, bIdx) => (
|
|
215
|
-
<box key={bIdx} flexDirection="column" marginBottom={1}>
|
|
216
|
-
<text fg="cyan">{` ${block.header}`}</text>
|
|
217
|
-
<text fg="cyan">{` ${block.header}`}</text>
|
|
218
|
-
{block.lines.map((line, lIdx) => {
|
|
219
|
-
let fg = 'white';
|
|
220
|
-
let prefix = ' ';
|
|
221
|
-
if (line.type === 'insert') { fg = '
|
|
222
|
-
else if (line.type === 'delete') { fg = 'brightRed'; prefix = '-'; }
|
|
223
|
-
|
|
224
|
-
const ln = line.type === 'insert' ? line.newNumber : line.oldNumber;
|
|
225
|
-
const content = line.content.substring(1);
|
|
226
|
-
|
|
227
|
-
const lnText = ln ? `${String(ln).padStart(4)}: ` : ' ';
|
|
228
|
-
|
|
229
|
-
// Wrap content if it's too long
|
|
230
|
-
const wrappedContent = wrapLine(content, maxLineWidth);
|
|
231
|
-
|
|
232
|
-
return (
|
|
233
|
-
<>
|
|
234
|
-
{wrappedContent.map((wrappedLine, wrapIdx) => {
|
|
235
|
-
const isFirstLine = wrapIdx === 0;
|
|
236
|
-
|
|
237
|
-
let renderedParts: React.ReactNode;
|
|
238
|
-
if (searchQuery && content.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
239
|
-
// Sanitize search query for regex to prevent ReDoS attacks
|
|
240
|
-
const sanitizedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
241
|
-
const parts = wrappedLine.split(new RegExp(`(${sanitizedQuery})`, 'gi'));
|
|
242
|
-
renderedParts = parts.map((part, i) =>
|
|
243
|
-
part.toLowerCase() === searchQuery.toLowerCase()
|
|
244
|
-
? <span key={i} fg="black" bg="yellow">{part}</span>
|
|
245
|
-
: part
|
|
246
|
-
);
|
|
247
|
-
} else {
|
|
248
|
-
renderedParts = wrappedLine;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return (
|
|
252
|
-
<box key={`${bIdx}-${lIdx}-${wrapIdx}`} flexDirection="row" height={1}>
|
|
253
|
-
<text fg="gray" width={lnText.length}>
|
|
254
|
-
{isFirstLine ? lnText : ' '}
|
|
255
|
-
</text>
|
|
256
|
-
<text fg={fg} flexGrow={1}>
|
|
257
|
-
{isFirstLine ? `${prefix}${renderedParts}` : ` ${renderedParts}`}
|
|
258
|
-
</text>
|
|
259
|
-
</box>
|
|
260
|
-
);
|
|
261
|
-
})}
|
|
262
|
-
</>
|
|
263
|
-
);
|
|
264
|
-
})}
|
|
265
|
-
</box>
|
|
266
|
-
))}
|
|
267
|
-
</scrollbox>
|
|
268
|
-
)}
|
|
269
|
-
</box>
|
|
270
|
-
);
|
|
271
|
-
}
|
|
1
|
+
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
|
2
|
+
import { useKeyboard, useRenderer } 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
|
+
const renderer = useRenderer();
|
|
26
|
+
|
|
27
|
+
const loadContent = async (showLoading = true) => {
|
|
28
|
+
if (!filename) {
|
|
29
|
+
setRawDiff('');
|
|
30
|
+
setFileContent('');
|
|
31
|
+
setIsBinary(false);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (showLoading) setLoading(true);
|
|
36
|
+
|
|
37
|
+
if (status === 'new' || status === 'unchanged') {
|
|
38
|
+
try {
|
|
39
|
+
const absPath = path.isAbsolute(filename) ? filename : path.join(repoPath || process.cwd(), filename);
|
|
40
|
+
const content = await fsPromises.readFile(absPath, 'utf-8');
|
|
41
|
+
const binary = await isBinaryFile(absPath);
|
|
42
|
+
if (binary) {
|
|
43
|
+
setFileContent('');
|
|
44
|
+
setRawDiff('');
|
|
45
|
+
setIsBinary(true);
|
|
46
|
+
} else {
|
|
47
|
+
setFileContent(content);
|
|
48
|
+
setRawDiff('');
|
|
49
|
+
setIsBinary(false);
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
setFileContent('');
|
|
53
|
+
setIsBinary(false);
|
|
54
|
+
}
|
|
55
|
+
if (showLoading) setLoading(false);
|
|
56
|
+
} else {
|
|
57
|
+
const diff = await getRawDiff(filename);
|
|
58
|
+
setRawDiff(diff);
|
|
59
|
+
setFileContent('');
|
|
60
|
+
setIsBinary(false);
|
|
61
|
+
if (showLoading) setLoading(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
loadContent(true);
|
|
67
|
+
}, [filename, status, repoPath]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!filename) return;
|
|
71
|
+
|
|
72
|
+
// Clear any existing interval to prevent duplicates
|
|
73
|
+
if (pollingIntervalRef.current) {
|
|
74
|
+
clearInterval(pollingIntervalRef.current);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pollingIntervalRef.current = setInterval(async () => {
|
|
78
|
+
await loadContent(false);
|
|
79
|
+
}, POLLING_INTERVAL);
|
|
80
|
+
|
|
81
|
+
// Cleanup function
|
|
82
|
+
return () => {
|
|
83
|
+
if (pollingIntervalRef.current) {
|
|
84
|
+
clearInterval(pollingIntervalRef.current);
|
|
85
|
+
pollingIntervalRef.current = null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}, [filename, status, repoPath]);
|
|
89
|
+
|
|
90
|
+
const diffData = useMemo(() => {
|
|
91
|
+
if (!rawDiff) return null;
|
|
92
|
+
const parsed = Diff2Html.parse(rawDiff, {
|
|
93
|
+
matching: 'lines'
|
|
94
|
+
});
|
|
95
|
+
return parsed[0] || null;
|
|
96
|
+
}, [rawDiff]);
|
|
97
|
+
|
|
98
|
+
useKeyboard((key) => {
|
|
99
|
+
if (!focused || !scrollRef.current) return;
|
|
100
|
+
|
|
101
|
+
if (key.name === 'up') {
|
|
102
|
+
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 1);
|
|
103
|
+
} else if (key.name === 'down') {
|
|
104
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 1;
|
|
105
|
+
} else if (key.name === 'pageup') {
|
|
106
|
+
scrollRef.current.scrollTop = Math.max(0, scrollRef.current.scrollTop - 10);
|
|
107
|
+
} else if (key.name === 'pagedown') {
|
|
108
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollTop + 10;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const hasChanges = diffData && diffData.blocks.length > 0;
|
|
113
|
+
const isNewFile = status === 'new';
|
|
114
|
+
const contentLines = useMemo(() => {
|
|
115
|
+
if (!fileContent) return [];
|
|
116
|
+
return fileContent.split('\n');
|
|
117
|
+
}, [fileContent]);
|
|
118
|
+
|
|
119
|
+
// Calculate the max line width based on available width in the diff viewer
|
|
120
|
+
// The diff viewer takes up 70% of the total width, and we need to account for:
|
|
121
|
+
// - Line numbers (6 chars: " 1234: ")
|
|
122
|
+
// - Prefix (+/-/space) (1 char)
|
|
123
|
+
// - Borders (2 chars: left and right)
|
|
124
|
+
// - Some padding (1 char)
|
|
125
|
+
// Total overhead: 6 (line numbers) + 1 (prefix) + 2 (borders) = 9 chars
|
|
126
|
+
const calculateMaxLineWidth = (): number => {
|
|
127
|
+
const estimatedWidth = Math.floor((renderer.width * 0.70) - 9); // 9 chars for line numbers, prefix, and borders
|
|
128
|
+
return Math.max(20, estimatedWidth); // minimum width of 20
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Soft wrap lines based on available width
|
|
132
|
+
const wrapLine = (text: string, maxLength: number): string[] => {
|
|
133
|
+
if (text.length <= maxLength) return [text];
|
|
134
|
+
|
|
135
|
+
const lines: string[] = [];
|
|
136
|
+
let remaining = text;
|
|
137
|
+
|
|
138
|
+
while (remaining.length > 0) {
|
|
139
|
+
if (remaining.length <= maxLength) {
|
|
140
|
+
lines.push(remaining);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Find best break point (prefer spaces)
|
|
145
|
+
let breakIndex = maxLength;
|
|
146
|
+
const lastSpace = remaining.lastIndexOf(' ', maxLength);
|
|
147
|
+
|
|
148
|
+
if (lastSpace > maxLength * 0.5) {
|
|
149
|
+
breakIndex = lastSpace + 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lines.push(remaining.substring(0, breakIndex));
|
|
153
|
+
remaining = remaining.substring(breakIndex);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lines;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const maxLineWidth = calculateMaxLineWidth();
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<box
|
|
163
|
+
border
|
|
164
|
+
title={` Diff (${filename || 'None'}) `}
|
|
165
|
+
width="70%"
|
|
166
|
+
borderColor={focused ? 'yellow' : 'grey'}
|
|
167
|
+
flexDirection="column"
|
|
168
|
+
>
|
|
169
|
+
{loading ? (
|
|
170
|
+
<text>Loading diff...</text>
|
|
171
|
+
) : !filename ? (
|
|
172
|
+
<text>Select a file to view changes.</text>
|
|
173
|
+
) : isBinary ? (
|
|
174
|
+
<text fg="gray">Binary file - content not displayed.</text>
|
|
175
|
+
) : isNewFile ? (
|
|
176
|
+
<scrollbox flexGrow={1} ref={scrollRef}>
|
|
177
|
+
{contentLines.flatMap((line, i) => {
|
|
178
|
+
const wrappedLines = wrapLine(line, maxLineWidth);
|
|
179
|
+
|
|
180
|
+
return wrappedLines.map((wrappedLine, wrapIdx) => {
|
|
181
|
+
const isFirstLine = wrapIdx === 0;
|
|
182
|
+
let renderedParts: React.ReactNode = wrappedLine;
|
|
183
|
+
|
|
184
|
+
if (searchQuery && line.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
185
|
+
// Sanitize search query for regex to prevent ReDoS attacks
|
|
186
|
+
const sanitizedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
187
|
+
const parts = wrappedLine.split(new RegExp(`(${sanitizedQuery})`, 'gi'));
|
|
188
|
+
renderedParts = parts.map((part, idx) =>
|
|
189
|
+
part.toLowerCase() === searchQuery.toLowerCase()
|
|
190
|
+
? <span key={idx} fg="black" bg="yellow">{part}</span>
|
|
191
|
+
: part
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<box key={`${i}-${wrapIdx}`} flexDirection="row" height={1}>
|
|
197
|
+
{isFirstLine ? (
|
|
198
|
+
<text fg="gray" width={6}>{`${String(i + 1).padStart(4)}: `}</text>
|
|
199
|
+
) : (
|
|
200
|
+
<text fg="gray" width={6}> </text>
|
|
201
|
+
)}
|
|
202
|
+
<text fg="brightGreen" flexGrow={1}>
|
|
203
|
+
{renderedParts}
|
|
204
|
+
</text>
|
|
205
|
+
</box>
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
})}
|
|
209
|
+
</scrollbox>
|
|
210
|
+
) : !hasChanges ? (
|
|
211
|
+
<text fg="gray">No changes detected.</text>
|
|
212
|
+
) : (
|
|
213
|
+
<scrollbox flexGrow={1} ref={scrollRef}>
|
|
214
|
+
{diffData.blocks.map((block, bIdx) => (
|
|
215
|
+
<box key={bIdx} flexDirection="column" marginBottom={1}>
|
|
216
|
+
<text fg="cyan">{` ${block.header}`}</text>
|
|
217
|
+
<text fg="cyan">{` ${block.header}`}</text>
|
|
218
|
+
{block.lines.map((line, lIdx) => {
|
|
219
|
+
let fg = 'white';
|
|
220
|
+
let prefix = ' ';
|
|
221
|
+
if (line.type === 'insert') { fg = 'brightGreen'; prefix = '+'; }
|
|
222
|
+
else if (line.type === 'delete') { fg = 'brightRed'; prefix = '-'; }
|
|
223
|
+
|
|
224
|
+
const ln = line.type === 'insert' ? line.newNumber : line.oldNumber;
|
|
225
|
+
const content = line.content.substring(1);
|
|
226
|
+
|
|
227
|
+
const lnText = ln ? `${String(ln).padStart(4)}: ` : ' ';
|
|
228
|
+
|
|
229
|
+
// Wrap content if it's too long
|
|
230
|
+
const wrappedContent = wrapLine(content, maxLineWidth);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<>
|
|
234
|
+
{wrappedContent.map((wrappedLine, wrapIdx) => {
|
|
235
|
+
const isFirstLine = wrapIdx === 0;
|
|
236
|
+
|
|
237
|
+
let renderedParts: React.ReactNode;
|
|
238
|
+
if (searchQuery && content.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
239
|
+
// Sanitize search query for regex to prevent ReDoS attacks
|
|
240
|
+
const sanitizedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
241
|
+
const parts = wrappedLine.split(new RegExp(`(${sanitizedQuery})`, 'gi'));
|
|
242
|
+
renderedParts = parts.map((part, i) =>
|
|
243
|
+
part.toLowerCase() === searchQuery.toLowerCase()
|
|
244
|
+
? <span key={i} fg="black" bg="yellow">{part}</span>
|
|
245
|
+
: part
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
renderedParts = wrappedLine;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<box key={`${bIdx}-${lIdx}-${wrapIdx}`} flexDirection="row" height={1}>
|
|
253
|
+
<text fg="gray" width={lnText.length}>
|
|
254
|
+
{isFirstLine ? lnText : ' '}
|
|
255
|
+
</text>
|
|
256
|
+
<text fg={fg} flexGrow={1}>
|
|
257
|
+
{isFirstLine ? `${prefix}${renderedParts}` : ` ${renderedParts}`}
|
|
258
|
+
</text>
|
|
259
|
+
</box>
|
|
260
|
+
);
|
|
261
|
+
})}
|
|
262
|
+
</>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</box>
|
|
266
|
+
))}
|
|
267
|
+
</scrollbox>
|
|
268
|
+
)}
|
|
269
|
+
</box>
|
|
270
|
+
);
|
|
271
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_VERSION = "2.0
|
|
1
|
+
export const BUILD_VERSION = "2.1.0";
|