Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/components/editor/overlays/editor-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Code,
Copy,
FileText,
GitMerge,
Indent,
Outdent,
RotateCcw,
Expand All @@ -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 {
Expand All @@ -41,6 +43,7 @@ interface EditorContextMenuProps {
onMoveLineDown?: () => void;
onInsertLine?: () => void;
onToggleBookmark?: () => void;
onToggleInlineDiff?: () => void;
}

const EditorContextMenu = ({
Expand All @@ -64,10 +67,13 @@ const EditorContextMenu = ({
onMoveLineDown,
onInsertLine,
onToggleBookmark,
onToggleInlineDiff,
}: EditorContextMenuProps) => {
const menuRef = useRef<HTMLDivElement>(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;
Expand Down Expand Up @@ -232,6 +238,16 @@ const EditorContextMenu = ({
onClose();
};

const handleToggleInlineDiff = () => {
if (onToggleInlineDiff) {
onToggleInlineDiff();
} else {
// Default behavior: toggle the setting directly
setShowInlineDiff(!showInlineDiff);
}
onClose();
};

return (
<div
ref={menuRef}
Expand Down Expand Up @@ -444,6 +460,20 @@ const EditorContextMenu = ({
</div>
<KeybindingBadge keys={["⌘", "K", "⌘", "K"]} className="opacity-60" />
</button>

<div className="my-0.5 border-border border-t" />

{/* Toggle Inline Git Diff */}
<button
className="flex w-full items-center justify-between gap-2 px-2.5 py-1 text-left font-mono text-text text-xs hover:bg-hover"
onClick={handleToggleInlineDiff}
>
<div className="flex items-center gap-2">
<GitMerge size={11} />
<span>{showInlineDiff ? "Hide" : "Show"} Inline Git Diff</span>
</div>
<div className="text-xs opacity-60">{showInlineDiff ? "✓" : ""}</div>
</button>
</div>
);
};
Expand Down
160 changes: 152 additions & 8 deletions src/components/editor/rendering/editor-viewport.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -22,21 +26,149 @@ export const EditorViewport = memo(
forwardRef<HTMLDivElement, EditorViewportProps>(
({ 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<GitDiff | undefined>(undefined);

useEffect(() => {
if (!showInlineDiff || !rootFolderPath || !filePath) {
setContentDiff(undefined);
return;
}

const content = getContent();
let timer: ReturnType<typeof setTimeout> | 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing that every 0.5 seconds, why not do it on save of the buffer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is because when you are editing the file and havn’t saved the file yet. It will still show you the git diff based upon the buffer changes

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<number>();
const selectedSet = new Set<number>();
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<HTMLDivElement>(null);

Expand Down Expand Up @@ -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<HTMLDivElement>) => {
const target = e.currentTarget;
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -153,14 +285,26 @@ export const EditorViewport = memo(
indices to generate line components */}
{Array.from({ length: visibleRange.end - visibleRange.start }, (_, i) => {
const idx = visibleRange.start + i;
const unifiedLine = unifiedLines[idx];

if (!unifiedLine) return null;

return (
<LineWithContent
key={`line-${idx}`}
lineNumber={idx}
bufferLineIndex={unifiedLine.bufferLineIndex}
content={unifiedLine.content}
diffLine={unifiedLine.diffLine}
isDiffOnly={unifiedLine.type === "diff-only"}
showLineNumbers={showLineNumbers}
gutterWidth={gutterWidth}
lineHeight={lineHeight}
isSelected={selectedLines.has(idx)}
isSelected={
unifiedLine.bufferLineIndex !== undefined
? selectedLines.has(unifiedLine.bufferLineIndex)
: false
}
/>
);
})}
Expand Down
61 changes: 42 additions & 19 deletions src/components/editor/rendering/line-gutter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ interface LineGutterProps {
isBreakpoint?: boolean;
hasError?: boolean;
hasWarning?: boolean;
isDeleted?: boolean;
oldLineNumber?: number;
newLineNumber?: number;
}

export const LineGutter = ({
Expand All @@ -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,
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes an inverted gap I guess?

this comment can be removed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sticks the git gutter to the left end this way. Will see if there is something better that can be done.

top: decoration.className?.includes("git-gutter-deleted") ? "0px" : "50%",
transform: decoration.className?.includes("git-gutter-deleted")
? "none"
Expand Down Expand Up @@ -99,24 +104,42 @@ export const LineGutter = ({
))}

{/* Line numbers */}
{showLineNumbers && (
<span
className="line-number"
style={{
position: "relative",
zIndex: 2,
fontSize: "12px",
color: "var(--color-text-lighter, #6b7280)",
fontFamily: "inherit",
userSelect: "none",
textAlign: "right",
minWidth: `${gutterWidth - 24}px`, // Account for git indicator space and padding
paddingLeft: "12px", // Space from git indicators
}}
>
{lineNumber + 1}
</span>
)}
{showLineNumbers &&
(newLineNumber !== undefined ? (
<div
className="line-number-container"
style={{
position: "relative",
zIndex: 2,
fontSize: "12px",
color: "var(--color-text-lighter, #6b7280)",
fontFamily: "inherit",
userSelect: "none",
textAlign: "right",
minWidth: `${gutterWidth - 24}px`,
paddingLeft: "12px",
}}
>
{newLineNumber}
</div>
) : !isDeleted ? (
<div
className="line-number-container"
style={{
position: "relative",
zIndex: 2,
fontSize: "12px",
color: "var(--color-text-lighter, #6b7280)",
fontFamily: "inherit",
userSelect: "none",
textAlign: "right",
minWidth: `${gutterWidth - 24}px`,
paddingLeft: "12px",
}}
>
{lineNumber + 1}
</div>
) : null)}

{/* Other decorations (breakpoints, errors, etc.) */}
{otherDecorations
Expand Down
Loading