claude-code-runner 0.1.0 → 0.1.2
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.zh-Hans.md +2 -2
- package/package.json +17 -17
- package/public/app.js +1134 -0
- package/public/i18n.js +247 -0
- package/public/index.html +660 -0
- package/public/locales/README.md +86 -0
- package/public/locales/en.json +60 -0
- package/public/locales/zh-CN.json +60 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
/* eslint-disable no-control-regex */
|
|
2
|
+
/* global Terminal, FitAddon, WebLinksAddon, io, I18n */
|
|
3
|
+
|
|
4
|
+
// Terminal and Socket.IO setup
|
|
5
|
+
let term;
|
|
6
|
+
let socket;
|
|
7
|
+
let fitAddon;
|
|
8
|
+
let webLinksAddon;
|
|
9
|
+
let containerId;
|
|
10
|
+
|
|
11
|
+
// Input detection state
|
|
12
|
+
let isWaitingForInput = false;
|
|
13
|
+
let lastOutputTime = Date.now();
|
|
14
|
+
let lastNotificationTime = 0;
|
|
15
|
+
let idleTimer = null;
|
|
16
|
+
let isWaitingForLoadingAnimation = false;
|
|
17
|
+
const seenLoadingChars = new Set();
|
|
18
|
+
let originalPageTitle = '';
|
|
19
|
+
const IDLE_THRESHOLD = 1500; // 1.5 seconds of no output means waiting for input
|
|
20
|
+
const NOTIFICATION_COOLDOWN = 2000; // 2 seconds between notifications
|
|
21
|
+
|
|
22
|
+
// Claude's loading animation characters (unique characters only)
|
|
23
|
+
const LOADING_CHARS = ['✢', '✶', '✻', '✽', '✻', '✢', '·'];
|
|
24
|
+
const UNIQUE_LOADING_CHARS = new Set(LOADING_CHARS);
|
|
25
|
+
|
|
26
|
+
// Create notification sound using Web Audio API
|
|
27
|
+
let audioContext;
|
|
28
|
+
let notificationSound;
|
|
29
|
+
|
|
30
|
+
function initializeAudio() {
|
|
31
|
+
try {
|
|
32
|
+
if (window.AudioContext || window.webkitAudioContext) {
|
|
33
|
+
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
34
|
+
console.log('Audio context created:', audioContext.state);
|
|
35
|
+
|
|
36
|
+
// Create a simple notification beep
|
|
37
|
+
function createBeep(frequency, duration) {
|
|
38
|
+
try {
|
|
39
|
+
const oscillator = audioContext.createOscillator();
|
|
40
|
+
const gainNode = audioContext.createGain();
|
|
41
|
+
|
|
42
|
+
oscillator.connect(gainNode);
|
|
43
|
+
gainNode.connect(audioContext.destination);
|
|
44
|
+
|
|
45
|
+
oscillator.frequency.value = frequency;
|
|
46
|
+
oscillator.type = 'sine';
|
|
47
|
+
|
|
48
|
+
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
|
49
|
+
gainNode.gain.exponentialRampToValueAtTime(
|
|
50
|
+
0.01,
|
|
51
|
+
audioContext.currentTime + duration,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
oscillator.start(audioContext.currentTime);
|
|
55
|
+
oscillator.stop(audioContext.currentTime + duration);
|
|
56
|
+
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.error('Error creating beep:', error);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
notificationSound = () => {
|
|
66
|
+
console.log(
|
|
67
|
+
'Playing notification sound, audio context state:',
|
|
68
|
+
audioContext.state,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Try Web Audio API first
|
|
72
|
+
try {
|
|
73
|
+
const beep1 = createBeep(800, 0.1);
|
|
74
|
+
setTimeout(() => createBeep(1000, 0.1), 100);
|
|
75
|
+
setTimeout(() => createBeep(1200, 0.15), 200);
|
|
76
|
+
return beep1;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.error('Web Audio API failed, trying fallback:', error);
|
|
80
|
+
|
|
81
|
+
// Fallback to HTML audio element
|
|
82
|
+
const audioElement = document.getElementById('notification-sound');
|
|
83
|
+
if (audioElement) {
|
|
84
|
+
audioElement.currentTime = 0;
|
|
85
|
+
audioElement
|
|
86
|
+
.play()
|
|
87
|
+
.catch(e => console.error('Fallback audio failed:', e));
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// No Web Audio API support, use fallback only
|
|
95
|
+
console.log('Web Audio API not supported, using fallback audio');
|
|
96
|
+
notificationSound = () => {
|
|
97
|
+
const audioElement = document.getElementById('notification-sound');
|
|
98
|
+
if (audioElement) {
|
|
99
|
+
audioElement.currentTime = 0;
|
|
100
|
+
audioElement
|
|
101
|
+
.play()
|
|
102
|
+
.catch(e => console.error('Fallback audio failed:', e));
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('Audio initialized successfully');
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error('Failed to initialize audio:', error);
|
|
111
|
+
|
|
112
|
+
// Last resort fallback
|
|
113
|
+
notificationSound = () => {
|
|
114
|
+
console.log('Audio not available');
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Idle detection functions
|
|
120
|
+
function resetIdleTimer() {
|
|
121
|
+
// Clear any existing timer
|
|
122
|
+
if (idleTimer) {
|
|
123
|
+
clearTimeout(idleTimer);
|
|
124
|
+
idleTimer = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Reset waiting state only if we're not waiting for loading animation
|
|
128
|
+
if (!isWaitingForLoadingAnimation) {
|
|
129
|
+
isWaitingForInput = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Update last output time
|
|
133
|
+
lastOutputTime = Date.now();
|
|
134
|
+
|
|
135
|
+
// Only start a new timer if we've seen the loading animation or not waiting for it
|
|
136
|
+
if (
|
|
137
|
+
!isWaitingForLoadingAnimation
|
|
138
|
+
|| seenLoadingChars.size === UNIQUE_LOADING_CHARS.size
|
|
139
|
+
) {
|
|
140
|
+
idleTimer = setTimeout(() => {
|
|
141
|
+
onIdleDetected();
|
|
142
|
+
}, IDLE_THRESHOLD);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function onIdleDetected() {
|
|
147
|
+
console.log('[IDLE] Idle detected. State:', {
|
|
148
|
+
isWaitingForInput,
|
|
149
|
+
isWaitingForLoadingAnimation,
|
|
150
|
+
seenLoadingCharsCount: seenLoadingChars.size,
|
|
151
|
+
requiredCharsCount: UNIQUE_LOADING_CHARS.size,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Claude has stopped outputting for 1.5 seconds - likely waiting for input
|
|
155
|
+
// But only trigger if we're not waiting for loading animation or have seen all chars
|
|
156
|
+
if (
|
|
157
|
+
!isWaitingForInput
|
|
158
|
+
&& (!isWaitingForLoadingAnimation
|
|
159
|
+
|| seenLoadingChars.size === UNIQUE_LOADING_CHARS.size)
|
|
160
|
+
) {
|
|
161
|
+
isWaitingForInput = true;
|
|
162
|
+
console.log('[IDLE] ✓ Triggering input needed notification');
|
|
163
|
+
|
|
164
|
+
// Check cooldown to avoid spamming notifications
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
if (now - lastNotificationTime > NOTIFICATION_COOLDOWN) {
|
|
167
|
+
lastNotificationTime = now;
|
|
168
|
+
|
|
169
|
+
// Check if sound is enabled
|
|
170
|
+
const soundEnabled = document.getElementById('soundEnabled').checked;
|
|
171
|
+
|
|
172
|
+
// Play notification sound if enabled
|
|
173
|
+
if (soundEnabled && notificationSound) {
|
|
174
|
+
try {
|
|
175
|
+
// Resume audio context if suspended (browser requirement)
|
|
176
|
+
if (audioContext && audioContext.state === 'suspended') {
|
|
177
|
+
audioContext.resume();
|
|
178
|
+
}
|
|
179
|
+
notificationSound();
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
console.error('Failed to play notification sound:', error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Show permanent visual notification
|
|
187
|
+
document.body.classList.add('input-needed');
|
|
188
|
+
|
|
189
|
+
// Update status bar
|
|
190
|
+
updateStatus('connected', t('status.waitingForInput', '⚠️ Waiting for input'));
|
|
191
|
+
|
|
192
|
+
// Update page title
|
|
193
|
+
if (!originalPageTitle) {
|
|
194
|
+
originalPageTitle = t('app.title', document.title);
|
|
195
|
+
}
|
|
196
|
+
document.title = `⚠️ ${t('messages.inputNeeded', 'Input needed')} - ${originalPageTitle}`;
|
|
197
|
+
|
|
198
|
+
// Trigger file sync
|
|
199
|
+
if (socket && containerId) {
|
|
200
|
+
console.log('[SYNC] Triggering file sync due to input needed...');
|
|
201
|
+
console.log('[SYNC] Container ID:', containerId);
|
|
202
|
+
console.log('[SYNC] Socket connected:', socket.connected);
|
|
203
|
+
console.log('[SYNC] Socket ID:', socket.id);
|
|
204
|
+
|
|
205
|
+
// Test the socket connection first
|
|
206
|
+
socket.emit('test-sync', { message: 'testing sync connection' });
|
|
207
|
+
|
|
208
|
+
// Emit the actual event and log it
|
|
209
|
+
socket.emit('input-needed', { containerId });
|
|
210
|
+
console.log('[SYNC] Event emitted successfully');
|
|
211
|
+
|
|
212
|
+
// Set a timeout to check if we get a response
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
console.log('[SYNC] 5 seconds passed, checking if sync completed...');
|
|
215
|
+
}, 5000);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
console.log(
|
|
219
|
+
'[SYNC] Cannot trigger sync - socket:',
|
|
220
|
+
!!socket,
|
|
221
|
+
'containerId:',
|
|
222
|
+
!!containerId,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check if output contains loading characters
|
|
230
|
+
function checkForLoadingChars(text) {
|
|
231
|
+
// Strip ANSI escape sequences to get plain text
|
|
232
|
+
// This regex handles color codes, cursor movements, and other escape sequences
|
|
233
|
+
const stripAnsi = str =>
|
|
234
|
+
str.replace(
|
|
235
|
+
/[\x1B\x9B][[()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
|
236
|
+
'',
|
|
237
|
+
);
|
|
238
|
+
const plainText = stripAnsi(text);
|
|
239
|
+
|
|
240
|
+
const foundChars = [];
|
|
241
|
+
// Check both the original text and stripped text
|
|
242
|
+
const textsToCheck = [text, plainText];
|
|
243
|
+
|
|
244
|
+
for (const textToCheck of textsToCheck) {
|
|
245
|
+
for (const char of textToCheck) {
|
|
246
|
+
if (LOADING_CHARS.includes(char)) {
|
|
247
|
+
seenLoadingChars.add(char);
|
|
248
|
+
foundChars.push(char);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (foundChars.length > 0) {
|
|
254
|
+
console.log(
|
|
255
|
+
`[LOADING] Found loading chars: ${foundChars.join(', ')} | Total seen: ${Array.from(seenLoadingChars).join(', ')} (${seenLoadingChars.size}/${UNIQUE_LOADING_CHARS.size})`,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Debug: show hex values if we're missing chars
|
|
259
|
+
if (seenLoadingChars.size < UNIQUE_LOADING_CHARS.size && text.length < 50) {
|
|
260
|
+
const hexView = Array.from(text)
|
|
261
|
+
.map(c => `${c}(${c.charCodeAt(0).toString(16)})`)
|
|
262
|
+
.join(' ');
|
|
263
|
+
console.log(`[LOADING] Hex view: ${hexView}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// If we've seen all unique loading chars, we can stop waiting
|
|
268
|
+
if (
|
|
269
|
+
seenLoadingChars.size === UNIQUE_LOADING_CHARS.size
|
|
270
|
+
&& isWaitingForLoadingAnimation
|
|
271
|
+
) {
|
|
272
|
+
console.log(
|
|
273
|
+
'[LOADING] ✓ Seen all loading characters, Claude has started processing',
|
|
274
|
+
);
|
|
275
|
+
isWaitingForLoadingAnimation = false;
|
|
276
|
+
// Reset the idle timer now that we know Claude is processing
|
|
277
|
+
resetIdleTimer();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Get container ID from URL only
|
|
282
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
283
|
+
containerId = urlParams.get('container');
|
|
284
|
+
|
|
285
|
+
// Initialize the terminal
|
|
286
|
+
function initTerminal() {
|
|
287
|
+
term = new Terminal({
|
|
288
|
+
cursorBlink: true,
|
|
289
|
+
fontSize: 14,
|
|
290
|
+
fontFamily: 'Consolas, "Courier New", monospace',
|
|
291
|
+
theme: {
|
|
292
|
+
background: '#1e1e1e',
|
|
293
|
+
foreground: '#d4d4d4',
|
|
294
|
+
cursor: '#d4d4d4',
|
|
295
|
+
black: '#000000',
|
|
296
|
+
red: '#cd3131',
|
|
297
|
+
green: '#0dbc79',
|
|
298
|
+
yellow: '#e5e510',
|
|
299
|
+
blue: '#2472c8',
|
|
300
|
+
magenta: '#bc3fbc',
|
|
301
|
+
cyan: '#11a8cd',
|
|
302
|
+
white: '#e5e5e5',
|
|
303
|
+
brightBlack: '#666666',
|
|
304
|
+
brightRed: '#f14c4c',
|
|
305
|
+
brightGreen: '#23d18b',
|
|
306
|
+
brightYellow: '#f5f543',
|
|
307
|
+
brightBlue: '#3b8eea',
|
|
308
|
+
brightMagenta: '#d670d6',
|
|
309
|
+
brightCyan: '#29b8db',
|
|
310
|
+
brightWhite: '#e5e5e5',
|
|
311
|
+
},
|
|
312
|
+
allowProposedApi: true,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Load addons
|
|
316
|
+
fitAddon = new FitAddon.FitAddon();
|
|
317
|
+
webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
|
318
|
+
|
|
319
|
+
term.loadAddon(fitAddon);
|
|
320
|
+
term.loadAddon(webLinksAddon);
|
|
321
|
+
|
|
322
|
+
// Open terminal in the DOM
|
|
323
|
+
term.open(document.getElementById('terminal'));
|
|
324
|
+
|
|
325
|
+
// Fit terminal to container
|
|
326
|
+
fitAddon.fit();
|
|
327
|
+
|
|
328
|
+
// Handle window resize
|
|
329
|
+
window.addEventListener('resize', () => {
|
|
330
|
+
fitAddon.fit();
|
|
331
|
+
if (socket && socket.connected) {
|
|
332
|
+
socket.emit('resize', {
|
|
333
|
+
cols: term.cols,
|
|
334
|
+
rows: term.rows,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Handle terminal input
|
|
340
|
+
term.onData((data) => {
|
|
341
|
+
if (socket && socket.connected) {
|
|
342
|
+
socket.emit('input', data);
|
|
343
|
+
|
|
344
|
+
// Cancel idle timer when user provides input
|
|
345
|
+
if (idleTimer) {
|
|
346
|
+
clearTimeout(idleTimer);
|
|
347
|
+
idleTimer = null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// When user provides input, start waiting for loading animation
|
|
351
|
+
if (isWaitingForInput) {
|
|
352
|
+
isWaitingForInput = false;
|
|
353
|
+
isWaitingForLoadingAnimation = true;
|
|
354
|
+
seenLoadingChars.clear(); // Clear seen loading chars
|
|
355
|
+
console.log(
|
|
356
|
+
'[STATE] User provided input, waiting for loading animation...',
|
|
357
|
+
);
|
|
358
|
+
console.log(
|
|
359
|
+
'[STATE] Need to see these chars:',
|
|
360
|
+
Array.from(UNIQUE_LOADING_CHARS).join(', '),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// Clear the input-needed visual state
|
|
364
|
+
document.body.classList.remove('input-needed');
|
|
365
|
+
|
|
366
|
+
// Reset title
|
|
367
|
+
if (originalPageTitle) {
|
|
368
|
+
document.title = originalPageTitle;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Update status
|
|
372
|
+
updateStatus(
|
|
373
|
+
'connected',
|
|
374
|
+
`Connected to ${containerId.substring(0, 12)}`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Show welcome message
|
|
381
|
+
term.writeln('\x1B[1;32mWelcome to Claude Code Runner Terminal\x1B[0m');
|
|
382
|
+
term.writeln('\x1B[90mConnecting to container...\x1B[0m');
|
|
383
|
+
term.writeln('');
|
|
384
|
+
|
|
385
|
+
// Auto-focus the terminal
|
|
386
|
+
term.focus();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Initialize Socket.IO connection
|
|
390
|
+
function initSocket() {
|
|
391
|
+
socket = io();
|
|
392
|
+
window.socket = socket; // Make it globally accessible for debugging
|
|
393
|
+
|
|
394
|
+
socket.on('connect', () => {
|
|
395
|
+
console.log('Connected to server');
|
|
396
|
+
updateStatus('connecting', 'Attaching to container...');
|
|
397
|
+
|
|
398
|
+
// Hide loading spinner
|
|
399
|
+
document.getElementById('loading').style.display = 'none';
|
|
400
|
+
|
|
401
|
+
// Only use container ID from URL, never from cache
|
|
402
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
403
|
+
const currentContainerId = urlParams.get('container');
|
|
404
|
+
|
|
405
|
+
if (currentContainerId) {
|
|
406
|
+
containerId = currentContainerId;
|
|
407
|
+
socket.emit('attach', {
|
|
408
|
+
containerId: currentContainerId,
|
|
409
|
+
cols: term.cols,
|
|
410
|
+
rows: term.rows,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// No container ID in URL, fetch available containers
|
|
415
|
+
fetchContainerList();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
socket.on('attached', (data) => {
|
|
420
|
+
console.log('Attached to container:', data.containerId);
|
|
421
|
+
containerId = data.containerId;
|
|
422
|
+
updateStatus(
|
|
423
|
+
'connected',
|
|
424
|
+
`Connected to ${data.containerId.substring(0, 12)}`,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// Don't clear terminal on attach - preserve existing content
|
|
428
|
+
|
|
429
|
+
// Send initial resize
|
|
430
|
+
socket.emit('resize', {
|
|
431
|
+
cols: term.cols,
|
|
432
|
+
rows: term.rows,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// Start idle detection
|
|
436
|
+
resetIdleTimer();
|
|
437
|
+
|
|
438
|
+
// Focus terminal when attached
|
|
439
|
+
if (term) {
|
|
440
|
+
term.focus();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Fetch git info for this container
|
|
444
|
+
fetchGitInfo();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
socket.on('output', (data) => {
|
|
448
|
+
// Convert ArrayBuffer to Uint8Array if needed
|
|
449
|
+
if (data instanceof ArrayBuffer) {
|
|
450
|
+
data = new Uint8Array(data);
|
|
451
|
+
}
|
|
452
|
+
term.write(data);
|
|
453
|
+
|
|
454
|
+
// Convert to string to check for loading characters
|
|
455
|
+
const decoder = new TextDecoder('utf-8');
|
|
456
|
+
const text = decoder.decode(data);
|
|
457
|
+
|
|
458
|
+
// Check for loading characters if we're waiting for them
|
|
459
|
+
if (isWaitingForLoadingAnimation) {
|
|
460
|
+
checkForLoadingChars(text);
|
|
461
|
+
}
|
|
462
|
+
else if (text.length > 0) {
|
|
463
|
+
// Check if loading chars are present in either raw or stripped text
|
|
464
|
+
const stripAnsi = str =>
|
|
465
|
+
str.replace(
|
|
466
|
+
/[\x1B\x9B][[()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
|
467
|
+
'',
|
|
468
|
+
);
|
|
469
|
+
const plainText = stripAnsi(text);
|
|
470
|
+
|
|
471
|
+
const foundInRaw = LOADING_CHARS.filter(char => text.includes(char));
|
|
472
|
+
const foundInPlain = LOADING_CHARS.filter(char =>
|
|
473
|
+
plainText.includes(char),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
if (foundInRaw.length > 0 || foundInPlain.length > 0) {
|
|
477
|
+
console.log('[DEBUG] Loading chars present but not tracking:', {
|
|
478
|
+
raw: foundInRaw.join(', '),
|
|
479
|
+
plain: foundInPlain.join(', '),
|
|
480
|
+
hasAnsi: text !== plainText,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Reset idle timer on any output
|
|
486
|
+
resetIdleTimer();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
socket.on('disconnect', () => {
|
|
490
|
+
updateStatus('error', 'Disconnected from server');
|
|
491
|
+
term.writeln(
|
|
492
|
+
'\r\n\x1B[1;31mServer connection lost. Click "Reconnect" to retry.\x1B[0m',
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// Clear idle timer on disconnect
|
|
496
|
+
if (idleTimer) {
|
|
497
|
+
clearTimeout(idleTimer);
|
|
498
|
+
idleTimer = null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Clear input-needed state
|
|
502
|
+
document.body.classList.remove('input-needed');
|
|
503
|
+
if (originalPageTitle) {
|
|
504
|
+
document.title = originalPageTitle;
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
socket.on('container-disconnected', () => {
|
|
509
|
+
updateStatus('error', 'Container disconnected');
|
|
510
|
+
term.writeln(
|
|
511
|
+
'\r\n\x1B[1;31mContainer connection lost. Click "Reconnect" to retry.\x1B[0m',
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// Clear idle timer on disconnect
|
|
515
|
+
if (idleTimer) {
|
|
516
|
+
clearTimeout(idleTimer);
|
|
517
|
+
idleTimer = null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Clear input-needed state
|
|
521
|
+
document.body.classList.remove('input-needed');
|
|
522
|
+
if (originalPageTitle) {
|
|
523
|
+
document.title = originalPageTitle;
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
socket.on('sync-complete', (data) => {
|
|
528
|
+
console.log('[SYNC] Sync completed:', data);
|
|
529
|
+
console.log('[SYNC] Has changes:', data.hasChanges);
|
|
530
|
+
console.log('[SYNC] Summary:', data.summary);
|
|
531
|
+
console.log('[SYNC] Diff data:', data.diffData);
|
|
532
|
+
|
|
533
|
+
if (data.hasChanges) {
|
|
534
|
+
// Keep showing container ID in status
|
|
535
|
+
updateStatus('connected', `Connected to ${containerId.substring(0, 12)}`);
|
|
536
|
+
updateChangesTab(data);
|
|
537
|
+
|
|
538
|
+
// Update file count badge with total changed files
|
|
539
|
+
const totalFiles = calculateTotalChangedFiles(data);
|
|
540
|
+
updateChangesTabBadge(totalFiles);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
updateStatus('connected', `Connected to ${containerId.substring(0, 12)}`);
|
|
544
|
+
clearChangesTab();
|
|
545
|
+
updateChangesTabBadge(0);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
socket.on('sync-error', (error) => {
|
|
550
|
+
console.error('[SYNC] Sync error:', error);
|
|
551
|
+
updateStatus('error', `Sync failed: ${error.message}`);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Add general error handler
|
|
555
|
+
socket.on('error', (error) => {
|
|
556
|
+
console.error('[SOCKET] Socket error:', error);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Add disconnect handler with debug
|
|
560
|
+
socket.on('disconnect', (reason) => {
|
|
561
|
+
console.log('[SOCKET] Disconnected:', reason);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// Container error handler (keeping this for backward compatibility)
|
|
565
|
+
socket.on('container-error', (error) => {
|
|
566
|
+
console.error('[CONTAINER] Container error:', error);
|
|
567
|
+
updateStatus('error', `Error: ${error.message}`);
|
|
568
|
+
if (term && term.writeln) {
|
|
569
|
+
term.writeln(`\r\n\x1B[1;31mError: ${error.message}\x1B[0m`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// If container not found, try to get a new one
|
|
573
|
+
if (error.message && error.message.includes('no such container')) {
|
|
574
|
+
containerId = null;
|
|
575
|
+
|
|
576
|
+
// Try to fetch available containers
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
fetchContainerList();
|
|
579
|
+
}, 1000);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Fetch available containers
|
|
585
|
+
async function fetchContainerList() {
|
|
586
|
+
try {
|
|
587
|
+
const response = await fetch('/api/containers');
|
|
588
|
+
const containers = await response.json();
|
|
589
|
+
|
|
590
|
+
if (containers.length > 0) {
|
|
591
|
+
// Use the first container
|
|
592
|
+
containerId = containers[0].Id;
|
|
593
|
+
socket.emit('attach', {
|
|
594
|
+
containerId,
|
|
595
|
+
cols: term.cols,
|
|
596
|
+
rows: term.rows,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
updateStatus('error', t('status.noContainersFound', 'No containers found'));
|
|
601
|
+
term.writeln(`\x1B[1;31m${t('messages.noContainersFoundMessage', 'No Claude Code Runner containers found.')}\x1B[0m`);
|
|
602
|
+
term.writeln(`\x1B[90m${t('messages.startContainerFirst', 'Please start a container first.')}\x1B[0m`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
console.error('Failed to fetch containers:', error);
|
|
607
|
+
updateStatus('error', t('status.failedToFetchContainers', 'Failed to fetch containers'));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Update connection status
|
|
612
|
+
function updateStatus(status, text) {
|
|
613
|
+
const indicator = document.getElementById('status-indicator');
|
|
614
|
+
const statusText = document.getElementById('status-text');
|
|
615
|
+
|
|
616
|
+
indicator.className = `status-indicator ${status}`;
|
|
617
|
+
statusText.textContent = text;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Translate helper - returns translated text if I18n is ready, otherwise returns original
|
|
621
|
+
function t(key, fallback) {
|
|
622
|
+
if (typeof I18n !== 'undefined' && I18n.ready()) {
|
|
623
|
+
return I18n.t(key);
|
|
624
|
+
}
|
|
625
|
+
return fallback || key;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Control functions
|
|
629
|
+
function clearTerminal() {
|
|
630
|
+
term.clear();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function reconnect() {
|
|
634
|
+
if (socket && containerId) {
|
|
635
|
+
// Don't clear terminal - preserve existing content
|
|
636
|
+
term.writeln(`\r\n\x1B[90m${t('messages.reconnecting', 'Reconnecting...')}\x1B[0m`);
|
|
637
|
+
|
|
638
|
+
// Just emit attach again without disconnecting
|
|
639
|
+
// This will reattach to the existing session
|
|
640
|
+
socket.emit('attach', {
|
|
641
|
+
containerId,
|
|
642
|
+
cols: term.cols,
|
|
643
|
+
rows: term.rows,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function copySelection() {
|
|
649
|
+
const selection = term.getSelection();
|
|
650
|
+
if (selection) {
|
|
651
|
+
navigator.clipboard
|
|
652
|
+
.writeText(selection)
|
|
653
|
+
.then(() => {
|
|
654
|
+
// Show temporary feedback
|
|
655
|
+
const originalText = document.getElementById('status-text').textContent;
|
|
656
|
+
updateStatus('connected', t('status.copiedToClipboard', 'Copied to clipboard'));
|
|
657
|
+
setTimeout(() => {
|
|
658
|
+
updateStatus('connected', originalText);
|
|
659
|
+
}, 2000);
|
|
660
|
+
})
|
|
661
|
+
.catch((err) => {
|
|
662
|
+
console.error('Failed to copy:', err);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Git info functions
|
|
668
|
+
async function fetchGitInfo() {
|
|
669
|
+
try {
|
|
670
|
+
// Use container ID if available to get branch from shadow repo
|
|
671
|
+
const url = containerId
|
|
672
|
+
? `/api/git/info?containerId=${containerId}`
|
|
673
|
+
: '/api/git/info';
|
|
674
|
+
const response = await fetch(url);
|
|
675
|
+
if (response.ok) {
|
|
676
|
+
const data = await response.json();
|
|
677
|
+
updateGitInfo(data);
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
console.error('Failed to fetch git info:', response.statusText);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
console.error('Error fetching git info:', error);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function updateGitInfo(data) {
|
|
689
|
+
const gitInfoElement = document.getElementById('git-info');
|
|
690
|
+
const branchNameElement = document.getElementById('branch-name');
|
|
691
|
+
const prInfoElement = document.getElementById('pr-info');
|
|
692
|
+
|
|
693
|
+
if (data.currentBranch) {
|
|
694
|
+
// Clear existing content
|
|
695
|
+
branchNameElement.innerHTML = '';
|
|
696
|
+
|
|
697
|
+
if (data.branchUrl) {
|
|
698
|
+
// Create clickable branch link
|
|
699
|
+
const branchLink = document.createElement('a');
|
|
700
|
+
branchLink.href = data.branchUrl;
|
|
701
|
+
branchLink.target = '_blank';
|
|
702
|
+
branchLink.textContent = data.currentBranch;
|
|
703
|
+
branchLink.style.color = 'inherit';
|
|
704
|
+
branchLink.style.textDecoration = 'none';
|
|
705
|
+
branchLink.title = `View ${data.currentBranch} branch on GitHub`;
|
|
706
|
+
branchLink.addEventListener('mouseenter', () => {
|
|
707
|
+
branchLink.style.textDecoration = 'underline';
|
|
708
|
+
});
|
|
709
|
+
branchLink.addEventListener('mouseleave', () => {
|
|
710
|
+
branchLink.style.textDecoration = 'none';
|
|
711
|
+
});
|
|
712
|
+
branchNameElement.appendChild(branchLink);
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
// Fallback to plain text
|
|
716
|
+
branchNameElement.textContent = data.currentBranch;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
gitInfoElement.style.display = 'inline-block';
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Clear existing PR info
|
|
723
|
+
prInfoElement.innerHTML = '';
|
|
724
|
+
|
|
725
|
+
if (data.prs && data.prs.length > 0) {
|
|
726
|
+
data.prs.forEach((pr) => {
|
|
727
|
+
const prBadge = document.createElement('a');
|
|
728
|
+
prBadge.className = 'pr-badge';
|
|
729
|
+
prBadge.href = pr.url;
|
|
730
|
+
prBadge.target = '_blank';
|
|
731
|
+
prBadge.title = pr.title;
|
|
732
|
+
|
|
733
|
+
// Set badge class based on state
|
|
734
|
+
if (pr.isDraft) {
|
|
735
|
+
prBadge.classList.add('draft');
|
|
736
|
+
prBadge.textContent = `Draft PR #${pr.number}`;
|
|
737
|
+
}
|
|
738
|
+
else if (pr.state === 'OPEN') {
|
|
739
|
+
prBadge.classList.add('open');
|
|
740
|
+
prBadge.textContent = `PR #${pr.number}`;
|
|
741
|
+
}
|
|
742
|
+
else if (pr.state === 'CLOSED') {
|
|
743
|
+
prBadge.classList.add('closed');
|
|
744
|
+
prBadge.textContent = `Closed PR #${pr.number}`;
|
|
745
|
+
}
|
|
746
|
+
else if (pr.state === 'MERGED') {
|
|
747
|
+
prBadge.classList.add('merged');
|
|
748
|
+
prBadge.textContent = `Merged PR #${pr.number}`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
prInfoElement.appendChild(prBadge);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Initialize everything when DOM is ready
|
|
757
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
758
|
+
// Store original page title
|
|
759
|
+
originalPageTitle = t('app.title', document.title);
|
|
760
|
+
|
|
761
|
+
window.addEventListener('languagechange', () => {
|
|
762
|
+
originalPageTitle = t('app.title', document.title);
|
|
763
|
+
if (document.body.classList.contains('input-needed')) {
|
|
764
|
+
document.title = `⚠️ ${t('messages.inputNeeded', 'Need Input')} - ${originalPageTitle}`;
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
initTerminal();
|
|
769
|
+
initSocket();
|
|
770
|
+
|
|
771
|
+
// Fetch git info on load
|
|
772
|
+
fetchGitInfo();
|
|
773
|
+
|
|
774
|
+
// Refresh git info periodically
|
|
775
|
+
setInterval(fetchGitInfo, 30000); // Every 30 seconds
|
|
776
|
+
|
|
777
|
+
// Initialize audio on first user interaction (browser requirement)
|
|
778
|
+
document.addEventListener(
|
|
779
|
+
'click',
|
|
780
|
+
function initAudioOnInteraction() {
|
|
781
|
+
if (!audioContext) {
|
|
782
|
+
initializeAudio();
|
|
783
|
+
}
|
|
784
|
+
// Remove listener after first interaction
|
|
785
|
+
document.removeEventListener('click', initAudioOnInteraction);
|
|
786
|
+
},
|
|
787
|
+
{ once: true },
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
// Also try to initialize on keyboard interaction
|
|
791
|
+
document.addEventListener(
|
|
792
|
+
'keydown',
|
|
793
|
+
function initAudioOnKeyboard() {
|
|
794
|
+
if (!audioContext) {
|
|
795
|
+
initializeAudio();
|
|
796
|
+
}
|
|
797
|
+
// Remove listener after first interaction
|
|
798
|
+
document.removeEventListener('keydown', initAudioOnKeyboard);
|
|
799
|
+
},
|
|
800
|
+
{ once: true },
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
// Expose variables for testing with getters
|
|
804
|
+
Object.defineProperty(window, 'term', { get: () => term });
|
|
805
|
+
Object.defineProperty(window, 'isWaitingForInput', {
|
|
806
|
+
get: () => isWaitingForInput,
|
|
807
|
+
});
|
|
808
|
+
Object.defineProperty(window, 'isWaitingForLoadingAnimation', {
|
|
809
|
+
get: () => isWaitingForLoadingAnimation,
|
|
810
|
+
});
|
|
811
|
+
Object.defineProperty(window, 'seenLoadingChars', {
|
|
812
|
+
get: () => seenLoadingChars,
|
|
813
|
+
});
|
|
814
|
+
Object.defineProperty(window, 'lastOutputTime', {
|
|
815
|
+
get: () => lastOutputTime,
|
|
816
|
+
});
|
|
817
|
+
Object.defineProperty(window, 'lastNotificationTime', {
|
|
818
|
+
get: () => lastNotificationTime,
|
|
819
|
+
});
|
|
820
|
+
Object.defineProperty(window, 'audioContext', { get: () => audioContext });
|
|
821
|
+
Object.defineProperty(window, 'notificationSound', {
|
|
822
|
+
get: () => notificationSound,
|
|
823
|
+
set: (value) => {
|
|
824
|
+
notificationSound = value;
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// Calculate total changed files from sync data
|
|
830
|
+
function calculateTotalChangedFiles(syncData) {
|
|
831
|
+
if (!syncData.diffData || !syncData.diffData.status)
|
|
832
|
+
return 0;
|
|
833
|
+
|
|
834
|
+
// Count unique files from git status
|
|
835
|
+
const statusLines = syncData.diffData.status
|
|
836
|
+
.split('\n')
|
|
837
|
+
.filter(line => line.trim());
|
|
838
|
+
const uniqueFiles = new Set();
|
|
839
|
+
|
|
840
|
+
statusLines.forEach((line) => {
|
|
841
|
+
if (line.trim()) {
|
|
842
|
+
const filename = line.substring(3).trim();
|
|
843
|
+
if (filename) {
|
|
844
|
+
uniqueFiles.add(filename);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
return uniqueFiles.size;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Update changes tab badge
|
|
853
|
+
function updateChangesTabBadge(fileCount) {
|
|
854
|
+
const changesTab = document.getElementById('changes-tab');
|
|
855
|
+
if (!changesTab)
|
|
856
|
+
return;
|
|
857
|
+
|
|
858
|
+
// Remove existing badge
|
|
859
|
+
const existingBadge = changesTab.querySelector('.file-count-badge');
|
|
860
|
+
if (existingBadge) {
|
|
861
|
+
existingBadge.remove();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Add new badge if there are changes
|
|
865
|
+
if (fileCount > 0) {
|
|
866
|
+
const badge = document.createElement('span');
|
|
867
|
+
badge.className = 'file-count-badge';
|
|
868
|
+
badge.textContent = fileCount.toString();
|
|
869
|
+
changesTab.appendChild(badge);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Tab system functions
|
|
874
|
+
function switchTab(tabName) {
|
|
875
|
+
// Remove active class from all tabs and content
|
|
876
|
+
document
|
|
877
|
+
.querySelectorAll('.tab')
|
|
878
|
+
.forEach(tab => tab.classList.remove('active'));
|
|
879
|
+
document
|
|
880
|
+
.querySelectorAll('.tab-content')
|
|
881
|
+
.forEach(content => content.classList.remove('active'));
|
|
882
|
+
|
|
883
|
+
// Add active class to selected tab and content
|
|
884
|
+
document.getElementById(`${tabName}-tab`).classList.add('active');
|
|
885
|
+
document.getElementById(`${tabName}-content`).classList.add('active');
|
|
886
|
+
|
|
887
|
+
// Tab switching handled by active class now
|
|
888
|
+
|
|
889
|
+
// Resize terminal if switching back to terminal tab
|
|
890
|
+
if (tabName === 'terminal' && term && term.fit) {
|
|
891
|
+
setTimeout(() => term.fit(), 100);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Git workflow functions for tab system
|
|
896
|
+
function updateChangesTab(syncData) {
|
|
897
|
+
console.log('[UI] updateChangesTab called with:', syncData);
|
|
898
|
+
|
|
899
|
+
const container = document.getElementById('changes-container');
|
|
900
|
+
|
|
901
|
+
if (!container) {
|
|
902
|
+
console.error('[UI] changes-container not found!');
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Clear existing content
|
|
907
|
+
container.innerHTML = '';
|
|
908
|
+
|
|
909
|
+
// Create changes content
|
|
910
|
+
const diffStats = syncData.diffData?.stats || {
|
|
911
|
+
additions: 0,
|
|
912
|
+
deletions: 0,
|
|
913
|
+
files: 0,
|
|
914
|
+
};
|
|
915
|
+
const statsText
|
|
916
|
+
= diffStats.files > 0
|
|
917
|
+
? `${diffStats.files} file(s), +${diffStats.additions} -${diffStats.deletions}`
|
|
918
|
+
: 'No changes';
|
|
919
|
+
|
|
920
|
+
container.innerHTML = `
|
|
921
|
+
<div class="changes-summary">
|
|
922
|
+
<strong>Changes Summary:</strong> ${syncData.summary}
|
|
923
|
+
<div class="diff-stats">📊 ${statsText}</div>
|
|
924
|
+
</div>
|
|
925
|
+
|
|
926
|
+
<div class="diff-viewer">
|
|
927
|
+
${formatDiffForDisplay(syncData.diffData)}
|
|
928
|
+
</div>
|
|
929
|
+
|
|
930
|
+
<div class="git-actions">
|
|
931
|
+
<h3>💾 Commit Changes</h3>
|
|
932
|
+
<textarea
|
|
933
|
+
id="commit-message"
|
|
934
|
+
placeholder="Enter commit message..."
|
|
935
|
+
rows="3"
|
|
936
|
+
>Update files from Claude
|
|
937
|
+
|
|
938
|
+
${syncData.summary}</textarea>
|
|
939
|
+
|
|
940
|
+
<div style="margin-bottom: 15px;">
|
|
941
|
+
<button onclick="commitChanges('${syncData.containerId}')" class="btn btn-primary" id="commit-btn">
|
|
942
|
+
Commit Changes
|
|
943
|
+
</button>
|
|
944
|
+
</div>
|
|
945
|
+
</div>
|
|
946
|
+
|
|
947
|
+
<div class="git-actions" id="push-section" style="display: none;">
|
|
948
|
+
<h3>🚀 Push to Remote</h3>
|
|
949
|
+
<div class="branch-input">
|
|
950
|
+
<label for="branch-name">Branch name:</label>
|
|
951
|
+
<input type="text" id="branch-name" placeholder="claude-changes" value="claude-changes">
|
|
952
|
+
</div>
|
|
953
|
+
<div>
|
|
954
|
+
<button onclick="pushChanges('${syncData.containerId}')" class="btn btn-success" id="push-btn">
|
|
955
|
+
Push to Remote
|
|
956
|
+
</button>
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
`;
|
|
960
|
+
|
|
961
|
+
// Store sync data for later use
|
|
962
|
+
window.currentSyncData = syncData;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function clearChangesTab() {
|
|
966
|
+
const container = document.getElementById('changes-container');
|
|
967
|
+
const noChanges = document.getElementById('no-changes');
|
|
968
|
+
|
|
969
|
+
// Show empty state
|
|
970
|
+
noChanges.style.display = 'block';
|
|
971
|
+
|
|
972
|
+
// Clear changes content but keep the empty state
|
|
973
|
+
container.innerHTML = `
|
|
974
|
+
<div class="empty-state" id="no-changes">
|
|
975
|
+
<h3 data-i18n="changes.noChangesTitle">${t('changes.noChangesTitle', 'No changes detected')}</h3>
|
|
976
|
+
<p data-i18n="changes.noChangesDescription">${t('changes.noChangesDescription', 'Claude hasn\'t made any changes yet. Changes will appear here automatically when Claude modifies files.')}</p>
|
|
977
|
+
</div>
|
|
978
|
+
`;
|
|
979
|
+
|
|
980
|
+
if (typeof I18n !== 'undefined' && I18n.ready()) {
|
|
981
|
+
I18n.updateDOM();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Remove badge
|
|
985
|
+
updateChangesTabBadge(0);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function formatDiffForDisplay(diffData) {
|
|
989
|
+
if (!diffData)
|
|
990
|
+
return '<div class="diff-line context">No changes to display</div>';
|
|
991
|
+
|
|
992
|
+
const lines = [];
|
|
993
|
+
|
|
994
|
+
// Show file status
|
|
995
|
+
if (diffData.status) {
|
|
996
|
+
lines.push('<div class="diff-line header">📄 File Status:</div>');
|
|
997
|
+
diffData.status.split('\n').forEach((line) => {
|
|
998
|
+
if (line.trim()) {
|
|
999
|
+
const status = line.substring(0, 2);
|
|
1000
|
+
const filename = line.substring(3);
|
|
1001
|
+
let statusText = '';
|
|
1002
|
+
if (status === '??')
|
|
1003
|
+
statusText = 'New file';
|
|
1004
|
+
else if (status === ' M' || status === 'M ' || status === 'MM')
|
|
1005
|
+
statusText = 'Modified';
|
|
1006
|
+
else if (status === ' D' || status === 'D ')
|
|
1007
|
+
statusText = 'Deleted';
|
|
1008
|
+
else if (status === 'A ' || status === 'AM')
|
|
1009
|
+
statusText = 'Added';
|
|
1010
|
+
else statusText = `Status: ${status}`;
|
|
1011
|
+
|
|
1012
|
+
lines.push(
|
|
1013
|
+
`<div class="diff-line context"> ${statusText}: ${filename}</div>`,
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
lines.push('<div class="diff-line context"></div>');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Show diff
|
|
1021
|
+
if (diffData.diff) {
|
|
1022
|
+
lines.push('<div class="diff-line header">📝 Changes:</div>');
|
|
1023
|
+
diffData.diff.split('\n').forEach((line) => {
|
|
1024
|
+
let className = 'context';
|
|
1025
|
+
if (line.startsWith('+'))
|
|
1026
|
+
className = 'added';
|
|
1027
|
+
else if (line.startsWith('-'))
|
|
1028
|
+
className = 'removed';
|
|
1029
|
+
else if (line.startsWith('@@'))
|
|
1030
|
+
className = 'header';
|
|
1031
|
+
|
|
1032
|
+
lines.push(
|
|
1033
|
+
`<div class="diff-line ${className}">${escapeHtml(line)}</div>`,
|
|
1034
|
+
);
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Show untracked files
|
|
1039
|
+
if (diffData.untrackedFiles && diffData.untrackedFiles.length > 0) {
|
|
1040
|
+
lines.push('<div class="diff-line context"></div>');
|
|
1041
|
+
lines.push('<div class="diff-line header">📁 New Files:</div>');
|
|
1042
|
+
diffData.untrackedFiles.forEach((filename) => {
|
|
1043
|
+
lines.push(`<div class="diff-line added">+ ${filename}</div>`);
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return lines.join('');
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function escapeHtml(text) {
|
|
1051
|
+
const div = document.createElement('div');
|
|
1052
|
+
div.textContent = text;
|
|
1053
|
+
return div.innerHTML;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function commitChanges(containerId) {
|
|
1057
|
+
const commitMessage = document.getElementById('commit-message').value.trim();
|
|
1058
|
+
if (!commitMessage) {
|
|
1059
|
+
alert('Please enter a commit message');
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const btn = document.getElementById('commit-btn');
|
|
1064
|
+
btn.disabled = true;
|
|
1065
|
+
btn.textContent = 'Committing...';
|
|
1066
|
+
|
|
1067
|
+
socket.emit('commit-changes', { containerId, commitMessage });
|
|
1068
|
+
|
|
1069
|
+
// Handle commit result
|
|
1070
|
+
socket.once('commit-success', () => {
|
|
1071
|
+
btn.textContent = '✓ Committed';
|
|
1072
|
+
btn.style.background = '#238636';
|
|
1073
|
+
|
|
1074
|
+
// Show push section
|
|
1075
|
+
document.getElementById('push-section').style.display = 'block';
|
|
1076
|
+
|
|
1077
|
+
updateStatus('connected', '✓ Changes committed successfully');
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
socket.once('commit-error', (error) => {
|
|
1081
|
+
btn.disabled = false;
|
|
1082
|
+
btn.textContent = 'Commit Changes';
|
|
1083
|
+
alert(`Commit failed: ${error.message}`);
|
|
1084
|
+
updateStatus('error', `Commit failed: ${error.message}`);
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function pushChanges(containerId) {
|
|
1089
|
+
const branchName
|
|
1090
|
+
= document.getElementById('branch-name').value.trim() || 'claude-changes';
|
|
1091
|
+
|
|
1092
|
+
const btn = document.getElementById('push-btn');
|
|
1093
|
+
btn.disabled = true;
|
|
1094
|
+
btn.textContent = 'Pushing...';
|
|
1095
|
+
|
|
1096
|
+
socket.emit('push-changes', { containerId, branchName });
|
|
1097
|
+
|
|
1098
|
+
// Handle push result
|
|
1099
|
+
socket.once('push-success', () => {
|
|
1100
|
+
btn.textContent = '✓ Pushed to GitHub';
|
|
1101
|
+
btn.style.background = '#238636';
|
|
1102
|
+
updateStatus('connected', `✓ Changes pushed to remote ${branchName}`);
|
|
1103
|
+
|
|
1104
|
+
// Clear the changes tab after successful push
|
|
1105
|
+
setTimeout(() => {
|
|
1106
|
+
clearChangesTab();
|
|
1107
|
+
}, 3000);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
socket.once('push-error', (error) => {
|
|
1111
|
+
btn.disabled = false;
|
|
1112
|
+
btn.textContent = 'Push to Remote';
|
|
1113
|
+
alert(`Push failed: ${error.message}`);
|
|
1114
|
+
updateStatus('error', `Push failed: ${error.message}`);
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Handle keyboard shortcuts
|
|
1119
|
+
document.addEventListener('keydown', (e) => {
|
|
1120
|
+
// Ctrl+Shift+C for copy
|
|
1121
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'C') {
|
|
1122
|
+
e.preventDefault();
|
|
1123
|
+
copySelection();
|
|
1124
|
+
}
|
|
1125
|
+
// Ctrl+Shift+V for paste
|
|
1126
|
+
else if (e.ctrlKey && e.shiftKey && e.key === 'V') {
|
|
1127
|
+
e.preventDefault();
|
|
1128
|
+
navigator.clipboard.readText().then((text) => {
|
|
1129
|
+
if (socket && socket.connected) {
|
|
1130
|
+
socket.emit('input', text);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
});
|