diff --git a/src/components/editor/overlays/editor-context-menu.tsx b/src/components/editor/overlays/editor-context-menu.tsx index f7e95ade..bcfbc23d 100644 --- a/src/components/editor/overlays/editor-context-menu.tsx +++ b/src/components/editor/overlays/editor-context-menu.tsx @@ -8,6 +8,7 @@ import { Code, Copy, FileText, + GitMerge, Indent, Outdent, RotateCcw, @@ -18,6 +19,7 @@ import { } from "lucide-react"; import { useEffect, useRef } from "react"; import { useEditorCursorStore } from "@/stores/editor-cursor-store"; +import { useEditorSettingsStore } from "@/stores/editor-settings-store"; import KeybindingBadge from "../../ui/keybinding-badge"; interface EditorContextMenuProps { @@ -41,6 +43,7 @@ interface EditorContextMenuProps { onMoveLineDown?: () => void; onInsertLine?: () => void; onToggleBookmark?: () => void; + onToggleInlineDiff?: () => void; } const EditorContextMenu = ({ @@ -64,10 +67,13 @@ const EditorContextMenu = ({ onMoveLineDown, onInsertLine, onToggleBookmark, + onToggleInlineDiff, }: EditorContextMenuProps) => { const menuRef = useRef(null); const selection = useEditorCursorStore.use.selection?.() ?? undefined; const hasSelection = selection && selection.start.offset !== selection.end.offset; + const showInlineDiff = useEditorSettingsStore.use.showInlineDiff(); + const { setShowInlineDiff } = useEditorSettingsStore.use.actions(); useEffect(() => { if (!isOpen) return; @@ -232,6 +238,16 @@ const EditorContextMenu = ({ onClose(); }; + const handleToggleInlineDiff = () => { + if (onToggleInlineDiff) { + onToggleInlineDiff(); + } else { + // Default behavior: toggle the setting directly + setShowInlineDiff(!showInlineDiff); + } + onClose(); + }; + return (
+ +
+ + {/* Toggle Inline Git Diff */} +
); }; diff --git a/src/components/editor/rendering/editor-viewport.tsx b/src/components/editor/rendering/editor-viewport.tsx index 8538719f..1dcbe034 100644 --- a/src/components/editor/rendering/editor-viewport.tsx +++ b/src/components/editor/rendering/editor-viewport.tsx @@ -1,11 +1,15 @@ import type React from "react"; import { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { EDITOR_CONSTANTS } from "@/constants/editor-constants"; +import { useFileSystemStore } from "@/file-system/controllers/store"; import { useEditorLayout } from "@/hooks/use-editor-layout"; import { useEditorCursorStore } from "@/stores/editor-cursor-store"; +import { useEditorInstanceStore } from "@/stores/editor-instance-store"; import { useEditorLayoutStore } from "@/stores/editor-layout-store"; import { useEditorSettingsStore } from "@/stores/editor-settings-store"; import { useEditorViewStore } from "@/stores/editor-view-store"; +import { getFileDiffAgainstContent } from "@/version-control/git/controllers/git"; +import type { GitDiff, GitDiffLine } from "@/version-control/git/models/git-types"; import { LineWithContent } from "./line-with-content"; interface EditorViewportProps { @@ -22,21 +26,149 @@ export const EditorViewport = memo( forwardRef( ({ onScroll, onClick, onMouseDown, onMouseMove, onMouseUp, onContextMenu }, ref) => { const selection = useEditorCursorStore((state) => state.selection); - const lineCount = useEditorViewStore((state) => state.lines.length); + const lines = useEditorViewStore((state) => state.lines); + const storeDiffData = useEditorViewStore((state) => state.diffData) as GitDiff | undefined; + const { getContent } = useEditorViewStore.use.actions(); const showLineNumbers = useEditorSettingsStore.use.lineNumbers(); + const showInlineDiff = useEditorSettingsStore.use.showInlineDiff(); const scrollTop = useEditorLayoutStore.use.scrollTop(); const viewportHeight = useEditorLayoutStore.use.viewportHeight(); const tabSize = useEditorSettingsStore.use.tabSize(); const { lineHeight, gutterWidth } = useEditorLayout(); + const { filePath } = useEditorInstanceStore(); + const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); + + // Maintain a local, content-based diff for live typing scenarios when inline diff is enabled + const [contentDiff, setContentDiff] = useState(undefined); + + useEffect(() => { + if (!showInlineDiff || !rootFolderPath || !filePath) { + setContentDiff(undefined); + return; + } + + const content = getContent(); + let timer: ReturnType | null = null; + + const run = async () => { + try { + // Compute relative path + let relativePath = filePath; + if (relativePath.startsWith(rootFolderPath)) { + relativePath = relativePath.slice(rootFolderPath.length); + if (relativePath.startsWith("/")) relativePath = relativePath.slice(1); + } + const diff = await getFileDiffAgainstContent( + rootFolderPath, + relativePath, + content, + "head", + ); + setContentDiff(diff ?? undefined); + } catch (e) { + console.error(e); + } + }; + + // Debounce updates to avoid frequent diff calculations while typing + timer = setTimeout(run, 500); + return () => { + if (timer) clearTimeout(timer); + }; + // Depend on lines so we refresh when content changes; getContent() returns latest content + }, [showInlineDiff, rootFolderPath, filePath, lines, getContent]); + + const diffData = showInlineDiff ? (contentDiff ?? storeDiffData) : undefined; + + // Create a unified view of lines including both buffer and diff-only lines + const unifiedLines = useMemo(() => { + if (!showInlineDiff || !diffData?.lines) { + // No diff data or diff is disabled, just show regular buffer lines + return lines.map((content, index) => ({ + type: "buffer" as const, + bufferLineIndex: index, + content, + diffLine: undefined, + })); + } + + type UnifiedLine = { + type: "buffer" | "diff-only"; + bufferLineIndex?: number; + content: string; + diffLine?: GitDiffLine; + }; + + const result: UnifiedLine[] = []; + let bufferLineIndex = 0; // 0-based index into current buffer lines + let pendingRemoved: GitDiffLine[] = []; + + const flushUnchangedUpTo = (targetBufferIndexExclusive: number) => { + while (bufferLineIndex < Math.min(targetBufferIndexExclusive, lines.length)) { + result.push({ + type: "buffer", + bufferLineIndex, + content: lines[bufferLineIndex], + diffLine: undefined, + }); + bufferLineIndex++; + } + }; + + const flushPendingRemoved = () => { + if (pendingRemoved.length === 0) return; + for (const rl of pendingRemoved) { + result.push({ type: "diff-only", content: rl.content, diffLine: rl }); + } + pendingRemoved = []; + }; + + for (const dl of diffData.lines) { + if (dl.line_type === "header") continue; + + if (dl.line_type === "removed") { + // Queue removed lines; they'll be displayed before the next added/context line + pendingRemoved.push(dl); + continue; + } + + // For added/context lines, position by new_line_number + const newNumber = dl.new_line_number; + if (typeof newNumber === "number") { + const targetIndex = newNumber - 1; // convert to 0-based + // Fill unchanged lines up to the target + flushUnchangedUpTo(targetIndex); + // Show any pending deletions just before the current position + flushPendingRemoved(); + // Now push the current buffer line aligned with this diff line + if (bufferLineIndex < lines.length) { + result.push({ + type: "buffer", + bufferLineIndex, + content: lines[bufferLineIndex], + diffLine: dl, + }); + bufferLineIndex++; + } + } + } + + // If there are trailing deletions at EOF, show them now + flushPendingRemoved(); + // Add remaining unchanged buffer lines + flushUnchangedUpTo(lines.length); + + return result; + }, [lines, diffData, showInlineDiff]); const selectedLines = useMemo(() => { - const lines = new Set(); + const selectedSet = new Set(); if (selection) { for (let i = selection.start.line; i <= selection.end.line; i++) { - lines.add(i); + selectedSet.add(i); } } - return lines; + return selectedSet; }, [selection]); const containerRef = useRef(null); @@ -69,9 +201,9 @@ export const EditorViewport = memo( return { start: Math.max(0, startLine - overscan), - end: Math.min(lineCount, endLine + overscan), + end: Math.min(unifiedLines.length, endLine + overscan), }; - }, [scrollTop, lineHeight, viewportHeight, lineCount, forceUpdate]); + }, [scrollTop, lineHeight, viewportHeight, unifiedLines.length, forceUpdate]); const handleScroll = (e: React.UIEvent) => { const target = e.currentTarget; @@ -105,7 +237,7 @@ export const EditorViewport = memo( }; }, []); - const totalHeight = lineCount * lineHeight + 20 * lineHeight; // Add 20 lines of empty space at bottom + const totalHeight = unifiedLines.length * lineHeight + 20 * lineHeight; // Add 20 lines of empty space at bottom return (
{ const idx = visibleRange.start + i; + const unifiedLine = unifiedLines[idx]; + + if (!unifiedLine) return null; + return ( ); })} diff --git a/src/components/editor/rendering/line-gutter.tsx b/src/components/editor/rendering/line-gutter.tsx index 519e90b9..ac97715d 100644 --- a/src/components/editor/rendering/line-gutter.tsx +++ b/src/components/editor/rendering/line-gutter.tsx @@ -9,6 +9,9 @@ interface LineGutterProps { isBreakpoint?: boolean; hasError?: boolean; hasWarning?: boolean; + isDeleted?: boolean; + oldLineNumber?: number; + newLineNumber?: number; } export const LineGutter = ({ @@ -19,6 +22,8 @@ export const LineGutter = ({ isBreakpoint = false, hasError = false, hasWarning = false, + isDeleted = false, + newLineNumber, }: LineGutterProps) => { const gutterDecorations = decorations.filter( (d) => d.type === "gutter" && d.range.start.line === lineNumber, @@ -55,7 +60,7 @@ export const LineGutter = ({ className={cn("gutter-decoration", decoration.className)} style={{ position: "absolute", - left: "2px", // Small gap from left edge + left: "-4px", // Small gap from left edge top: decoration.className?.includes("git-gutter-deleted") ? "0px" : "50%", transform: decoration.className?.includes("git-gutter-deleted") ? "none" @@ -99,24 +104,42 @@ export const LineGutter = ({ ))} {/* Line numbers */} - {showLineNumbers && ( - - {lineNumber + 1} - - )} + {showLineNumbers && + (newLineNumber !== undefined ? ( +
+ {newLineNumber} +
+ ) : !isDeleted ? ( +
+ {lineNumber + 1} +
+ ) : null)} {/* Other decorations (breakpoints, errors, etc.) */} {otherDecorations diff --git a/src/components/editor/rendering/line-with-content.tsx b/src/components/editor/rendering/line-with-content.tsx index 097c8e42..6a65fb74 100644 --- a/src/components/editor/rendering/line-with-content.tsx +++ b/src/components/editor/rendering/line-with-content.tsx @@ -3,14 +3,20 @@ import { EDITOR_CONSTANTS } from "@/constants/editor-constants"; import { useEditorCursorStore } from "@/stores/editor-cursor-store"; import { useEditorDecorationsStore } from "@/stores/editor-decorations-store"; import { useEditorInstanceStore } from "@/stores/editor-instance-store"; +import { useEditorSettingsStore } from "@/stores/editor-settings-store"; import { useEditorViewStore } from "@/stores/editor-view-store"; import { useGitBlameStore } from "@/stores/git-blame-store"; +import type { GitDiffLine } from "@/version-control/git/models/git-types"; import { InlineGitBlame } from "@/version-control/git/views/inline-git-blame"; import { LineGutter } from "./line-gutter"; import { LineRenderer } from "./line-renderer"; interface LineWithContentProps { lineNumber: number; + bufferLineIndex?: number; + content: string; + diffLine?: GitDiffLine; + isDiffOnly: boolean; showLineNumbers: boolean; gutterWidth: number; lineHeight: number; @@ -18,24 +24,78 @@ interface LineWithContentProps { } export const LineWithContent = memo( - ({ lineNumber, showLineNumbers, gutterWidth, lineHeight, isSelected }) => { - const content = useEditorViewStore((state) => state.lines[lineNumber]); - const tokens = useEditorViewStore((state) => state.lineTokens.get(lineNumber)) ?? []; + ({ + lineNumber, + bufferLineIndex, + content, + diffLine, + isDiffOnly, + showLineNumbers, + gutterWidth, + lineHeight, + isSelected, + }) => { + const tokens = useEditorViewStore((state) => + bufferLineIndex !== undefined ? (state.lineTokens.get(bufferLineIndex) ?? []) : [], + ); const decorations = useEditorDecorationsStore((state) => - state.getDecorationsForLine(lineNumber), + bufferLineIndex !== undefined ? state.getDecorationsForLine(bufferLineIndex) : [], ); + const showInlineDiff = useEditorSettingsStore.use.showInlineDiff(); const { line } = useEditorCursorStore((state) => state.cursorPosition); // Git blame functionality const { filePath } = useEditorInstanceStore(); const { getBlameForLine } = useGitBlameStore(); - const blameLine = filePath ? getBlameForLine(filePath, lineNumber) : null; - const isSelectedLine = line === lineNumber; + const blameLine = + filePath && bufferLineIndex !== undefined ? getBlameForLine(filePath, bufferLineIndex) : null; + const isSelectedLine = bufferLineIndex !== undefined && line === bufferLineIndex; + + // diffLine is now passed as a prop, so we don't need to find it + + // Determine CSS class based on diff line type + const getDiffClassName = () => { + if (!showInlineDiff || !diffLine) return ""; + + switch (diffLine.line_type) { + case "added": + return "git-diff-line-added"; + case "removed": + return "git-diff-line-removed"; + case "context": + return ""; // No special styling for context lines + default: + return ""; + } + }; + + // Create diff decorations for the gutter + // Inline diff should not create separate git gutter decorations; rely on global git gutter + const diffDecorations: typeof decorations = []; + + // Combine existing decorations with diff decorations + const allDecorations = [...decorations, ...diffDecorations]; + + // Get line numbers for display + const displayLineNumbers = showInlineDiff + ? { + old: undefined, + new: isDiffOnly + ? undefined // deleted (diff-only) lines show no line number + : bufferLineIndex !== undefined + ? bufferLineIndex + 1 + : undefined, + } + : { + old: undefined, + new: bufferLineIndex !== undefined ? bufferLineIndex + 1 : undefined, + }; + const isDeleted = diffLine?.line_type === "removed"; return (
( }} >
( }} > {isSelectedLine && blameLine && ( diff --git a/src/file-system/controllers/store.ts b/src/file-system/controllers/store.ts index 014809d4..b7bbdbca 100644 --- a/src/file-system/controllers/store.ts +++ b/src/file-system/controllers/store.ts @@ -16,10 +16,11 @@ import { useEditorSettingsStore } from "@/stores/editor-settings-store"; import { useProjectStore } from "@/stores/project-store"; import { useSidebarStore } from "@/stores/sidebar-store"; import { createSelectors } from "@/utils/zustand-selectors"; -import { getGitStatus } from "@/version-control/git/controllers/git"; +import { getFileDiff, getGitStatus } from "@/version-control/git/controllers/git"; import { gitDiffCache } from "@/version-control/git/controllers/git-diff-cache"; import { isDiffFile, parseRawDiffContent } from "@/version-control/git/controllers/git-diff-parser"; import { useGitStore } from "@/version-control/git/controllers/git-store"; +import type { GitDiff } from "@/version-control/git/models/git-types"; import type { FileEntry } from "../models/app"; import type { FsActions, FsState } from "../models/interface"; import { @@ -230,7 +231,23 @@ export const useFileSystemStore = createSelectors( const diffJson = JSON.stringify(parsedDiff); openBuffer(path, fileName, diffJson, false, false, true, false); } else { - openBuffer(path, fileName, content, false, false, false, false); + // For regular files, try to fetch git diff data + let diffData: GitDiff | undefined; + try { + const { rootFolderPath } = get(); + if (rootFolderPath) { + // Get relative path for git operations + const relativePath = path.startsWith(rootFolderPath) + ? path.substring(rootFolderPath.length + 1).replace(/\\/g, "/") + : path; + diffData = (await getFileDiff(rootFolderPath, relativePath, false)) || undefined; + } + } catch (error) { + console.warn("Failed to fetch git diff for file:", path, error); + // Continue without diff data + } + + openBuffer(path, fileName, content, false, false, false, false, diffData); } // Handle navigation to specific line/column diff --git a/src/stores/buffer-store.ts b/src/stores/buffer-store.ts index f5a4211d..46f4712b 100644 --- a/src/stores/buffer-store.ts +++ b/src/stores/buffer-store.ts @@ -6,6 +6,7 @@ import { useRecentFilesStore } from "@/file-system/controllers/recent-files-stor import { detectLanguageFromFileName } from "@/utils/language-detection"; import { createSelectors } from "@/utils/zustand-selectors"; import type { MultiFileDiff } from "@/version-control/diff-viewer/models/diff-types"; +import { getFileDiff } from "@/version-control/git/controllers/git"; import type { GitDiff } from "@/version-control/git/models/git-types"; interface Buffer { @@ -220,6 +221,42 @@ export const useBufferStore = createSelectors( // Don't clear tokens - syntax highlighter will update them } }); + + // For non-virtual, non-diff files, refresh git diff data in the background + if ( + buffer && + !buffer.isVirtual && + !buffer.isDiff && + !buffer.isImage && + !buffer.isSQLite + ) { + (async () => { + try { + const { useFileSystemStore } = await import("@/file-system/controllers/store"); + const rootFolderPath = useFileSystemStore.getState().rootFolderPath; + + if (rootFolderPath && buffer.path.startsWith(rootFolderPath)) { + const relativePath = buffer.path + .substring(rootFolderPath.length + 1) + .replace(/\\/g, "/"); + + const freshDiffData = await getFileDiff(rootFolderPath, relativePath, false); + + if (freshDiffData) { + set((state) => { + const currentBuffer = state.buffers.find((b) => b.id === bufferId); + if (currentBuffer) { + currentBuffer.diffData = freshDiffData; + } + }); + } + } + } catch (error) { + // Silently fail - diff data is not critical for basic functionality + console.debug("Failed to refresh git diff data:", error); + } + })(); + } }, updateBufferTokens: (bufferId: string, tokens: Buffer["tokens"]) => { diff --git a/src/stores/editor-settings-store.ts b/src/stores/editor-settings-store.ts index d28761a4..0f04b897 100644 --- a/src/stores/editor-settings-store.ts +++ b/src/stores/editor-settings-store.ts @@ -9,6 +9,7 @@ interface EditorSettingsState { tabSize: number; wordWrap: boolean; lineNumbers: boolean; + showInlineDiff: boolean; disabled: boolean; theme: string; actions: EditorSettingsActions; @@ -20,6 +21,7 @@ interface EditorSettingsActions { setTabSize: (size: number) => void; setWordWrap: (wrap: boolean) => void; setLineNumbers: (show: boolean) => void; + setShowInlineDiff: (show: boolean) => void; setDisabled: (disabled: boolean) => void; setTheme: (theme: string) => void; } @@ -32,6 +34,7 @@ export const useEditorSettingsStore = createSelectors( tabSize: 2, wordWrap: true, lineNumbers: true, + showInlineDiff: false, disabled: false, theme: "auto", actions: { @@ -40,6 +43,7 @@ export const useEditorSettingsStore = createSelectors( setTabSize: (size) => set({ tabSize: size }), setWordWrap: (wrap) => set({ wordWrap: wrap }), setLineNumbers: (show) => set({ lineNumbers: show }), + setShowInlineDiff: (show) => set({ showInlineDiff: show }), setDisabled: (disabled) => set({ disabled }), setTheme: (theme) => set({ theme }), }, diff --git a/src/stores/editor-view-store.ts b/src/stores/editor-view-store.ts index 0226cbec..db7dbfd8 100644 --- a/src/stores/editor-view-store.ts +++ b/src/stores/editor-view-store.ts @@ -1,6 +1,8 @@ import isEqual from "fast-deep-equal"; import { createWithEqualityFn } from "zustand/traditional"; import { createSelectors } from "@/utils/zustand-selectors"; +import type { MultiFileDiff } from "@/version-control/diff-viewer/models/diff-types"; +import type { GitDiff } from "@/version-control/git/models/git-types"; import type { LineToken } from "../types/editor-types"; import { useBufferStore } from "./buffer-store"; @@ -8,6 +10,7 @@ interface EditorViewState { // Computed views of the active buffer lines: string[]; lineTokens: Map; + diffData?: GitDiff | MultiFileDiff; // Actions actions: { @@ -66,6 +69,7 @@ export const useEditorViewStore = createSelectors( // These will be computed from the active buffer lines: [""], lineTokens: new Map(), + diffData: undefined, actions: { getLines: () => { @@ -84,6 +88,14 @@ export const useEditorViewStore = createSelectors( const activeBuffer = useBufferStore.getState().actions.getActiveBuffer(); return activeBuffer?.content || ""; }, + + getDiffData: () => { + const activeBuffer = useBufferStore.getState().actions.getActiveBuffer(); + if (!activeBuffer) return []; + const diffData = activeBuffer.diffData; + if (!diffData) return []; + return diffData; + }, }, }), isEqual, @@ -97,11 +109,13 @@ useBufferStore.subscribe((state) => { useEditorViewStore.setState({ lines: activeBuffer.content.split("\n"), lineTokens: convertToLineTokens(activeBuffer.content, activeBuffer.tokens), + diffData: activeBuffer.diffData, }); } else { useEditorViewStore.setState({ lines: [""], lineTokens: new Map(), + diffData: undefined, }); } }); diff --git a/src/styles/editor-line-based.css b/src/styles/editor-line-based.css index c038bbdb..3ae1e989 100644 --- a/src/styles/editor-line-based.css +++ b/src/styles/editor-line-based.css @@ -57,6 +57,52 @@ margin-left: 4px; } +/* Git diff line background styles */ +:root { + --git-diff-added-bg: rgba(34, 197, 94, 0.1); + --git-diff-removed-bg: rgba(239, 68, 68, 0.1); + --git-diff-modified-bg: rgba(245, 158, 11, 0.1); + --git-diff-header-bg: rgba(128, 128, 128, 0.1); + --git-diff-added-border: rgba(34, 197, 94, 0.3); + --git-diff-removed-border: rgba(239, 68, 68, 0.3); +} + +.git-diff-line-added { + background-color: var(--git-diff-added-bg); + /*border-left: 2px solid var(--git-diff-added-border);*/ +} + +.git-diff-line-removed { + background-color: var(--git-diff-removed-bg); + /*border-left: 2px solid var(--git-diff-removed-border);*/ + position: relative; +} + +.git-diff-line-removed::after { + content: ""; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + /*background-color: var(--git-diff-removed-border);*/ + opacity: 0.5; + z-index: 1; +} + +.git-diff-line-modified { + background-color: var(--git-diff-modified-bg); +} + +.git-diff-line-header { + background-color: var(--git-diff-header-bg); +} + +/* Diff-only line styling (for removed lines) */ +.editor-line-wrapper.git-diff-line-removed { + opacity: 0.8; +} + /* Virtual scrolling optimization */ .editor-content { contain: layout;