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