Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MarkdownRenderer } from "renderer/components/MarkdownRenderer";
import { LightDiffViewer } from "renderer/screens/main/components/WorkspaceView/ChangesContent/components/LightDiffViewer";
import type { CodeEditorAdapter } from "renderer/screens/main/components/WorkspaceView/ContentView/components";
import { CodeEditor } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor";
import { DiffScrollIndicators } from "renderer/screens/main/components/WorkspaceView/components/DiffScrollIndicators";
import type { Tab } from "renderer/stores/tabs/types";
import type { DiffViewMode } from "shared/changes-types";
import { detectLanguage } from "shared/detect-language";
Expand Down Expand Up @@ -328,41 +329,51 @@ export function FileViewerContent({
});
}}
>
<div
ref={diffContainerRef}
className="h-full min-h-0 overflow-auto bg-background select-text"
onClickCapture={(event) => {
if (hasActiveSelectionWithinElement(diffContainerRef.current)) {
event.stopPropagation();
}
}}
onContextMenuCapture={(event) => {
const location = getDiffLocationFromEvent(event.nativeEvent);
if (!location) {
return;
}

const column = getColumnFromDiffPoint({
lineElement: location.lineElement,
numberColumn: location.numberColumn,
clientX: event.clientX,
clientY: event.clientY,
});

lastDiffLocationRef.current = {
...location,
column,
};
}}
>
<LightDiffViewer
key={filePath}
contents={diffData}
viewMode={diffViewMode}
hideUnchangedRegions={hideUnchangedRegions}
filePath={filePath}
className="min-h-full"
/>
<div className="flex h-full min-h-0">

@cubic-dev-ai cubic-dev-ai Bot Mar 11, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: By wrapping both the diff container and DiffScrollIndicators inside DiffViewerContextMenu, the diff's context menu will unexpectedly trigger when right-clicking the scroll indicators. This breaks menu actions: 'Edit Here' will jump to a stale location (the last line right-clicked in the diff), and 'Select All' will select text in the adjacent diff viewer. Move the flex layout wrapper outside of DiffViewerContextMenu so the menu is strictly bound to the diff container.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/FileViewerContent.tsx, line 332:

<comment>By wrapping both the diff container and `DiffScrollIndicators` inside `DiffViewerContextMenu`, the diff's context menu will unexpectedly trigger when right-clicking the scroll indicators. This breaks menu actions: 'Edit Here' will jump to a stale location (the last line right-clicked in the diff), and 'Select All' will select text in the adjacent diff viewer. Move the flex layout wrapper outside of `DiffViewerContextMenu` so the menu is strictly bound to the diff container.</comment>

