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