perf(diff): Pierre diff optimizations (#419)
* perf: implement virtualized rendering for diff viewer - Enables efficient rendering of large diffs by only rendering visible content - Uses shared virtualizer cache to optimize memory across multiple diff viewers - Configures virtual scrolling with 24px line height for consistent layout * fix: improve diff viewer line selection and annotation rendering * fix: normalize line ranges to prevent selection bugs * chore: upgrade @opencode-ai/sdk to v1.1.65
This commit is contained in:
committed by
GitHub
parent
2f09d375d3
commit
523eafdf65
@@ -30,7 +30,7 @@
|
|||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.6.6",
|
"version": "1.6.8",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.6.6",
|
"version": "1.6.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
@@ -132,8 +132,8 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"@pierre/diffs": "^1.0.5",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -198,10 +198,10 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.6.6",
|
"version": "1.6.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.6.6",
|
"version": "1.6.8",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -843,9 +843,9 @@
|
|||||||
|
|
||||||
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.53", "", {}, "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.65", "", {}, "sha512-XlpgQJQ5WwO4tYgyyHoTT0NAB5/1StXonabVUAYVpW0JdtbwWFSdFEaLkWx6CU7MNW6ELP+SMC4n6wWO6zRW8Q=="],
|
||||||
|
|
||||||
"@pierre/diffs": ["@pierre/diffs@1.0.10", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-ahkpfS30NfaB+PBxnf0/Mc20ySBRTQmM28a7Ojpd0UZixmTyhGhJfBFjvmhX8dSzR22lB3h3OIMMxpB4yYTIOQ=="],
|
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
|
||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@
|
|||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"@pierre/diffs": "^1.0.5",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -38,24 +38,62 @@ export function InlineCommentInput({
|
|||||||
}
|
}
|
||||||
}, [lineRange]);
|
}, [lineRange]);
|
||||||
|
|
||||||
|
const normalizeRange = (range?: { start: number; end: number; side?: 'additions' | 'deletions' }) => {
|
||||||
|
if (!range) return undefined;
|
||||||
|
const start = Math.min(range.start, range.end);
|
||||||
|
const end = Math.max(range.start, range.end);
|
||||||
|
return { ...range, start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayRange = normalizeRange(lineRange);
|
||||||
|
|
||||||
// Focus on mount (desktop only) or when becoming visible
|
// Focus on mount (desktop only) or when becoming visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile && textareaRef.current) {
|
const textarea = textareaRef.current;
|
||||||
textareaRef.current.focus();
|
if (!textarea) return;
|
||||||
// Move cursor to end
|
|
||||||
const len = textareaRef.current.value.length;
|
const scrollContainer = textarea.closest('.overlay-scrollbar-container') as HTMLElement | null;
|
||||||
textareaRef.current.setSelectionRange(len, len);
|
const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY;
|
||||||
} else if (isMobile && textareaRef.current) {
|
const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX;
|
||||||
// Scroll into view on mobile
|
|
||||||
textareaRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (isMobile) {
|
||||||
|
textarea.scrollIntoView({ behavior: 'auto', block: 'nearest' });
|
||||||
|
try {
|
||||||
|
textarea.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
textarea.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = textarea.value.length;
|
||||||
|
try {
|
||||||
|
textarea.setSelectionRange(len, len);
|
||||||
|
} catch (err) {
|
||||||
|
void err;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollContainer) {
|
||||||
|
scrollContainer.scrollTop = prevScrollTop;
|
||||||
|
scrollContainer.scrollLeft = prevScrollLeft;
|
||||||
|
} else {
|
||||||
|
window.scrollTo({ top: prevScrollTop, left: prevScrollLeft });
|
||||||
|
}
|
||||||
|
});
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
onSave(text, stableRangeRef.current);
|
onSave(text, normalizeRange(stableRangeRef.current));
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -67,7 +105,7 @@ export function InlineCommentInput({
|
|||||||
// Stop propagation to prevent parent selection clearing before save
|
// Stop propagation to prevent parent selection clearing before save
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
onSave(text, stableRangeRef.current);
|
onSave(text, normalizeRange(stableRangeRef.current));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +128,7 @@ export function InlineCommentInput({
|
|||||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground mb-2">
|
||||||
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
|
{fileLabel && <span className="truncate max-w-[200px]">{fileLabel}</span>}
|
||||||
{fileLabel && lineRange && <span>•</span>}
|
{fileLabel && lineRange && <span>•</span>}
|
||||||
{lineRange && <span>Lines {lineRange.start}-{lineRange.end}</span>}
|
{displayRange && <span>Lines {displayRange.start}-{displayRange.end}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1082,6 +1082,8 @@ export const DiffView: React.FC = () => {
|
|||||||
outerClassName="flex-1 min-h-0 h-full"
|
outerClassName="flex-1 min-h-0 h-full"
|
||||||
className="pr-2"
|
className="pr-2"
|
||||||
disableHorizontal
|
disableHorizontal
|
||||||
|
data-diff-virtual-root
|
||||||
|
data-diff-virtual-content
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{changedFiles.map((file, index) => (
|
{changedFiles.map((file, index) => (
|
||||||
@@ -1144,7 +1146,7 @@ export const DiffView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden px-3 py-3 relative">
|
<div className="flex flex-1 min-h-0 overflow-hidden px-3 py-3 relative" data-diff-virtual-root data-diff-virtual-content>
|
||||||
{renderSelectedDiffViewer()}
|
{renderSelectedDiffViewer()}
|
||||||
{isCurrentFileLoading && !hasCurrentDiff && (
|
{isCurrentFileLoading && !hasCurrentDiff && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { FileDiff as PierreFileDiff, type FileContents, type FileDiffOptions, type SelectedLineRange, type DiffLineAnnotation, type AnnotationSide } from '@pierre/diffs';
|
import {
|
||||||
|
FileDiff as PierreFileDiff,
|
||||||
|
VirtualizedFileDiff,
|
||||||
|
Virtualizer,
|
||||||
|
type FileContents,
|
||||||
|
type FileDiffOptions,
|
||||||
|
type SelectedLineRange,
|
||||||
|
type DiffLineAnnotation,
|
||||||
|
type AnnotationSide,
|
||||||
|
type VirtualFileMetrics,
|
||||||
|
} from '@pierre/diffs';
|
||||||
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
|
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
|
||||||
|
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
@@ -120,8 +130,10 @@ const extractSelectedCode = (original: string, modified: string, range: Selected
|
|||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
|
||||||
// Ensure bounds
|
// Ensure bounds
|
||||||
const startLine = Math.max(1, range.start);
|
const from = Math.min(range.start, range.end);
|
||||||
const endLine = Math.min(lines.length, range.end);
|
const to = Math.max(range.start, range.end);
|
||||||
|
const startLine = Math.max(1, from);
|
||||||
|
const endLine = Math.min(lines.length, to);
|
||||||
|
|
||||||
if (startLine > endLine) return '';
|
if (startLine > endLine) return '';
|
||||||
|
|
||||||
@@ -138,6 +150,82 @@ type AnnotationData =
|
|||||||
| { type: 'saved' | 'edit'; draft: InlineCommentDraft }
|
| { type: 'saved' | 'edit'; draft: InlineCommentDraft }
|
||||||
| { type: 'new'; selection: SelectedLineRange };
|
| { type: 'new'; selection: SelectedLineRange };
|
||||||
|
|
||||||
|
type SharedVirtualizer = {
|
||||||
|
virtualizer: Virtualizer;
|
||||||
|
release: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VirtualizerTarget = {
|
||||||
|
key: Document | HTMLElement;
|
||||||
|
root: Document | HTMLElement;
|
||||||
|
content: HTMLElement | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VirtualizerEntry = {
|
||||||
|
virtualizer: Virtualizer;
|
||||||
|
refs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const virtualizerCache = new WeakMap<Document | HTMLElement, VirtualizerEntry>();
|
||||||
|
|
||||||
|
const VIRTUAL_METRICS: Partial<VirtualFileMetrics> = {
|
||||||
|
lineHeight: 24,
|
||||||
|
hunkSeparatorHeight: 24,
|
||||||
|
fileGap: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveVirtualizerTarget(container: HTMLElement): VirtualizerTarget {
|
||||||
|
const root = container.closest('[data-diff-virtual-root]');
|
||||||
|
if (root instanceof HTMLElement) {
|
||||||
|
const content = root.querySelector('[data-diff-virtual-content]');
|
||||||
|
return {
|
||||||
|
key: root,
|
||||||
|
root,
|
||||||
|
content: content instanceof HTMLElement ? content : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: document,
|
||||||
|
root: document,
|
||||||
|
content: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireSharedVirtualizer(container: HTMLElement): SharedVirtualizer | null {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
const target = resolveVirtualizerTarget(container);
|
||||||
|
let entry = virtualizerCache.get(target.key);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
const virtualizer = new Virtualizer();
|
||||||
|
virtualizer.setup(target.root, target.content);
|
||||||
|
entry = { virtualizer, refs: 0 };
|
||||||
|
virtualizerCache.set(target.key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.refs += 1;
|
||||||
|
let released = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
virtualizer: entry.virtualizer,
|
||||||
|
release: () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
|
||||||
|
const current = virtualizerCache.get(target.key);
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
current.refs -= 1;
|
||||||
|
if (current.refs > 0) return;
|
||||||
|
|
||||||
|
current.virtualizer.cleanUp();
|
||||||
|
virtualizerCache.delete(target.key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||||
original,
|
original,
|
||||||
modified,
|
modified,
|
||||||
@@ -161,46 +249,54 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||||
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
|
||||||
|
|
||||||
const getSessionKey = useCallback(() => {
|
const getSessionKey = useCallback(() => {
|
||||||
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
return currentSessionId ?? 'draft';
|
||||||
}, [currentSessionId, newSessionDraftOpen]);
|
}, [currentSessionId]);
|
||||||
|
|
||||||
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
const [selection, setSelection] = useState<SelectedLineRange | null>(null);
|
||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
|
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
|
||||||
|
const selectionRef = useRef<SelectedLineRange | null>(null);
|
||||||
|
const editingDraftIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Use a ref to track if we're currently applying a selection programmatically
|
// Use a ref to track if we're currently applying a selection programmatically
|
||||||
// to avoid loop with onLineSelected callback
|
// to avoid loop with onLineSelected callback
|
||||||
const isApplyingSelectionRef = useRef(false);
|
const isApplyingSelectionRef = useRef(false);
|
||||||
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
|
const lastAppliedSelectionRef = useRef<SelectedLineRange | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectionRef.current = selection;
|
||||||
|
}, [selection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editingDraftIdRef.current = editingDraftId;
|
||||||
|
}, [editingDraftId]);
|
||||||
|
|
||||||
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
|
const handleSelectionChange = useCallback((range: SelectedLineRange | null) => {
|
||||||
// Ignore callbacks while we're programmatically applying selection
|
// Ignore callbacks while we're programmatically applying selection
|
||||||
if (isApplyingSelectionRef.current) {
|
if (isApplyingSelectionRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prevSelection = selectionRef.current;
|
||||||
|
|
||||||
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
|
// Mobile tap-to-extend: if selection exists and new tap is on same side, extend range
|
||||||
if (isMobile && selection && range && range.side === selection.side) {
|
if (isMobile && prevSelection && range && range.side === prevSelection.side) {
|
||||||
const start = Math.min(selection.start, range.start);
|
const start = Math.min(prevSelection.start, range.start);
|
||||||
const end = Math.max(selection.end, range.end);
|
const end = Math.max(prevSelection.end, range.end);
|
||||||
setSelection({ ...range, start, end });
|
setSelection({ ...range, start, end });
|
||||||
return;
|
} else {
|
||||||
|
setSelection(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelection(range);
|
|
||||||
|
|
||||||
// Clear editing state when selection changes user-driven
|
// Clear editing state when selection changes user-driven
|
||||||
if (range) {
|
if (range) {
|
||||||
// Don't clear if we're just updating the selection for the same draft?
|
if (!editingDraftIdRef.current) {
|
||||||
// For now, simple behavior: new selection = new comment flow
|
|
||||||
if (!editingDraftId) {
|
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editingDraftId, isMobile, selection]);
|
}, [isMobile]);
|
||||||
|
|
||||||
const handleCancelComment = useCallback(() => {
|
const handleCancelComment = useCallback(() => {
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
@@ -258,6 +354,14 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const targetRange = rangeOverride ?? selection;
|
const targetRange = rangeOverride ?? selection;
|
||||||
if (!targetRange || !textToSave.trim()) return;
|
if (!targetRange || !textToSave.trim()) return;
|
||||||
|
|
||||||
|
const normalizedStart = Math.min(targetRange.start, targetRange.end);
|
||||||
|
const normalizedEnd = Math.max(targetRange.start, targetRange.end);
|
||||||
|
const normalizedRange: SelectedLineRange = {
|
||||||
|
...targetRange,
|
||||||
|
start: normalizedStart,
|
||||||
|
end: normalizedEnd,
|
||||||
|
};
|
||||||
|
|
||||||
const sessionKey = getSessionKey();
|
const sessionKey = getSessionKey();
|
||||||
if (!sessionKey) {
|
if (!sessionKey) {
|
||||||
toast.error('Select a session to save comment');
|
toast.error('Select a session to save comment');
|
||||||
@@ -267,16 +371,16 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
// Pierre selection range: { start, end, side }
|
// Pierre selection range: { start, end, side }
|
||||||
// Store needs { startLine, endLine, side: 'original'|'modified' }
|
// Store needs { startLine, endLine, side: 'original'|'modified' }
|
||||||
// Pierre side: 'additions' (right) | 'deletions' (left)
|
// Pierre side: 'additions' (right) | 'deletions' (left)
|
||||||
const storeSide = targetRange.side === 'deletions' ? 'original' : 'modified';
|
const storeSide = normalizedRange.side === 'deletions' ? 'original' : 'modified';
|
||||||
|
|
||||||
// Use deterministic code extraction instead of instance.getSelectedText()
|
// Use deterministic code extraction instead of instance.getSelectedText()
|
||||||
const selectedText = extractSelectedCode(original, modified, targetRange);
|
const selectedText = extractSelectedCode(original, modified, normalizedRange);
|
||||||
|
|
||||||
if (editingDraftId) {
|
if (editingDraftId) {
|
||||||
updateDraft(sessionKey, editingDraftId, {
|
updateDraft(sessionKey, editingDraftId, {
|
||||||
fileLabel: fileName || 'unknown',
|
fileLabel: fileName || 'unknown',
|
||||||
startLine: targetRange.start,
|
startLine: normalizedRange.start,
|
||||||
endLine: targetRange.end,
|
endLine: normalizedRange.end,
|
||||||
side: storeSide,
|
side: storeSide,
|
||||||
code: selectedText,
|
code: selectedText,
|
||||||
language: language,
|
language: language,
|
||||||
@@ -287,8 +391,8 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
source: 'diff',
|
source: 'diff',
|
||||||
fileLabel: fileName || 'unknown',
|
fileLabel: fileName || 'unknown',
|
||||||
startLine: targetRange.start,
|
startLine: normalizedRange.start,
|
||||||
endLine: targetRange.end,
|
endLine: normalizedRange.end,
|
||||||
side: storeSide,
|
side: storeSide,
|
||||||
code: selectedText,
|
code: selectedText,
|
||||||
language: language,
|
language: language,
|
||||||
@@ -325,6 +429,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const diffRootRef = useRef<HTMLDivElement | null>(null);
|
const diffRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
const diffContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
|
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
|
||||||
|
const sharedVirtualizerRef = useRef<SharedVirtualizer | null>(null);
|
||||||
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
|
||||||
const workerPool = useWorkerPool();
|
const workerPool = useWorkerPool();
|
||||||
|
|
||||||
@@ -417,6 +522,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
hunkSeparators: 'line-info' as const,
|
hunkSeparators: 'line-info' as const,
|
||||||
// Perf: disable intra-line diff (word-level) globally.
|
// Perf: disable intra-line diff (word-level) globally.
|
||||||
lineDiffType: 'none' as const,
|
lineDiffType: 'none' as const,
|
||||||
|
maxLineDiffLength: 1000,
|
||||||
|
maxLineLengthForHighlighting: 1000,
|
||||||
|
expansionLineCount: 20,
|
||||||
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
|
||||||
disableFileHeader: true,
|
disableFileHeader: true,
|
||||||
enableLineSelection: true,
|
enableLineSelection: true,
|
||||||
@@ -469,7 +577,12 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
return anns;
|
return anns;
|
||||||
}, [allDrafts, getSessionKey, fileName, editingDraftId, selection]);
|
}, [allDrafts, getSessionKey, fileName, editingDraftId, selection]);
|
||||||
|
|
||||||
// Imperative render (like upstream OpenCode): avoids `parseDiffFromFile` on main thread.
|
const lineAnnotationsRef = useRef(lineAnnotations);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
lineAnnotationsRef.current = lineAnnotations;
|
||||||
|
}, [lineAnnotations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
@@ -480,9 +593,21 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
// Dispose previous instance
|
// Dispose previous instance
|
||||||
diffInstanceRef.current?.cleanUp();
|
diffInstanceRef.current?.cleanUp();
|
||||||
diffInstanceRef.current = null;
|
diffInstanceRef.current = null;
|
||||||
|
sharedVirtualizerRef.current?.release();
|
||||||
|
sharedVirtualizerRef.current = null;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
const instance = new PierreFileDiff(options as unknown as FileDiffOptions<unknown>, workerPool);
|
const sharedVirtualizer = acquireSharedVirtualizer(container);
|
||||||
|
sharedVirtualizerRef.current = sharedVirtualizer;
|
||||||
|
|
||||||
|
const instance = sharedVirtualizer
|
||||||
|
? new VirtualizedFileDiff(
|
||||||
|
options as unknown as FileDiffOptions<unknown>,
|
||||||
|
sharedVirtualizer.virtualizer,
|
||||||
|
VIRTUAL_METRICS,
|
||||||
|
workerPool,
|
||||||
|
)
|
||||||
|
: new PierreFileDiff(options as unknown as FileDiffOptions<unknown>, workerPool);
|
||||||
diffInstanceRef.current = instance;
|
diffInstanceRef.current = instance;
|
||||||
lastAppliedSelectionRef.current = null;
|
lastAppliedSelectionRef.current = null;
|
||||||
|
|
||||||
@@ -502,7 +627,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
instance.render({
|
instance.render({
|
||||||
oldFile,
|
oldFile,
|
||||||
newFile,
|
newFile,
|
||||||
lineAnnotations,
|
lineAnnotations: lineAnnotationsRef.current,
|
||||||
containerWrapper: container,
|
containerWrapper: container,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -514,9 +639,29 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
if (diffInstanceRef.current === instance) {
|
if (diffInstanceRef.current === instance) {
|
||||||
diffInstanceRef.current = null;
|
diffInstanceRef.current = null;
|
||||||
}
|
}
|
||||||
|
sharedVirtualizer?.release();
|
||||||
|
if (sharedVirtualizer && sharedVirtualizerRef.current === sharedVirtualizer) {
|
||||||
|
sharedVirtualizerRef.current = null;
|
||||||
|
}
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
};
|
};
|
||||||
}, [diffThemeKey, fileName, language, modified, options, original, workerPool, lineAnnotations]);
|
}, [diffThemeKey, fileName, language, modified, options, original, workerPool]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const instance = diffInstanceRef.current;
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
instance.setLineAnnotations(lineAnnotations);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (diffInstanceRef.current !== instance) return;
|
||||||
|
try {
|
||||||
|
instance.rerender();
|
||||||
|
} catch (err) {
|
||||||
|
void err;
|
||||||
|
}
|
||||||
|
forceUpdate();
|
||||||
|
});
|
||||||
|
}, [lineAnnotations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const instance = diffInstanceRef.current;
|
const instance = diffInstanceRef.current;
|
||||||
@@ -545,6 +690,64 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selection]);
|
}, [selection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = diffContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
let cleanup = () => {};
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
const host = container.querySelector('diffs-container');
|
||||||
|
const shadowRoot = host?.shadowRoot;
|
||||||
|
if (!shadowRoot) {
|
||||||
|
rafId = requestAnimationFrame(setup);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickCapture = (event: Event) => {
|
||||||
|
if (!(event instanceof MouseEvent) || event.button !== 0) return;
|
||||||
|
if (!(event.target instanceof Element)) return;
|
||||||
|
|
||||||
|
const numberCell = event.target.closest('[data-column-number]');
|
||||||
|
if (!(numberCell instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const lineRaw = numberCell.getAttribute('data-column-number');
|
||||||
|
const lineNumber = lineRaw ? parseInt(lineRaw, 10) : NaN;
|
||||||
|
if (Number.isNaN(lineNumber)) return;
|
||||||
|
|
||||||
|
const lineType =
|
||||||
|
numberCell.closest('[data-line-type]')?.getAttribute('data-line-type')
|
||||||
|
?? numberCell.getAttribute('data-line-type');
|
||||||
|
|
||||||
|
const side: AnnotationSide = lineType === 'change-deletion' ? 'deletions' : 'additions';
|
||||||
|
|
||||||
|
handleSelectionChange({
|
||||||
|
start: lineNumber,
|
||||||
|
end: lineNumber,
|
||||||
|
side,
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
shadowRoot.addEventListener('click', onClickCapture, true);
|
||||||
|
cleanup = () => {
|
||||||
|
shadowRoot.removeEventListener('click', onClickCapture, true);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setup();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [diffThemeKey, fileName, handleSelectionChange]);
|
||||||
|
|
||||||
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
|
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = diffContainerRef.current;
|
const container = diffContainerRef.current;
|
||||||
@@ -669,12 +872,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
|
|
||||||
if (layout === 'fill') {
|
if (layout === 'fill') {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col relative", "size-full")}>
|
<div className={cn("flex flex-col relative", "size-full")} data-diff-virtual-root>
|
||||||
<div className="flex-1 relative min-h-0">
|
<div className="flex-1 relative min-h-0">
|
||||||
<ScrollableOverlay
|
<ScrollableOverlay
|
||||||
outerClassName="pierre-diff-wrapper size-full"
|
outerClassName="pierre-diff-wrapper size-full"
|
||||||
disableHorizontal={false}
|
disableHorizontal={false}
|
||||||
fillContainer={true}
|
fillContainer={true}
|
||||||
|
data-diff-virtual-content
|
||||||
>
|
>
|
||||||
<div ref={diffRootRef} className="size-full relative">
|
<div ref={diffRootRef} className="size-full relative">
|
||||||
<div ref={diffContainerRef} className="size-full" />
|
<div ref={diffContainerRef} className="size-full" />
|
||||||
@@ -696,5 +900,3 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||||
"@ibm/plex": "^6.4.1",
|
"@ibm/plex": "^6.4.1",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "^1.1.53",
|
"@opencode-ai/sdk": "^1.1.65",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
Reference in New Issue
Block a user