<file context>
@@ -328,40 +329,48 @@ export function FileViewerContent({
-						contents={diffData}
-						viewMode={diffViewMode}
-						hideUnchangedRegions={hideUnchangedRegions}
+				<div className="flex h-full min-h-0">
+					<div
+						ref={diffContainerRef}
</file context>
Fix with Cubic

<div
ref={diffContainerRef}
className="flex-1 min-w-0 h-full overflow-auto bg-background select-text"
onClickCapture={(event) => {
if (hasActiveSelectionWithinElement(diffContainerRef.current)) {
event.stopPropagation();
}
}}
onContextMenuCapture={(event) => {
const location = getDiffLocationFromEvent(event.nativeEvent);
if (!location) {
return;
}

const column = getColumnFromDiffPoint({
lineElement: location.lineElement,
numberColumn: location.numberColumn,
clientX: event.clientX,
clientY: event.clientY,
});

lastDiffLocationRef.current = {
...location,
column,
};
}}
>
<LightDiffViewer
key={filePath}
contents={diffData}
viewMode={diffViewMode}
hideUnchangedRegions={hideUnchangedRegions}
filePath={filePath}
className="min-h-full"
/>
</div>
{!hideUnchangedRegions && (
<DiffScrollIndicators
scrollRef={diffContainerRef}
original={diffData.original}
modified={diffData.modified}
filePath={filePath}
/>
)}
</div>
</DiffViewerContextMenu>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { parseDiffFromFile } from "@pierre/diffs";
import {
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { MIDNIGHT_CODE_COLORS } from "renderer/screens/main/components/WorkspaceView/components/CodeEditor/constants";

type ChangeType = "addition" | "deletion" | "modified";

interface ChangeRegion {
startLine: number;
lineCount: number;
type: ChangeType;
}

interface DiffScrollIndicatorsProps {
scrollRef: RefObject<HTMLElement | null>;
original: string;
modified: string;
filePath: string;
}

const STRIP_WIDTH = 14;
const MIN_BAND_PX = 3;
const COLUMN_GAP = 3;
const TRACK_BG = "#1e2127";
const TRACK_BG_HOVER = "#22262e";
const VIEWPORT_THRESHOLD = 0.95;
const MIN_THUMB_PX = 20;

const DELETION_COLOR = `${MIDNIGHT_CODE_COLORS.deletion}cc`;
const ADDITION_COLOR = `${MIDNIGHT_CODE_COLORS.addition}cc`;

function computeRegions(
original: string,
modified: string,
filePath: string,
): { regions: Array<ChangeRegion>; totalLines: number } {
const diff = parseDiffFromFile(
{ name: filePath, contents: original },
{ name: filePath, contents: modified },
);

const modifiedLineCount = modified.split("\n").length;
const totalLines = Math.max(modifiedLineCount, 1);
const regions: Array<ChangeRegion> = [];

for (const hunk of diff.hunks) {
let currentLine = hunk.additionStart;

for (const chunk of hunk.hunkContent) {
if (chunk.type === "context") {
currentLine += chunk.lines.length;
continue;
}

const hasDeletions = chunk.deletions.length > 0;
const hasAdditions = chunk.additions.length > 0;

if (hasDeletions && hasAdditions) {
regions.push({
startLine: currentLine,
lineCount: chunk.additions.length,
type: "modified",
});
currentLine += chunk.additions.length;
} else if (hasDeletions) {
regions.push({
startLine: currentLine,
lineCount: Math.max(chunk.deletions.length, 1),
type: "deletion",
});
} else if (hasAdditions) {
regions.push({
startLine: currentLine,
lineCount: chunk.additions.length,
type: "addition",
});
currentLine += chunk.additions.length;
}
}
}

return { regions, totalLines };
}

function renderBand(
region: ChangeRegion,
topPercent: number,
heightPercent: number,
) {
const bandStyle = {
top: `${topPercent}%`,
height: `max(${MIN_BAND_PX}px, ${heightPercent}%)`,
borderRadius: 1,
};

if (region.type === "modified") {
return (
<div
key={`mod-${region.startLine}`}
className="absolute"
style={{ ...bandStyle, left: 0, right: 0, display: "flex" }}
>
<div
className="h-full"
style={{ flex: 1, backgroundColor: DELETION_COLOR, borderRadius: 1 }}
/>
<div style={{ width: COLUMN_GAP }} />
<div
className="h-full"
style={{ flex: 1, backgroundColor: ADDITION_COLOR, borderRadius: 1 }}
/>
</div>
);
}

const isDeletion = region.type === "deletion";

return (
<div
key={`${region.type}-${region.startLine}`}
className="absolute"
style={{
...bandStyle,
left: isDeletion ? 0 : `calc(50% + ${COLUMN_GAP / 2}px)`,
right: isDeletion ? `calc(50% + ${COLUMN_GAP / 2}px)` : 0,
backgroundColor: isDeletion ? DELETION_COLOR : ADDITION_COLOR,
}}
/>
);
}

export function DiffScrollIndicators({
scrollRef,
original,
modified,
filePath,
}: DiffScrollIndicatorsProps) {
const [viewportRatio, setViewportRatio] = useState({ top: 0, height: 1 });
const trackRef = useRef<HTMLDivElement | null>(null);

const { regions, totalLines } = useMemo(
() => computeRegions(original, modified, filePath),
[original, modified, filePath],
);

useEffect(() => {
const el = scrollRef.current;
if (!el) return;

const updateViewport = () => {
const sh = el.scrollHeight;
if (sh > 0) {
setViewportRatio({
top: el.scrollTop / sh,
height: el.clientHeight / sh,
});
}
};

updateViewport();
el.addEventListener("scroll", updateViewport, { passive: true });
const resizeObserver = new ResizeObserver(updateViewport);
resizeObserver.observe(el);

return () => {
el.removeEventListener("scroll", updateViewport);
resizeObserver.disconnect();
};
}, [scrollRef]);

const handleClick = useCallback(
(event: React.MouseEvent) => {
const el = scrollRef.current;
const track = trackRef.current;
if (!el || !track) return;

const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
const targetScroll = ratio * el.scrollHeight - el.clientHeight / 2;
const prefersReduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
el.scrollTo({
top: targetScroll,
behavior: prefersReduced ? "auto" : "smooth",
});
},
[scrollRef],
);

function handleMouseEnter() {
if (trackRef.current) {
trackRef.current.style.backgroundColor = TRACK_BG_HOVER;
}
}

function handleMouseLeave() {
if (trackRef.current) {
trackRef.current.style.backgroundColor = TRACK_BG;
}
}

if (regions.length === 0) return null;

const showViewportThumb = viewportRatio.height < VIEWPORT_THRESHOLD;

return (
<div
ref={trackRef}
aria-hidden="true"
className="shrink-0 cursor-pointer"
style={{
width: STRIP_WIDTH,
backgroundColor: TRACK_BG,
borderLeft: `1px solid ${MIDNIGHT_CODE_COLORS.border}`,
position: "relative",
overflow: "hidden",
transition: "background-color 150ms",
}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{regions.map((region) => {
const topPercent = ((region.startLine - 1) / totalLines) * 100;

@cubic-dev-ai cubic-dev-ai Bot Mar 11, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Clamp region start/size before converting to percentages so deletion markers at EOF are still visible in the minimap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/DiffScrollIndicators/DiffScrollIndicators.tsx, line 231:

<comment>Clamp region start/size before converting to percentages so deletion markers at EOF are still visible in the minimap.</comment>

<file context>
@@ -0,0 +1,250 @@
+			onMouseLeave={handleMouseLeave}
+		>
+			{regions.map((region) => {
+				const topPercent = ((region.startLine - 1) / totalLines) * 100;
+				const heightPercent = (region.lineCount / totalLines) * 100;
+				return renderBand(region, topPercent, heightPercent);
</file context>
Fix with Cubic

const heightPercent = (region.lineCount / totalLines) * 100;
return renderBand(region, topPercent, heightPercent);
})}

{showViewportThumb && (
<div
className="absolute left-0 right-0 pointer-events-none"
style={{
top: `${viewportRatio.top * 100}%`,
height: `max(${MIN_THUMB_PX}px, ${viewportRatio.height * 100}%)`,
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderTop: "1px solid rgba(255, 255, 255, 0.15)",
borderBottom: "1px solid rgba(255, 255, 255, 0.15)",
}}
/>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DiffScrollIndicators } from "./DiffScrollIndicators";