vzcode 2.21.0 → 2.22.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/README.md +359 -0
- package/dist/assets/{index-D6-he0oi.js → index-CfDs-dAu.js} +99 -99
- package/dist/assets/{index-fYq6iaqF.css → index-fSroLVgi.css} +1 -1
- package/dist/index.html +2 -2
- package/dist/llm-streaming-server/aiEditing.js +58 -0
- package/dist/llm-streaming-server/chatOperations.js +494 -0
- package/dist/llm-streaming-server/errorHandling.js +49 -0
- package/dist/llm-streaming-server/index.js +20 -0
- package/dist/llm-streaming-server/llmStreaming.js +265 -0
- package/dist/llm-streaming-server/validation.js +19 -0
- package/dist/server/aiChatHandler/index.js +5 -5
- package/package.json +35 -35
- package/src/client/CodeEditor/getOrCreateEditor.tsx +20 -13
- package/src/client/CodeEditor/index.tsx +3 -3
- package/src/client/VZSidebar/AIChat/styles.scss +38 -0
- package/src/client/VZSidebar/FileTypeIcon.tsx +1 -0
- package/src/client/VZSidebar/index.tsx +1 -1
- package/src/llm-streaming-server/README.md +71 -0
- package/src/llm-streaming-server/aiEditing.ts +102 -0
- package/src/llm-streaming-server/chatOperations.ts +655 -0
- package/src/llm-streaming-server/errorHandling.ts +66 -0
- package/src/llm-streaming-server/index.ts +51 -0
- package/src/llm-streaming-server/llmStreaming.ts +403 -0
- package/src/llm-streaming-server/validation.ts +24 -0
- package/src/llm-streaming-ui/README.md +103 -0
- package/src/llm-streaming-ui/components/ChatInput.tsx +237 -0
- package/src/llm-streaming-ui/components/DiffView.scss +173 -0
- package/src/llm-streaming-ui/components/DiffView.tsx +236 -0
- package/src/llm-streaming-ui/components/FileEditingIndicator.tsx +89 -0
- package/src/llm-streaming-ui/components/IndividualFileDiff.tsx +86 -0
- package/src/llm-streaming-ui/components/JumpToLatestButton.tsx +64 -0
- package/src/llm-streaming-ui/components/Message.tsx +145 -0
- package/src/llm-streaming-ui/components/MessageList.tsx +241 -0
- package/src/llm-streaming-ui/components/ThinkingScratchpad.tsx +42 -0
- package/src/llm-streaming-ui/components/TypingIndicator.tsx +19 -0
- package/src/llm-streaming-ui/components/index.tsx +388 -0
- package/src/llm-streaming-ui/components/styles.scss +831 -0
- package/src/llm-streaming-ui/components/useSpeechRecognition.ts +116 -0
- package/src/llm-streaming-ui/index.ts +34 -0
- package/src/server/aiChatHandler/index.ts +5 -5
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useCallback,
|
|
5
|
+
memo,
|
|
6
|
+
} from 'react';
|
|
7
|
+
import {
|
|
8
|
+
Form,
|
|
9
|
+
Button,
|
|
10
|
+
ButtonGroup,
|
|
11
|
+
ToggleButton,
|
|
12
|
+
} from '../../client/bootstrap';
|
|
13
|
+
import { enableAskMode } from '../../client/featureFlags';
|
|
14
|
+
import { useSpeechRecognition } from './useSpeechRecognition';
|
|
15
|
+
import { MicSVG, MicOffSVG } from '../../client/Icons';
|
|
16
|
+
|
|
17
|
+
interface ChatInputProps {
|
|
18
|
+
aiChatMessage: string;
|
|
19
|
+
setAIChatMessage: (message: string) => void;
|
|
20
|
+
onSendMessage: () => void;
|
|
21
|
+
focused: boolean;
|
|
22
|
+
aiChatMode: 'ask' | 'edit';
|
|
23
|
+
setAIChatMode: (mode: 'ask' | 'edit') => void;
|
|
24
|
+
navigateMessageHistoryUp: () => void;
|
|
25
|
+
navigateMessageHistoryDown: () => void;
|
|
26
|
+
resetMessageHistoryNavigation: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ChatInputComponent = ({
|
|
30
|
+
aiChatMessage,
|
|
31
|
+
setAIChatMessage,
|
|
32
|
+
onSendMessage,
|
|
33
|
+
focused,
|
|
34
|
+
aiChatMode,
|
|
35
|
+
setAIChatMode,
|
|
36
|
+
navigateMessageHistoryUp,
|
|
37
|
+
navigateMessageHistoryDown,
|
|
38
|
+
resetMessageHistoryNavigation,
|
|
39
|
+
}: ChatInputProps) => {
|
|
40
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
41
|
+
|
|
42
|
+
// Use the speech recognition hook
|
|
43
|
+
const {
|
|
44
|
+
isSpeaking,
|
|
45
|
+
toggleSpeechRecognition,
|
|
46
|
+
stopSpeaking,
|
|
47
|
+
} = useSpeechRecognition(setAIChatMessage);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
// Focus the input when the AI chat is focused
|
|
51
|
+
if (focused && inputRef.current) {
|
|
52
|
+
inputRef.current.focus();
|
|
53
|
+
}
|
|
54
|
+
}, [focused]);
|
|
55
|
+
|
|
56
|
+
const handleKeyDown = useCallback(
|
|
57
|
+
(event: React.KeyboardEvent) => {
|
|
58
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
onSendMessage();
|
|
61
|
+
} else if (event.key === 'Enter' && event.shiftKey) {
|
|
62
|
+
// Shift+Enter should add a newline, not trigger run code
|
|
63
|
+
// We prevent the event from bubbling up to the global keyboard handler
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
} else if (event.key === 'ArrowUp') {
|
|
66
|
+
// Only navigate history if cursor is at the beginning of the first line
|
|
67
|
+
const textarea =
|
|
68
|
+
event.target as HTMLTextAreaElement;
|
|
69
|
+
const { selectionStart, value } = textarea;
|
|
70
|
+
const lines = value
|
|
71
|
+
.substring(0, selectionStart)
|
|
72
|
+
.split('\n');
|
|
73
|
+
|
|
74
|
+
// Check if we're at the beginning of the first line
|
|
75
|
+
if (lines.length === 1 && selectionStart === 0) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
navigateMessageHistoryUp();
|
|
78
|
+
}
|
|
79
|
+
} else if (event.key === 'ArrowDown') {
|
|
80
|
+
// Only navigate history if cursor is at the end of the last line
|
|
81
|
+
const textarea =
|
|
82
|
+
event.target as HTMLTextAreaElement;
|
|
83
|
+
const { selectionStart, value } = textarea;
|
|
84
|
+
const remainingText =
|
|
85
|
+
value.substring(selectionStart);
|
|
86
|
+
const remainingLines = remainingText.split('\n');
|
|
87
|
+
|
|
88
|
+
// Check if we're at the end of the last line
|
|
89
|
+
if (
|
|
90
|
+
remainingLines.length === 1 &&
|
|
91
|
+
remainingLines[0] === ''
|
|
92
|
+
) {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
navigateMessageHistoryDown();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[
|
|
99
|
+
onSendMessage,
|
|
100
|
+
navigateMessageHistoryUp,
|
|
101
|
+
navigateMessageHistoryDown,
|
|
102
|
+
],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const handleSendClick = useCallback(() => {
|
|
106
|
+
// If speech recognition is active, stop it
|
|
107
|
+
if (isSpeaking) {
|
|
108
|
+
stopSpeaking();
|
|
109
|
+
}
|
|
110
|
+
onSendMessage();
|
|
111
|
+
}, [onSendMessage, isSpeaking, stopSpeaking]);
|
|
112
|
+
|
|
113
|
+
const handleChange = useCallback(
|
|
114
|
+
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
115
|
+
setAIChatMessage(event.target.value);
|
|
116
|
+
// Reset history navigation when user starts typing
|
|
117
|
+
resetMessageHistoryNavigation();
|
|
118
|
+
},
|
|
119
|
+
[setAIChatMessage, resetMessageHistoryNavigation],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="ai-chat-input-container">
|
|
124
|
+
{enableAskMode && (
|
|
125
|
+
<div
|
|
126
|
+
className="ai-chat-mode-toggle"
|
|
127
|
+
style={{ marginBottom: '8px' }}
|
|
128
|
+
>
|
|
129
|
+
<ButtonGroup size="sm">
|
|
130
|
+
<ToggleButton
|
|
131
|
+
id="ai-chat-mode-ask"
|
|
132
|
+
type="radio"
|
|
133
|
+
variant={
|
|
134
|
+
aiChatMode === 'ask'
|
|
135
|
+
? 'primary'
|
|
136
|
+
: 'outline-primary'
|
|
137
|
+
}
|
|
138
|
+
name="ai-chat-mode"
|
|
139
|
+
value="ask"
|
|
140
|
+
checked={aiChatMode === 'ask'}
|
|
141
|
+
onChange={() => setAIChatMode('ask')}
|
|
142
|
+
>
|
|
143
|
+
💬 Ask
|
|
144
|
+
</ToggleButton>
|
|
145
|
+
<ToggleButton
|
|
146
|
+
id="ai-chat-mode-edit"
|
|
147
|
+
type="radio"
|
|
148
|
+
variant={
|
|
149
|
+
aiChatMode === 'edit'
|
|
150
|
+
? 'primary'
|
|
151
|
+
: 'outline-primary'
|
|
152
|
+
}
|
|
153
|
+
name="ai-chat-mode"
|
|
154
|
+
value="edit"
|
|
155
|
+
checked={aiChatMode === 'edit'}
|
|
156
|
+
onChange={() => setAIChatMode('edit')}
|
|
157
|
+
>
|
|
158
|
+
✏️ Edit
|
|
159
|
+
</ToggleButton>
|
|
160
|
+
</ButtonGroup>
|
|
161
|
+
<div className="ai-chat-mode-description">
|
|
162
|
+
{aiChatMode === 'ask'
|
|
163
|
+
? 'Ask questions without editing files'
|
|
164
|
+
: 'Get answers and code edits'}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
<Form.Group className="ai-chat-input-group">
|
|
169
|
+
<Form.Control
|
|
170
|
+
as="textarea"
|
|
171
|
+
rows={10}
|
|
172
|
+
value={aiChatMessage}
|
|
173
|
+
onChange={handleChange}
|
|
174
|
+
onKeyDown={handleKeyDown}
|
|
175
|
+
ref={inputRef}
|
|
176
|
+
placeholder={
|
|
177
|
+
aiChatMode === 'edit'
|
|
178
|
+
? 'Ask me to make code changes...'
|
|
179
|
+
: 'Ask me anything about your code...'
|
|
180
|
+
}
|
|
181
|
+
spellCheck="false"
|
|
182
|
+
aria-label="Chat message input"
|
|
183
|
+
/>
|
|
184
|
+
<div className="ai-chat-input-footer">
|
|
185
|
+
<span className="ai-chat-hint">
|
|
186
|
+
{aiChatMessage ? 'Press Enter to send' : ''}
|
|
187
|
+
</span>
|
|
188
|
+
<div style={{ display: 'flex', gap: '8px' }}>
|
|
189
|
+
<Button
|
|
190
|
+
variant={
|
|
191
|
+
isSpeaking ? 'danger' : 'outline-secondary'
|
|
192
|
+
}
|
|
193
|
+
size="sm"
|
|
194
|
+
onClick={toggleSpeechRecognition}
|
|
195
|
+
aria-label={
|
|
196
|
+
isSpeaking
|
|
197
|
+
? 'Stop voice typing'
|
|
198
|
+
: 'Start voice typing'
|
|
199
|
+
}
|
|
200
|
+
title={
|
|
201
|
+
isSpeaking
|
|
202
|
+
? 'Stop voice typing'
|
|
203
|
+
: 'Start voice typing'
|
|
204
|
+
}
|
|
205
|
+
style={{
|
|
206
|
+
borderColor: isSpeaking
|
|
207
|
+
? undefined
|
|
208
|
+
: '#dee2e6',
|
|
209
|
+
borderRadius: '0.375rem',
|
|
210
|
+
borderWidth: '1px',
|
|
211
|
+
borderStyle: 'solid',
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{isSpeaking ? <MicOffSVG /> : <MicSVG />}
|
|
215
|
+
</Button>
|
|
216
|
+
<Button
|
|
217
|
+
variant={
|
|
218
|
+
aiChatMessage.trim()
|
|
219
|
+
? 'primary'
|
|
220
|
+
: 'outline-secondary'
|
|
221
|
+
}
|
|
222
|
+
onClick={handleSendClick}
|
|
223
|
+
disabled={!aiChatMessage.trim()}
|
|
224
|
+
className="ai-chat-send-button"
|
|
225
|
+
aria-label="Send message"
|
|
226
|
+
title="Send message (Enter)"
|
|
227
|
+
>
|
|
228
|
+
Send
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</Form.Group>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const ChatInput = memo(ChatInputComponent);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
.diff-view {
|
|
2
|
+
margin: 12px 0;
|
|
3
|
+
|
|
4
|
+
.diff-summary {
|
|
5
|
+
margin-bottom: 8px;
|
|
6
|
+
display: flex;
|
|
7
|
+
justify-content: space-between;
|
|
8
|
+
align-items: center;
|
|
9
|
+
|
|
10
|
+
.diff-stats {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 8px;
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
color: var(--bs-secondary-color);
|
|
16
|
+
|
|
17
|
+
.files-changed {
|
|
18
|
+
color: white;
|
|
19
|
+
font-weight: 600;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.additions {
|
|
23
|
+
color: #28a745;
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.deletions {
|
|
28
|
+
color: #dc3545;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.diff-files {
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
gap: 4px;
|
|
38
|
+
|
|
39
|
+
// Fix diff2html styling issues for dark theme and layout
|
|
40
|
+
.d2h-wrapper {
|
|
41
|
+
// Override diff2html variables for dark theme
|
|
42
|
+
--d2h-bg-color: rgb(41, 44, 52);
|
|
43
|
+
--d2h-color: #e6edf3;
|
|
44
|
+
--d2h-border-color: #30363d;
|
|
45
|
+
--d2h-dim-color: #6e7681;
|
|
46
|
+
--d2h-info-bg-color: #161b22;
|
|
47
|
+
--d2h-file-header-bg-color: #161b22;
|
|
48
|
+
--d2h-file-header-border-color: #30363d;
|
|
49
|
+
--d2h-line-border-color: #21262d;
|
|
50
|
+
--d2h-ins-bg-color: rgba(46, 160, 67, 0.15);
|
|
51
|
+
--d2h-ins-border-color: rgba(46, 160, 67, 0.4);
|
|
52
|
+
--d2h-ins-highlight-bg-color: rgba(46, 160, 67, 0.4);
|
|
53
|
+
--d2h-ins-label-color: #3fb950;
|
|
54
|
+
--d2h-del-bg-color: rgba(248, 81, 73, 0.1);
|
|
55
|
+
--d2h-del-border-color: rgba(248, 81, 73, 0.4);
|
|
56
|
+
--d2h-del-highlight-bg-color: rgba(248, 81, 73, 0.4);
|
|
57
|
+
--d2h-del-label-color: #f85149;
|
|
58
|
+
|
|
59
|
+
background-color: var(--d2h-bg-color);
|
|
60
|
+
color: var(--d2h-color);
|
|
61
|
+
border-radius: 6px;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fix layout issues with line numbers
|
|
66
|
+
.d2h-diff-table {
|
|
67
|
+
position: relative;
|
|
68
|
+
background-color: var(--d2h-bg-color);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.d2h-code-linenumber {
|
|
72
|
+
position: relative !important;
|
|
73
|
+
float: none !important;
|
|
74
|
+
display: table-cell !important;
|
|
75
|
+
width: auto !important;
|
|
76
|
+
background-color: var(--d2h-bg-color) !important;
|
|
77
|
+
border-color: var(--d2h-line-border-color) !important;
|
|
78
|
+
color: var(--d2h-dim-color) !important;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.d2h-code-side-linenumber {
|
|
82
|
+
position: relative !important;
|
|
83
|
+
float: none !important;
|
|
84
|
+
display: table-cell !important;
|
|
85
|
+
width: auto !important;
|
|
86
|
+
background-color: var(--d2h-bg-color) !important;
|
|
87
|
+
border-color: var(--d2h-line-border-color) !important;
|
|
88
|
+
color: var(--d2h-dim-color) !important;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Ensure proper table layout
|
|
92
|
+
.d2h-diff-tbody tr {
|
|
93
|
+
display: table-row;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.d2h-diff-tbody td {
|
|
97
|
+
display: table-cell;
|
|
98
|
+
vertical-align: top;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fix file header styling
|
|
102
|
+
.d2h-file-header {
|
|
103
|
+
display: none !important;
|
|
104
|
+
background-color: var(
|
|
105
|
+
--d2h-file-header-bg-color
|
|
106
|
+
) !important;
|
|
107
|
+
border-bottom: 1px solid
|
|
108
|
+
var(--d2h-file-header-border-color) !important;
|
|
109
|
+
color: var(--d2h-color);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Ensure proper line styling
|
|
113
|
+
.d2h-del {
|
|
114
|
+
background-color: var(--d2h-del-bg-color) !important;
|
|
115
|
+
border-color: var(--d2h-del-border-color) !important;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.d2h-ins {
|
|
119
|
+
background-color: var(--d2h-ins-bg-color) !important;
|
|
120
|
+
border-color: var(--d2h-ins-border-color) !important;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Style clickable file names
|
|
124
|
+
.d2h-file-name {
|
|
125
|
+
&:hover {
|
|
126
|
+
text-decoration: underline !important;
|
|
127
|
+
opacity: 0.8;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Fix wrapper border
|
|
132
|
+
.d2h-file-wrapper {
|
|
133
|
+
border: 1px solid var(--d2h-border-color) !important;
|
|
134
|
+
background-color: var(--d2h-bg-color);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.deleted-file {
|
|
139
|
+
margin-bottom: 8px;
|
|
140
|
+
border: 1px solid #30363d;
|
|
141
|
+
border-radius: 6px;
|
|
142
|
+
background-color: rgb(41, 44, 52);
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
|
|
145
|
+
.deleted-file-header {
|
|
146
|
+
display: flex;
|
|
147
|
+
justify-content: space-between;
|
|
148
|
+
align-items: center;
|
|
149
|
+
padding: 12px 16px;
|
|
150
|
+
background-color: #161b22;
|
|
151
|
+
border-bottom: 1px solid #30363d;
|
|
152
|
+
|
|
153
|
+
.deleted-file-name {
|
|
154
|
+
color: #e6edf3;
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
font-family:
|
|
157
|
+
'SFMono-Regular', Consolas, 'Liberation Mono',
|
|
158
|
+
Menlo, monospace;
|
|
159
|
+
font-size: 14px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.deleted-file-status {
|
|
163
|
+
color: #f85149;
|
|
164
|
+
font-weight: 600;
|
|
165
|
+
font-size: 14px;
|
|
166
|
+
background-color: rgba(248, 81, 73, 0.1);
|
|
167
|
+
padding: 4px 8px;
|
|
168
|
+
border-radius: 4px;
|
|
169
|
+
border: 1px solid rgba(248, 81, 73, 0.4);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useContext,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import {
|
|
9
|
+
UnifiedFilesDiff,
|
|
10
|
+
parseUnifiedDiffStats,
|
|
11
|
+
combineUnifiedDiffs,
|
|
12
|
+
isFileDeletion,
|
|
13
|
+
getDeletedFileName,
|
|
14
|
+
} from '../../utils/fileDiff';
|
|
15
|
+
import * as Diff2Html from 'diff2html';
|
|
16
|
+
import 'diff2html/bundles/css/diff2html.min.css';
|
|
17
|
+
import './DiffView.scss';
|
|
18
|
+
import { VZCodeContext } from '../../client/VZCodeContext';
|
|
19
|
+
import {
|
|
20
|
+
scrollToFirstDiff,
|
|
21
|
+
getHeaderOffset,
|
|
22
|
+
announceDiffSummary,
|
|
23
|
+
} from '../../client/utils/scrollUtils';
|
|
24
|
+
import { getFileId } from '@vizhub/viz-utils';
|
|
25
|
+
|
|
26
|
+
interface DiffViewProps {
|
|
27
|
+
diffData: UnifiedFilesDiff;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Methods that can be called on DiffView from parent components
|
|
31
|
+
export interface DiffViewRef {
|
|
32
|
+
scrollToFirstHunk: () => void;
|
|
33
|
+
focusDiffContainer: () => void;
|
|
34
|
+
announceSummary: () => void;
|
|
35
|
+
getFirstHunkElement: () => HTMLElement | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DiffView = forwardRef<
|
|
39
|
+
DiffViewRef,
|
|
40
|
+
DiffViewProps
|
|
41
|
+
>(({ diffData }, ref) => {
|
|
42
|
+
const { content, openTab, setIsAIChatOpen } =
|
|
43
|
+
useContext(VZCodeContext);
|
|
44
|
+
const diffContainerRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
|
|
46
|
+
const allDiffs = Object.values(diffData).filter(
|
|
47
|
+
(diff) => diff.length > 0,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Separate deleted files from regular diffs
|
|
51
|
+
const deletedFiles: string[] = [];
|
|
52
|
+
const regularDiffs: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const diff of allDiffs) {
|
|
55
|
+
if (isFileDeletion(diff)) {
|
|
56
|
+
deletedFiles.push(diff);
|
|
57
|
+
} else {
|
|
58
|
+
regularDiffs.push(diff);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Calculate statistics from regular unified diffs only
|
|
63
|
+
let totalAdditions = 0;
|
|
64
|
+
let totalDeletions = 0;
|
|
65
|
+
|
|
66
|
+
for (const unifiedDiff of regularDiffs) {
|
|
67
|
+
const stats = parseUnifiedDiffStats(unifiedDiff);
|
|
68
|
+
totalAdditions += stats.additions;
|
|
69
|
+
totalDeletions += stats.deletions;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Combine regular unified diffs and convert to HTML using diff2html
|
|
73
|
+
const combinedUnifiedDiff = combineUnifiedDiffs(diffData);
|
|
74
|
+
const diffHtml = Diff2Html.html(combinedUnifiedDiff, {
|
|
75
|
+
drawFileList: false,
|
|
76
|
+
matching: 'words',
|
|
77
|
+
diffStyle: 'word',
|
|
78
|
+
outputFormat: 'line-by-line',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Expose methods for parent components to control scrolling and focus
|
|
82
|
+
useImperativeHandle(
|
|
83
|
+
ref,
|
|
84
|
+
() => ({
|
|
85
|
+
scrollToFirstHunk: () => {
|
|
86
|
+
if (diffContainerRef.current) {
|
|
87
|
+
const headerOffset = getHeaderOffset();
|
|
88
|
+
scrollToFirstDiff(
|
|
89
|
+
diffContainerRef.current,
|
|
90
|
+
headerOffset,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
focusDiffContainer: () => {
|
|
95
|
+
if (diffContainerRef.current) {
|
|
96
|
+
diffContainerRef.current.tabIndex = -1;
|
|
97
|
+
diffContainerRef.current.focus();
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
announceSummary: () => {
|
|
101
|
+
if (diffContainerRef.current) {
|
|
102
|
+
announceDiffSummary(diffContainerRef.current);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
getFirstHunkElement: () => {
|
|
106
|
+
if (diffContainerRef.current) {
|
|
107
|
+
return diffContainerRef.current.querySelector(
|
|
108
|
+
'.d2h-diff-tbody tr',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
[],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Add click handlers to file names after HTML is rendered
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!diffContainerRef.current) return;
|
|
120
|
+
|
|
121
|
+
const handleFileNameClick = (event: MouseEvent) => {
|
|
122
|
+
const target = event.target as HTMLElement;
|
|
123
|
+
if (target.classList.contains('d2h-file-name')) {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
event.stopPropagation();
|
|
126
|
+
|
|
127
|
+
console.log('File name clicked');
|
|
128
|
+
|
|
129
|
+
const fileName = target.textContent?.trim();
|
|
130
|
+
|
|
131
|
+
console.log('Clicked file name:', fileName);
|
|
132
|
+
if (fileName && content) {
|
|
133
|
+
const fileId = getFileId(content, fileName);
|
|
134
|
+
|
|
135
|
+
if (fileId) {
|
|
136
|
+
// Open the file tab and switch to files view
|
|
137
|
+
openTab({ fileId, isTransient: false });
|
|
138
|
+
setIsAIChatOpen(false);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const container = diffContainerRef.current;
|
|
145
|
+
const fileNameElements = container.querySelectorAll(
|
|
146
|
+
'.d2h-file-name',
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Add click event listeners and cursor pointer style
|
|
150
|
+
fileNameElements.forEach((element) => {
|
|
151
|
+
element.addEventListener(
|
|
152
|
+
'click',
|
|
153
|
+
handleFileNameClick,
|
|
154
|
+
);
|
|
155
|
+
(element as HTMLElement).style.cursor = 'pointer';
|
|
156
|
+
(element as HTMLElement).style.textDecoration =
|
|
157
|
+
'underline';
|
|
158
|
+
(element as HTMLElement).style.color = '#58a6ff'; // GitHub blue link color
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return () => {
|
|
162
|
+
// Cleanup event listeners
|
|
163
|
+
fileNameElements.forEach((element) => {
|
|
164
|
+
element.removeEventListener(
|
|
165
|
+
'click',
|
|
166
|
+
handleFileNameClick,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
}, [diffHtml, content, openTab, setIsAIChatOpen]);
|
|
171
|
+
|
|
172
|
+
if (allDiffs.length === 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="diff-view">
|
|
178
|
+
<div className="diff-summary" id="diff-summary">
|
|
179
|
+
<div className="diff-stats">
|
|
180
|
+
<span className="files-changed">
|
|
181
|
+
{allDiffs.length} file
|
|
182
|
+
{allDiffs.length !== 1 ? 's' : ''} changed
|
|
183
|
+
</span>
|
|
184
|
+
{totalAdditions > 0 && (
|
|
185
|
+
<span className="additions">
|
|
186
|
+
+{totalAdditions}
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
{totalDeletions > 0 && (
|
|
190
|
+
<span className="deletions">
|
|
191
|
+
-{totalDeletions}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
194
|
+
{deletedFiles.length > 0 && (
|
|
195
|
+
<span className="deletions">
|
|
196
|
+
{deletedFiles.length} deleted
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* Render deleted files */}
|
|
203
|
+
{deletedFiles.map((deletionMarker, index) => {
|
|
204
|
+
const fileName = getDeletedFileName(deletionMarker);
|
|
205
|
+
return (
|
|
206
|
+
<div key={index} className="deleted-file">
|
|
207
|
+
<div className="deleted-file-header">
|
|
208
|
+
<span className="deleted-file-name">
|
|
209
|
+
{fileName}
|
|
210
|
+
</span>
|
|
211
|
+
<span className="deleted-file-status">
|
|
212
|
+
File deleted
|
|
213
|
+
</span>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
|
|
219
|
+
{/* Render regular diffs */}
|
|
220
|
+
{regularDiffs.length > 0 && (
|
|
221
|
+
<div
|
|
222
|
+
className="diff-files"
|
|
223
|
+
ref={diffContainerRef}
|
|
224
|
+
tabIndex={-1}
|
|
225
|
+
role="region"
|
|
226
|
+
aria-label="Code diff content"
|
|
227
|
+
aria-describedby="diff-summary"
|
|
228
|
+
dangerouslySetInnerHTML={{ __html: diffHtml }}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Add display name for debugging
|
|
236
|
+
DiffView.displayName = 'DiffView';
|