1import React, { useState, useEffect, useCallback, useRef } from "react";
2import type * as Monaco from "monaco-editor";
3import { api } from "../services/api";
4import { isDarkModeActive } from "../services/theme";
5import { GitDiffInfo, GitFileInfo, GitFileDiff } from "../types";
6
7interface DiffViewerProps {
8 cwd: string;
9 isOpen: boolean;
10 onClose: () => void;
11 onCommentTextChange: (text: string) => void;
12 initialCommit?: string; // If set, select this commit when opening
13}
14
15// Icon components for cleaner JSX
16const PrevFileIcon = () => (
17 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
18 <path d="M8 2L2 8l6 6V2z" />
19 <path d="M14 2L8 8l6 6V2z" />
20 </svg>
21);
22
23const PrevChangeIcon = () => (
24 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
25 <path d="M10 2L4 8l6 6V2z" />
26 </svg>
27);
28
29const NextChangeIcon = () => (
30 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
31 <path d="M6 2l6 6-6 6V2z" />
32 </svg>
33);
34
35const NextFileIcon = () => (
36 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
37 <path d="M2 2l6 6-6 6V2z" />
38 <path d="M8 2l6 6-6 6V2z" />
39 </svg>
40);
41
42// Global Monaco instance - loaded lazily
43let monacoInstance: typeof Monaco | null = null;
44let monacoLoadPromise: Promise<typeof Monaco> | null = null;
45
46function loadMonaco(): Promise<typeof Monaco> {
47 if (monacoInstance) {
48 return Promise.resolve(monacoInstance);
49 }
50 if (monacoLoadPromise) {
51 return monacoLoadPromise;
52 }
53
54 monacoLoadPromise = (async () => {
55 // Configure Monaco environment for web workers before importing
56 const monacoEnv: Monaco.Environment = {
57 getWorkerUrl: () => "/editor.worker.js",
58 };
59 (self as Window).MonacoEnvironment = monacoEnv;
60
61 // Load Monaco CSS if not already loaded
62 if (!document.querySelector('link[href="/monaco-editor.css"]')) {
63 const link = document.createElement("link");
64 link.rel = "stylesheet";
65 link.href = "/monaco-editor.css";
66 document.head.appendChild(link);
67 }
68
69 // Load Monaco from our local bundle (runtime URL, cast to proper types)
70 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
71 // @ts-ignore - dynamic runtime URL import
72 const monaco = (await import("/monaco-editor.js")) as typeof Monaco;
73 monacoInstance = monaco;
74 return monacoInstance;
75 })();
76
77 return monacoLoadPromise;
78}
79
80type ViewMode = "comment" | "edit";
81
82function DiffViewer({ cwd, isOpen, onClose, onCommentTextChange, initialCommit }: DiffViewerProps) {
83 const [diffs, setDiffs] = useState<GitDiffInfo[]>([]);
84 const [gitRoot, setGitRoot] = useState<string | null>(null);
85 const [selectedDiff, setSelectedDiff] = useState<string | null>(null);
86 const [files, setFiles] = useState<GitFileInfo[]>([]);
87 const [selectedFile, setSelectedFile] = useState<string | null>(null);
88 const [fileDiff, setFileDiff] = useState<GitFileDiff | null>(null);
89 const [loading, setLoading] = useState(false);
90 const [error, setError] = useState<string | null>(null);
91 const [monacoLoaded, setMonacoLoaded] = useState(false);
92 const [currentChangeIndex, setCurrentChangeIndex] = useState<number>(-1);
93 const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
94 const saveTimeoutRef = useRef<number | null>(null);
95 const pendingSaveRef = useRef<(() => Promise<void>) | null>(null);
96 const scheduleSaveRef = useRef<(() => void) | null>(null);
97 const contentChangeDisposableRef = useRef<Monaco.IDisposable | null>(null);
98 const [showCommentDialog, setShowCommentDialog] = useState<{
99 line: number;
100 side: "left" | "right";
101 selectedText?: string;
102 startLine?: number;
103 endLine?: number;
104 } | null>(null);
105 const [commentText, setCommentText] = useState("");
106 const [mode, setMode] = useState<ViewMode>("comment");
107 const [showKeyboardHint, setShowKeyboardHint] = useState(false);
108 const hasShownKeyboardHint = useRef(false);
109
110 const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
111 const editorContainerRef = useRef<HTMLDivElement>(null);
112 const editorRef = useRef<Monaco.editor.IStandaloneDiffEditor | null>(null);
113 const monacoRef = useRef<typeof Monaco | null>(null);
114 const commentInputRef = useRef<HTMLTextAreaElement>(null);
115 const modeRef = useRef<ViewMode>(mode);
116 const hoverDecorationsRef = useRef<string[]>([]);
117
118 // Keep modeRef in sync with mode state and update editor options
119 useEffect(() => {
120 modeRef.current = mode;
121 // Update editor readOnly state when mode changes
122 if (editorRef.current) {
123 const modifiedEditor = editorRef.current.getModifiedEditor();
124 modifiedEditor.updateOptions({ readOnly: mode === "comment" });
125 }
126 }, [mode]);
127
128 // Track viewport size
129 useEffect(() => {
130 const handleResize = () => {
131 setIsMobile(window.innerWidth < 768);
132 };
133 window.addEventListener("resize", handleResize);
134 return () => window.removeEventListener("resize", handleResize);
135 }, []);
136
137 // Focus comment input when dialog opens
138 useEffect(() => {
139 if (showCommentDialog && commentInputRef.current) {
140 // Small delay to ensure the dialog is rendered
141 setTimeout(() => {
142 commentInputRef.current?.focus();
143 }, 50);
144 }
145 }, [showCommentDialog]);
146
147 // Load Monaco when viewer opens
148 useEffect(() => {
149 if (isOpen && !monacoLoaded) {
150 loadMonaco()
151 .then((monaco) => {
152 monacoRef.current = monaco;
153 setMonacoLoaded(true);
154 })
155 .catch((err) => {
156 console.error("Failed to load Monaco:", err);
157 setError("Failed to load diff editor");
158 });
159 }
160 }, [isOpen, monacoLoaded]);
161
162 // Show keyboard hint toast on first open (desktop only)
163 useEffect(() => {
164 if (isOpen && !isMobile && !hasShownKeyboardHint.current && fileDiff) {
165 hasShownKeyboardHint.current = true;
166 setShowKeyboardHint(true);
167 }
168 }, [isOpen, isMobile, fileDiff]);
169
170 // Auto-hide keyboard hint after 6 seconds
171 useEffect(() => {
172 if (showKeyboardHint) {
173 const timer = setTimeout(() => setShowKeyboardHint(false), 6000);
174 return () => clearTimeout(timer);
175 }
176 }, [showKeyboardHint]);
177
178 // Load diffs when viewer opens, reset state when it closes
179 useEffect(() => {
180 if (isOpen && cwd) {
181 loadDiffs();
182 } else if (!isOpen) {
183 // Reset state when closing
184 setFileDiff(null);
185 setSelectedFile(null);
186 setFiles([]);
187 setSelectedDiff(null);
188 setDiffs([]);
189 setError(null);
190 setShowCommentDialog(null);
191 setCommentText("");
192 // Dispose editor when closing
193 if (editorRef.current) {
194 editorRef.current.dispose();
195 editorRef.current = null;
196 }
197 }
198 }, [isOpen, cwd, initialCommit]);
199
200 // Load files when diff is selected
201 useEffect(() => {
202 if (selectedDiff && cwd) {
203 loadFiles(selectedDiff);
204 }
205 }, [selectedDiff, cwd]);
206
207 // Load file diff when file is selected
208 useEffect(() => {
209 if (selectedDiff && selectedFile && cwd) {
210 loadFileDiff(selectedDiff, selectedFile);
211 setCurrentChangeIndex(-1); // Reset change index for new file
212 }
213 }, [selectedDiff, selectedFile, cwd]);
214
215 // Create/update Monaco editor when fileDiff changes
216 useEffect(() => {
217 if (!monacoLoaded || !fileDiff || !editorContainerRef.current || !monacoRef.current) {
218 return;
219 }
220
221 const monaco = monacoRef.current;
222
223 // Dispose previous editor
224 if (editorRef.current) {
225 editorRef.current.dispose();
226 editorRef.current = null;
227 }
228
229 // Get language from file extension
230 const ext = "." + (fileDiff.path.split(".").pop()?.toLowerCase() || "");
231 const languages = monaco.languages.getLanguages();
232 let language = "plaintext";
233 for (const lang of languages) {
234 if (lang.extensions?.includes(ext)) {
235 language = lang.id;
236 break;
237 }
238 }
239
240 // Create models with unique URIs (include timestamp to avoid conflicts)
241 const timestamp = Date.now();
242 const originalUri = monaco.Uri.file(`original-${timestamp}-${fileDiff.path}`);
243 const modifiedUri = monaco.Uri.file(`modified-${timestamp}-${fileDiff.path}`);
244
245 const originalModel = monaco.editor.createModel(fileDiff.oldContent, language, originalUri);
246 const modifiedModel = monaco.editor.createModel(fileDiff.newContent, language, modifiedUri);
247
248 // Create diff editor with mobile-friendly options
249 const diffEditor = monaco.editor.createDiffEditor(editorContainerRef.current, {
250 theme: isDarkModeActive() ? "vs-dark" : "vs",
251 readOnly: true, // Always read-only in diff viewer
252 originalEditable: false,
253 automaticLayout: true,
254 renderSideBySide: !isMobile,
255 enableSplitViewResizing: true,
256 renderIndicators: true,
257 renderMarginRevertIcon: false,
258 lineNumbers: isMobile ? "off" : "on",
259 minimap: { enabled: false },
260 scrollBeyondLastLine: true, // Enable scroll past end for mobile floating buttons
261 wordWrap: "on",
262 glyphMargin: !isMobile, // Enable glyph margin for comment indicator on hover
263 lineDecorationsWidth: isMobile ? 0 : 10,
264 lineNumbersMinChars: isMobile ? 0 : 3,
265 quickSuggestions: false,
266 suggestOnTriggerCharacters: false,
267 lightbulb: { enabled: false },
268 codeLens: false,
269 contextmenu: false,
270 links: false,
271 folding: !isMobile,
272 padding: isMobile ? { bottom: 80 } : undefined, // Extra padding for floating buttons on mobile
273 });
274
275 diffEditor.setModel({
276 original: originalModel,
277 modified: modifiedModel,
278 });
279
280 editorRef.current = diffEditor;
281
282 // Auto-scroll to first diff when Monaco finishes computing it (once per file)
283 let hasScrolledToFirstChange = false;
284 const scrollToFirstChange = () => {
285 if (hasScrolledToFirstChange) return;
286 const changes = diffEditor.getLineChanges();
287 if (changes && changes.length > 0) {
288 hasScrolledToFirstChange = true;
289 const firstChange = changes[0];
290 const targetLine = firstChange.modifiedStartLineNumber || 1;
291 const editor = diffEditor.getModifiedEditor();
292 editor.revealLineInCenter(targetLine);
293 editor.setPosition({ lineNumber: targetLine, column: 1 });
294 setCurrentChangeIndex(0);
295 }
296 };
297
298 // Try immediately in case diff is already computed, then listen for update
299 scrollToFirstChange();
300 const diffUpdateDisposable = diffEditor.onDidUpdateDiff(scrollToFirstChange);
301
302 // Add click handler for commenting - clicking on a line in comment mode opens dialog
303 const modifiedEditor = diffEditor.getModifiedEditor();
304
305 // Handler function for opening comment dialog
306 const openCommentDialog = (lineNumber: number) => {
307 const model = modifiedEditor.getModel();
308 const selection = modifiedEditor.getSelection();
309 let selectedText = "";
310 let startLine = lineNumber;
311 let endLine = lineNumber;
312
313 if (selection && !selection.isEmpty() && model) {
314 selectedText = model.getValueInRange(selection);
315 startLine = selection.startLineNumber;
316 endLine = selection.endLineNumber;
317 } else if (model) {
318 selectedText = model.getLineContent(lineNumber) || "";
319 }
320
321 setShowCommentDialog({
322 line: startLine,
323 side: "right",
324 selectedText,
325 startLine,
326 endLine,
327 });
328 };
329
330 modifiedEditor.onMouseDown((e: Monaco.editor.IEditorMouseEvent) => {
331 // In comment mode, clicking on line content opens comment dialog
332 const isLineClick =
333 e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT ||
334 e.target.type === monaco.editor.MouseTargetType.CONTENT_EMPTY;
335
336 if (isLineClick && modeRef.current === "comment") {
337 const position = e.target.position;
338 if (position) {
339 openCommentDialog(position.lineNumber);
340 }
341 }
342 });
343
344 // For mobile: use onMouseUp which fires more reliably on touch devices
345 if (isMobile) {
346 modifiedEditor.onMouseUp((e: Monaco.editor.IEditorMouseEvent) => {
347 if (modeRef.current !== "comment") return;
348
349 const isLineClick =
350 e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT ||
351 e.target.type === monaco.editor.MouseTargetType.CONTENT_EMPTY;
352
353 if (isLineClick) {
354 const position = e.target.position;
355 if (position) {
356 openCommentDialog(position.lineNumber);
357 }
358 }
359 });
360 }
361
362 // Add hover highlighting with comment indicator (comment mode only)
363 let lastHoveredLine = -1;
364 modifiedEditor.onMouseMove((e: Monaco.editor.IEditorMouseEvent) => {
365 // Only show hover effects in comment mode
366 if (modeRef.current !== "comment") {
367 if (hoverDecorationsRef.current.length > 0) {
368 hoverDecorationsRef.current = modifiedEditor.deltaDecorations(
369 hoverDecorationsRef.current,
370 [],
371 );
372 }
373 return;
374 }
375
376 const position = e.target.position;
377 const lineNumber = position?.lineNumber ?? -1;
378
379 if (lineNumber === lastHoveredLine) return;
380 lastHoveredLine = lineNumber;
381
382 if (lineNumber > 0) {
383 hoverDecorationsRef.current = modifiedEditor.deltaDecorations(hoverDecorationsRef.current, [
384 {
385 range: new monaco.Range(lineNumber, 1, lineNumber, 1),
386 options: {
387 isWholeLine: true,
388 className: "diff-viewer-line-hover",
389 glyphMarginClassName: "diff-viewer-comment-glyph",
390 },
391 },
392 ]);
393 } else {
394 hoverDecorationsRef.current = modifiedEditor.deltaDecorations(
395 hoverDecorationsRef.current,
396 [],
397 );
398 }
399 });
400
401 // Clear decorations when mouse leaves editor
402 modifiedEditor.onMouseLeave(() => {
403 lastHoveredLine = -1;
404 hoverDecorationsRef.current = modifiedEditor.deltaDecorations(
405 hoverDecorationsRef.current,
406 [],
407 );
408 });
409
410 // Add content change listener for auto-save
411 contentChangeDisposableRef.current?.dispose();
412 contentChangeDisposableRef.current = modifiedEditor.onDidChangeModelContent(() => {
413 scheduleSaveRef.current?.();
414 });
415
416 // Cleanup function
417 return () => {
418 diffUpdateDisposable.dispose();
419 contentChangeDisposableRef.current?.dispose();
420 contentChangeDisposableRef.current = null;
421 if (editorRef.current) {
422 editorRef.current.dispose();
423 editorRef.current = null;
424 }
425 };
426 }, [monacoLoaded, fileDiff, isMobile]);
427
428 const loadDiffs = async () => {
429 try {
430 setLoading(true);
431 setError(null);
432 const response = await api.getGitDiffs(cwd);
433 setDiffs(response.diffs);
434 setGitRoot(response.gitRoot);
435
436 // If initialCommit is set, try to select that commit
437 if (initialCommit) {
438 const matchingDiff = response.diffs.find(
439 (d) => d.id === initialCommit || d.id.startsWith(initialCommit),
440 );
441 if (matchingDiff) {
442 setSelectedDiff(matchingDiff.id);
443 return;
444 }
445 }
446
447 // Auto-select working changes if non-empty
448 if (response.diffs.length > 0) {
449 const working = response.diffs.find((d) => d.id === "working");
450 if (working && working.filesCount > 0) {
451 setSelectedDiff("working");
452 } else if (response.diffs.length > 1) {
453 setSelectedDiff(response.diffs[1].id);
454 }
455 }
456 } catch (err) {
457 const errStr = String(err);
458 if (errStr.toLowerCase().includes("not a git repository")) {
459 setError(`Not a git repository: ${cwd}`);
460 } else {
461 setError(`Failed to load diffs: ${errStr}`);
462 }
463 } finally {
464 setLoading(false);
465 }
466 };
467
468 const loadFiles = async (diffId: string) => {
469 try {
470 setLoading(true);
471 setError(null);
472 const filesData = await api.getGitDiffFiles(diffId, cwd);
473 setFiles(filesData || []);
474 if (filesData && filesData.length > 0) {
475 setSelectedFile(filesData[0].path);
476 } else {
477 setSelectedFile(null);
478 setFileDiff(null);
479 }
480 } catch (err) {
481 setError(`Failed to load files: ${err}`);
482 } finally {
483 setLoading(false);
484 }
485 };
486
487 const loadFileDiff = async (diffId: string, filePath: string) => {
488 try {
489 setLoading(true);
490 setError(null);
491 const diffData = await api.getGitFileDiff(diffId, filePath, cwd);
492 setFileDiff(diffData);
493 } catch (err) {
494 setError(`Failed to load file diff: ${err}`);
495 } finally {
496 setLoading(false);
497 }
498 };
499
500 const handleAddComment = () => {
501 if (!showCommentDialog || !commentText.trim() || !selectedFile) return;
502
503 // Format: > filename:123: code
504 // Comment...
505 const line = showCommentDialog.line;
506 const codeSnippet = showCommentDialog.selectedText?.split("\n")[0]?.trim() || "";
507 const truncatedCode =
508 codeSnippet.length > 60 ? codeSnippet.substring(0, 57) + "..." : codeSnippet;
509
510 const commentBlock = `> ${selectedFile}:${line}: ${truncatedCode}\n${commentText}\n\n`;
511
512 onCommentTextChange(commentBlock);
513 setShowCommentDialog(null);
514 setCommentText("");
515 };
516
517 const goToNextFile = useCallback(() => {
518 if (files.length === 0 || !selectedFile) return false;
519 const idx = files.findIndex((f) => f.path === selectedFile);
520 if (idx < files.length - 1) {
521 setSelectedFile(files[idx + 1].path);
522 setCurrentChangeIndex(-1); // Reset to start of new file
523 return true;
524 }
525 return false;
526 }, [files, selectedFile]);
527
528 const goToPreviousFile = useCallback(() => {
529 if (files.length === 0 || !selectedFile) return false;
530 const idx = files.findIndex((f) => f.path === selectedFile);
531 if (idx > 0) {
532 setSelectedFile(files[idx - 1].path);
533 setCurrentChangeIndex(-1); // Will go to last change when file loads
534 return true;
535 }
536 return false;
537 }, [files, selectedFile]);
538
539 const goToNextChange = useCallback(() => {
540 if (!editorRef.current) return;
541 const changes = editorRef.current.getLineChanges();
542 if (!changes || changes.length === 0) {
543 // No changes in this file, try next file
544 goToNextFile();
545 return;
546 }
547
548 const modifiedEditor = editorRef.current.getModifiedEditor();
549 const visibleRanges = modifiedEditor.getVisibleRanges();
550 const viewBottom = visibleRanges.length > 0 ? visibleRanges[0].endLineNumber : 0;
551
552 // Find the next change that starts below the current view
553 // This ensures we always move "down" and never scroll up
554 let nextIdx = -1;
555 for (let i = 0; i < changes.length; i++) {
556 const changeLine = changes[i].modifiedStartLineNumber || 1;
557 if (changeLine > viewBottom) {
558 nextIdx = i;
559 break;
560 }
561 }
562
563 if (nextIdx === -1) {
564 // No more changes below current view, try to go to next file
565 if (goToNextFile()) {
566 return;
567 }
568 // No next file, stay where we are
569 return;
570 }
571
572 const change = changes[nextIdx];
573 const targetLine = change.modifiedStartLineNumber || 1;
574 modifiedEditor.revealLineInCenter(targetLine);
575 modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
576 setCurrentChangeIndex(nextIdx);
577 }, [goToNextFile]);
578
579 const goToPreviousChange = useCallback(() => {
580 if (!editorRef.current) return;
581 const changes = editorRef.current.getLineChanges();
582 if (!changes || changes.length === 0) {
583 // No changes in this file, try previous file
584 goToPreviousFile();
585 return;
586 }
587
588 const modifiedEditor = editorRef.current.getModifiedEditor();
589 const prevIdx = currentChangeIndex <= 0 ? -1 : currentChangeIndex - 1;
590
591 if (prevIdx < 0) {
592 // At start of file, try to go to previous file
593 if (goToPreviousFile()) {
594 return;
595 }
596 // No previous file, go to first change
597 const change = changes[0];
598 const targetLine = change.modifiedStartLineNumber || 1;
599 modifiedEditor.revealLineInCenter(targetLine);
600 modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
601 setCurrentChangeIndex(0);
602 return;
603 }
604
605 const change = changes[prevIdx];
606 const targetLine = change.modifiedStartLineNumber || 1;
607 modifiedEditor.revealLineInCenter(targetLine);
608 modifiedEditor.setPosition({ lineNumber: targetLine, column: 1 });
609 setCurrentChangeIndex(prevIdx);
610 }, [currentChangeIndex, goToPreviousFile]);
611
612 // Save the current file (in edit mode)
613 const saveCurrentFile = useCallback(async () => {
614 if (
615 !editorRef.current ||
616 !selectedFile ||
617 !fileDiff ||
618 modeRef.current !== "edit" ||
619 !gitRoot
620 ) {
621 return;
622 }
623
624 const modifiedEditor = editorRef.current.getModifiedEditor();
625 const model = modifiedEditor.getModel();
626 if (!model) return;
627
628 const content = model.getValue();
629 const fullPath = gitRoot + "/" + selectedFile;
630
631 try {
632 setSaveStatus("saving");
633 const response = await fetch("/api/write-file", {
634 method: "POST",
635 headers: { "Content-Type": "application/json" },
636 body: JSON.stringify({ path: fullPath, content }),
637 });
638
639 if (response.ok) {
640 setSaveStatus("saved");
641 setTimeout(() => setSaveStatus("idle"), 2000);
642 } else {
643 setSaveStatus("error");
644 setTimeout(() => setSaveStatus("idle"), 3000);
645 }
646 } catch (err) {
647 console.error("Failed to save:", err);
648 setSaveStatus("error");
649 setTimeout(() => setSaveStatus("idle"), 3000);
650 }
651 }, [selectedFile, fileDiff, gitRoot]);
652
653 // Debounced auto-save
654 const scheduleSave = useCallback(() => {
655 if (modeRef.current !== "edit") return; // Only auto-save in edit mode
656 if (saveTimeoutRef.current) {
657 clearTimeout(saveTimeoutRef.current);
658 }
659 pendingSaveRef.current = saveCurrentFile;
660 saveTimeoutRef.current = window.setTimeout(() => {
661 pendingSaveRef.current?.();
662 pendingSaveRef.current = null;
663 saveTimeoutRef.current = null;
664 }, 1000);
665 }, [saveCurrentFile]);
666
667 // Keep scheduleSaveRef in sync
668 useEffect(() => {
669 scheduleSaveRef.current = scheduleSave;
670 }, [scheduleSave]);
671
672 // Force immediate save (for Ctrl+S)
673 const saveImmediately = useCallback(() => {
674 if (saveTimeoutRef.current) {
675 clearTimeout(saveTimeoutRef.current);
676 saveTimeoutRef.current = null;
677 }
678 pendingSaveRef.current = null;
679 saveCurrentFile();
680 }, [saveCurrentFile]);
681
682 // Update Monaco theme when dark mode changes
683 useEffect(() => {
684 if (!monacoRef.current) return;
685
686 const updateMonacoTheme = () => {
687 const theme = isDarkModeActive() ? "vs-dark" : "vs";
688 monacoRef.current?.editor.setTheme(theme);
689 };
690
691 // Watch for changes to the dark class on documentElement
692 const observer = new MutationObserver((mutations) => {
693 for (const mutation of mutations) {
694 if (mutation.attributeName === "class") {
695 updateMonacoTheme();
696 }
697 }
698 });
699
700 observer.observe(document.documentElement, { attributes: true });
701
702 return () => observer.disconnect();
703 }, [monacoLoaded]);
704
705 // Keyboard shortcuts
706 useEffect(() => {
707 if (!isOpen) return;
708
709 const handleKeyDown = (e: KeyboardEvent) => {
710 if (e.key === "Escape") {
711 if (showCommentDialog) {
712 setShowCommentDialog(null);
713 } else {
714 onClose();
715 }
716 return;
717 }
718 if ((e.ctrlKey || e.metaKey) && e.key === "s") {
719 e.preventDefault();
720 saveImmediately();
721 return;
722 }
723
724 // Intercept PageUp/PageDown to scroll the diff editor instead of background
725 if (e.key === "PageUp" || e.key === "PageDown") {
726 if (editorRef.current) {
727 e.preventDefault();
728 e.stopPropagation();
729 const modifiedEditor = editorRef.current.getModifiedEditor();
730 // Trigger the editor's built-in page up/down action
731 modifiedEditor.trigger(
732 "keyboard",
733 e.key === "PageUp" ? "cursorPageUp" : "cursorPageDown",
734 null,
735 );
736 }
737 return;
738 }
739
740 // Comment mode navigation shortcuts (only when comment dialog is closed)
741 if (mode === "comment" && !showCommentDialog) {
742 if (e.key === ".") {
743 e.preventDefault();
744 goToNextChange();
745 return;
746 } else if (e.key === ",") {
747 e.preventDefault();
748 goToPreviousChange();
749 return;
750 } else if (e.key === ">") {
751 e.preventDefault();
752 goToNextFile();
753 return;
754 } else if (e.key === "<") {
755 e.preventDefault();
756 goToPreviousFile();
757 return;
758 }
759 }
760
761 if (!e.ctrlKey) return;
762 if (e.key === "j") {
763 e.preventDefault();
764 goToNextFile();
765 } else if (e.key === "k") {
766 e.preventDefault();
767 goToPreviousFile();
768 }
769 };
770
771 // Use capture phase to intercept events before Monaco editor handles them
772 window.addEventListener("keydown", handleKeyDown, true);
773 return () => window.removeEventListener("keydown", handleKeyDown, true);
774 }, [
775 isOpen,
776 goToNextFile,
777 goToPreviousFile,
778 goToNextChange,
779 goToPreviousChange,
780 showCommentDialog,
781 onClose,
782 saveImmediately,
783 mode,
784 ]);
785
786 if (!isOpen) return null;
787
788 const getStatusSymbol = (status: string) => {
789 switch (status) {
790 case "added":
791 return "+";
792 case "deleted":
793 return "-";
794 case "modified":
795 return "~";
796 default:
797 return "";
798 }
799 };
800
801 const currentFileIndex = files.findIndex((f) => f.path === selectedFile);
802 const hasNextFile = currentFileIndex < files.length - 1;
803 const hasPrevFile = currentFileIndex > 0;
804
805 // Selectors shared between desktop and mobile
806 const commitSelector = (
807 <select
808 value={selectedDiff || ""}
809 onChange={(e) => setSelectedDiff(e.target.value || null)}
810 className="diff-viewer-select"
811 >
812 <option value="">Choose base...</option>
813 {diffs.map((diff) => {
814 const stats = `${diff.filesCount} files, +${diff.additions}/-${diff.deletions}`;
815 return (
816 <option key={diff.id} value={diff.id}>
817 {diff.id === "working"
818 ? `Working Changes (${stats})`
819 : `${diff.message.slice(0, 40)} (${stats})`}
820 </option>
821 );
822 })}
823 </select>
824 );
825
826 const fileIndexIndicator =
827 files.length > 1 && currentFileIndex >= 0 ? `(${currentFileIndex + 1}/${files.length})` : null;
828
829 const fileSelector = (
830 <div className="diff-viewer-file-selector-wrapper">
831 <select
832 value={selectedFile || ""}
833 onChange={(e) => setSelectedFile(e.target.value || null)}
834 className="diff-viewer-select"
835 disabled={files.length === 0}
836 >
837 <option value="">{files.length === 0 ? "No files" : "Choose file..."}</option>
838 {files.map((file) => (
839 <option key={file.path} value={file.path}>
840 {getStatusSymbol(file.status)} {file.path}
841 {file.additions > 0 && ` (+${file.additions})`}
842 {file.deletions > 0 && ` (-${file.deletions})`}
843 {file.isGenerated && " [generated]"}
844 </option>
845 ))}
846 </select>
847 {fileIndexIndicator && <span className="diff-viewer-file-index">{fileIndexIndicator}</span>}
848 </div>
849 );
850
851 const modeToggle = (
852 <div className="diff-viewer-mode-toggle">
853 <button
854 className={`diff-viewer-mode-btn ${mode === "comment" ? "active" : ""}`}
855 onClick={() => setMode("comment")}
856 title="Comment mode"
857 >
858 💬
859 </button>
860 <button
861 className={`diff-viewer-mode-btn ${mode === "edit" ? "active" : ""}`}
862 onClick={() => setMode("edit")}
863 title="Edit mode"
864 >
865 ✏️
866 </button>
867 </div>
868 );
869
870 const navButtons = (
871 <div className="diff-viewer-nav-buttons">
872 <button
873 className="diff-viewer-nav-btn"
874 onClick={goToPreviousFile}
875 disabled={!hasPrevFile}
876 title="Previous file (<)"
877 >
878 <PrevFileIcon />
879 </button>
880 <button
881 className="diff-viewer-nav-btn"
882 onClick={goToPreviousChange}
883 disabled={!fileDiff}
884 title="Previous change (,)"
885 >
886 <PrevChangeIcon />
887 </button>
888 <button
889 className="diff-viewer-nav-btn"
890 onClick={goToNextChange}
891 disabled={!fileDiff}
892 title="Next change (.)"
893 >
894 <NextChangeIcon />
895 </button>
896 <button
897 className="diff-viewer-nav-btn"
898 onClick={() => goToNextFile()}
899 disabled={!hasNextFile}
900 title="Next file (>)"
901 >
902 <NextFileIcon />
903 </button>
904 </div>
905 );
906
907 return (
908 <div className="diff-viewer-overlay">
909 <div className="diff-viewer-container">
910 {/* Toast notification */}
911 {saveStatus !== "idle" && (
912 <div className={`diff-viewer-toast diff-viewer-toast-${saveStatus}`}>
913 {saveStatus === "saving" && "💾 Saving..."}
914 {saveStatus === "saved" && "✅ Saved"}
915 {saveStatus === "error" && "❌ Error saving"}
916 </div>
917 )}
918 {showKeyboardHint && (
919 <div className="diff-viewer-toast diff-viewer-toast-hint">
920 ⌨️ Use . , for next/prev change, < > for files
921 </div>
922 )}
923
924 {/* Header - different layout for desktop vs mobile */}
925 {isMobile ? (
926 // Mobile header: just selectors 50/50
927 <div className="diff-viewer-header diff-viewer-header-mobile">
928 <div className="diff-viewer-mobile-selectors">
929 {commitSelector}
930 {fileSelector}
931 </div>
932 <button className="diff-viewer-close" onClick={onClose} title="Close (Esc)">
933 ×
934 </button>
935 </div>
936 ) : (
937 // Desktop header: selectors expand, controls on right
938 <div className="diff-viewer-header">
939 <div className="diff-viewer-header-row">
940 <div className="diff-viewer-selectors-row">
941 {commitSelector}
942 {fileSelector}
943 </div>
944 <div className="diff-viewer-controls-row">
945 {navButtons}
946 {modeToggle}
947 <button className="diff-viewer-close" onClick={onClose} title="Close (Esc)">
948 ×
949 </button>
950 </div>
951 </div>
952 </div>
953 )}
954
955 {/* Error banner */}
956 {error && <div className="diff-viewer-error">{error}</div>}
957
958 {/* Main content */}
959 <div className="diff-viewer-content">
960 {loading && !fileDiff && (
961 <div className="diff-viewer-loading">
962 <div className="spinner"></div>
963 <span>Loading...</span>
964 </div>
965 )}
966
967 {!loading && !monacoLoaded && !error && (
968 <div className="diff-viewer-loading">
969 <div className="spinner"></div>
970 <span>Loading editor...</span>
971 </div>
972 )}
973
974 {!loading && monacoLoaded && !fileDiff && !error && (
975 <div className="diff-viewer-empty">
976 <p>Select a diff and file to view changes.</p>
977 <p className="diff-viewer-hint">Click on line numbers to add comments.</p>
978 </div>
979 )}
980
981 {/* Monaco editor container */}
982 <div
983 ref={editorContainerRef}
984 className="diff-viewer-editor"
985 style={{ display: fileDiff && monacoLoaded ? "block" : "none" }}
986 />
987 </div>
988
989 {/* Mobile floating nav buttons at bottom */}
990 {isMobile && (
991 <div className="diff-viewer-mobile-nav">
992 <button
993 className={`diff-viewer-mobile-nav-btn diff-viewer-mobile-mode-btn ${mode === "comment" ? "active" : ""}`}
994 onClick={() => setMode(mode === "comment" ? "edit" : "comment")}
995 title={
996 mode === "comment" ? "Comment mode (tap to switch)" : "Edit mode (tap to switch)"
997 }
998 >
999 {mode === "comment" ? "💬" : "✏️"}
1000 </button>
1001 <button
1002 className="diff-viewer-mobile-nav-btn"
1003 onClick={goToPreviousFile}
1004 disabled={!hasPrevFile}
1005 title="Previous file (<)"
1006 >
1007 <PrevFileIcon />
1008 </button>
1009 <button
1010 className="diff-viewer-mobile-nav-btn"
1011 onClick={goToPreviousChange}
1012 disabled={!fileDiff}
1013 title="Previous change (,)"
1014 >
1015 <PrevChangeIcon />
1016 </button>
1017 <button
1018 className="diff-viewer-mobile-nav-btn"
1019 onClick={goToNextChange}
1020 disabled={!fileDiff}
1021 title="Next change (.)"
1022 >
1023 <NextChangeIcon />
1024 </button>
1025 <button
1026 className="diff-viewer-mobile-nav-btn"
1027 onClick={() => goToNextFile()}
1028 disabled={!hasNextFile}
1029 title="Next file (>)"
1030 >
1031 <NextFileIcon />
1032 </button>
1033 </div>
1034 )}
1035
1036 {/* Comment dialog */}
1037 {showCommentDialog && (
1038 <div className="diff-viewer-comment-dialog">
1039 <h4>
1040 Add Comment (Line
1041 {showCommentDialog.startLine !== showCommentDialog.endLine
1042 ? `s ${showCommentDialog.startLine}-${showCommentDialog.endLine}`
1043 : ` ${showCommentDialog.line}`}
1044 , {showCommentDialog.side === "left" ? "old" : "new"})
1045 </h4>
1046 {showCommentDialog.selectedText && (
1047 <pre className="diff-viewer-selected-text">{showCommentDialog.selectedText}</pre>
1048 )}
1049 <textarea
1050 ref={commentInputRef}
1051 value={commentText}
1052 onChange={(e) => setCommentText(e.target.value)}
1053 placeholder="Enter your comment..."
1054 className="diff-viewer-comment-input"
1055 autoFocus
1056 />
1057 <div className="diff-viewer-comment-actions">
1058 <button
1059 onClick={() => setShowCommentDialog(null)}
1060 className="diff-viewer-btn diff-viewer-btn-secondary"
1061 >
1062 Cancel
1063 </button>
1064 <button
1065 onClick={handleAddComment}
1066 className="diff-viewer-btn diff-viewer-btn-primary"
1067 disabled={!commentText.trim()}
1068 >
1069 Add Comment
1070 </button>
1071 </div>
1072 </div>
1073 )}
1074 </div>
1075 </div>
1076 );
1077}
1078
1079export default DiffViewer;