DiffViewer.tsx

   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, &lt; &gt; 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;