diff --git a/bun.lock b/bun.lock index 2c18bde1..eeea1ce9 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@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-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -95,7 +95,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.6.6", + "version": "1.6.8", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -104,7 +104,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.6.6", + "version": "1.6.8", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -132,8 +132,8 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.53", - "@pierre/diffs": "^1.0.5", + "@opencode-ai/sdk": "^1.1.65", + "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -198,10 +198,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.6.6", + "version": "1.6.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.53", + "@opencode-ai/sdk": "^1.1.65", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -221,7 +221,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.6.6", + "version": "1.6.8", "bin": { "openchamber": "./bin/cli.js", }, @@ -230,7 +230,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.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-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -843,9 +843,9 @@ "@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=="], diff --git a/package.json b/package.json index f6bd1c75..0312130d 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@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-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index d8580cbd..8f9f4a72 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,8 +37,8 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.53", - "@pierre/diffs": "^1.0.5", + "@opencode-ai/sdk": "^1.1.65", + "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx index 995f011e..82226186 100644 --- a/packages/ui/src/components/comments/InlineCommentInput.tsx +++ b/packages/ui/src/components/comments/InlineCommentInput.tsx @@ -38,24 +38,62 @@ export function InlineCommentInput({ } }, [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 useEffect(() => { - if (!isMobile && textareaRef.current) { - textareaRef.current.focus(); - // Move cursor to end - const len = textareaRef.current.value.length; - textareaRef.current.setSelectionRange(len, len); - } else if (isMobile && textareaRef.current) { - // Scroll into view on mobile - textareaRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const textarea = textareaRef.current; + if (!textarea) return; + + const scrollContainer = textarea.closest('.overlay-scrollbar-container') as HTMLElement | null; + const prevScrollTop = scrollContainer?.scrollTop ?? window.scrollY; + const prevScrollLeft = scrollContainer?.scrollLeft ?? window.scrollX; + + 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]); const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (text.trim()) { - onSave(text, stableRangeRef.current); + onSave(text, normalizeRange(stableRangeRef.current)); } } else if (e.key === 'Escape') { e.preventDefault(); @@ -67,7 +105,7 @@ export function InlineCommentInput({ // Stop propagation to prevent parent selection clearing before save e.stopPropagation(); if (text.trim()) { - onSave(text, stableRangeRef.current); + onSave(text, normalizeRange(stableRangeRef.current)); } }; @@ -90,7 +128,7 @@ export function InlineCommentInput({
{fileLabel && {fileLabel}} {fileLabel && lineRange && } - {lineRange && Lines {lineRange.start}-{lineRange.end}} + {displayRange && Lines {displayRange.start}-{displayRange.end}}
)} diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index eda371ac..224f7f9a 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -1082,6 +1082,8 @@ export const DiffView: React.FC = () => { outerClassName="flex-1 min-h-0 h-full" className="pr-2" disableHorizontal + data-diff-virtual-root + data-diff-virtual-content >
{changedFiles.map((file, index) => ( @@ -1144,7 +1146,7 @@ export const DiffView: React.FC = () => { } return ( -
+
{renderSelectedDiffViewer()} {isCurrentFileLoading && !hasCurrentDiff && (
diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 1d5ca241..58ba45ad 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -1,6 +1,16 @@ import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react'; 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 { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; @@ -120,8 +130,10 @@ const extractSelectedCode = (original: string, modified: string, range: Selected const lines = content.split('\n'); // Ensure bounds - const startLine = Math.max(1, range.start); - const endLine = Math.min(lines.length, range.end); + const from = Math.min(range.start, 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 ''; @@ -138,6 +150,82 @@ type AnnotationData = | { type: 'saved' | 'edit'; draft: InlineCommentDraft } | { 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(); + +const VIRTUAL_METRICS: Partial = { + 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 = ({ original, modified, @@ -161,46 +249,54 @@ export const PierreDiffViewer: React.FC = ({ const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft); const allDrafts = useInlineCommentDraftStore((state) => state.drafts); const currentSessionId = useSessionStore((state) => state.currentSessionId); - const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open); const getSessionKey = useCallback(() => { - return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); - }, [currentSessionId, newSessionDraftOpen]); + return currentSessionId ?? 'draft'; + }, [currentSessionId]); const [selection, setSelection] = useState(null); const [commentText, setCommentText] = useState(''); const [editingDraftId, setEditingDraftId] = useState(null); + const selectionRef = useRef(null); + const editingDraftIdRef = useRef(null); // Use a ref to track if we're currently applying a selection programmatically // to avoid loop with onLineSelected callback const isApplyingSelectionRef = useRef(false); const lastAppliedSelectionRef = useRef(null); + useEffect(() => { + selectionRef.current = selection; + }, [selection]); + + useEffect(() => { + editingDraftIdRef.current = editingDraftId; + }, [editingDraftId]); + const handleSelectionChange = useCallback((range: SelectedLineRange | null) => { // Ignore callbacks while we're programmatically applying selection if (isApplyingSelectionRef.current) { return; } - + + const prevSelection = selectionRef.current; + // Mobile tap-to-extend: if selection exists and new tap is on same side, extend range - if (isMobile && selection && range && range.side === selection.side) { - const start = Math.min(selection.start, range.start); - const end = Math.max(selection.end, range.end); + if (isMobile && prevSelection && range && range.side === prevSelection.side) { + const start = Math.min(prevSelection.start, range.start); + const end = Math.max(prevSelection.end, range.end); setSelection({ ...range, start, end }); - return; + } else { + setSelection(range); } - - setSelection(range); - + // Clear editing state when selection changes user-driven if (range) { - // Don't clear if we're just updating the selection for the same draft? - // For now, simple behavior: new selection = new comment flow - if (!editingDraftId) { + if (!editingDraftIdRef.current) { setCommentText(''); } } - }, [editingDraftId, isMobile, selection]); + }, [isMobile]); const handleCancelComment = useCallback(() => { setCommentText(''); @@ -258,6 +354,14 @@ export const PierreDiffViewer: React.FC = ({ const targetRange = rangeOverride ?? selection; 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(); if (!sessionKey) { toast.error('Select a session to save comment'); @@ -267,16 +371,16 @@ export const PierreDiffViewer: React.FC = ({ // Pierre selection range: { start, end, side } // Store needs { startLine, endLine, side: 'original'|'modified' } // 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() - const selectedText = extractSelectedCode(original, modified, targetRange); + const selectedText = extractSelectedCode(original, modified, normalizedRange); if (editingDraftId) { updateDraft(sessionKey, editingDraftId, { fileLabel: fileName || 'unknown', - startLine: targetRange.start, - endLine: targetRange.end, + startLine: normalizedRange.start, + endLine: normalizedRange.end, side: storeSide, code: selectedText, language: language, @@ -287,8 +391,8 @@ export const PierreDiffViewer: React.FC = ({ sessionKey, source: 'diff', fileLabel: fileName || 'unknown', - startLine: targetRange.start, - endLine: targetRange.end, + startLine: normalizedRange.start, + endLine: normalizedRange.end, side: storeSide, code: selectedText, language: language, @@ -325,6 +429,7 @@ export const PierreDiffViewer: React.FC = ({ const diffRootRef = useRef(null); const diffContainerRef = useRef(null); const diffInstanceRef = useRef | null>(null); + const sharedVirtualizerRef = useRef(null); const [, forceUpdate] = React.useReducer((x) => x + 1, 0); const workerPool = useWorkerPool(); @@ -417,6 +522,9 @@ export const PierreDiffViewer: React.FC = ({ hunkSeparators: 'line-info' as const, // Perf: disable intra-line diff (word-level) globally. lineDiffType: 'none' as const, + maxLineDiffLength: 1000, + maxLineLengthForHighlighting: 1000, + expansionLineCount: 20, overflow: wrapLines ? ('wrap' as const) : ('scroll' as const), disableFileHeader: true, enableLineSelection: true, @@ -469,7 +577,12 @@ export const PierreDiffViewer: React.FC = ({ return anns; }, [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(() => { if (typeof window === 'undefined') return; @@ -480,9 +593,21 @@ export const PierreDiffViewer: React.FC = ({ // Dispose previous instance diffInstanceRef.current?.cleanUp(); diffInstanceRef.current = null; + sharedVirtualizerRef.current?.release(); + sharedVirtualizerRef.current = null; container.innerHTML = ''; - const instance = new PierreFileDiff(options as unknown as FileDiffOptions, workerPool); + const sharedVirtualizer = acquireSharedVirtualizer(container); + sharedVirtualizerRef.current = sharedVirtualizer; + + const instance = sharedVirtualizer + ? new VirtualizedFileDiff( + options as unknown as FileDiffOptions, + sharedVirtualizer.virtualizer, + VIRTUAL_METRICS, + workerPool, + ) + : new PierreFileDiff(options as unknown as FileDiffOptions, workerPool); diffInstanceRef.current = instance; lastAppliedSelectionRef.current = null; @@ -502,7 +627,7 @@ export const PierreDiffViewer: React.FC = ({ instance.render({ oldFile, newFile, - lineAnnotations, + lineAnnotations: lineAnnotationsRef.current, containerWrapper: container, }); @@ -514,9 +639,29 @@ export const PierreDiffViewer: React.FC = ({ if (diffInstanceRef.current === instance) { diffInstanceRef.current = null; } + sharedVirtualizer?.release(); + if (sharedVirtualizer && sharedVirtualizerRef.current === sharedVirtualizer) { + sharedVirtualizerRef.current = null; + } 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(() => { const instance = diffInstanceRef.current; @@ -545,6 +690,64 @@ export const PierreDiffViewer: React.FC = ({ } }, [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 useEffect(() => { const container = diffContainerRef.current; @@ -669,12 +872,13 @@ export const PierreDiffViewer: React.FC = ({ if (layout === 'fill') { return ( -
+
@@ -696,5 +900,3 @@ export const PierreDiffViewer: React.FC = ({
); }; - - diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 1a3949db..3ff8a543 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -229,7 +229,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.53", + "@opencode-ai/sdk": "^1.1.65", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 98bd5012..a97b74b2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.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-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",