diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 99e0b07c1845e..5d9c2fc9c9e95 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -776,12 +776,12 @@ export default function DiffPanel({ return next; }); }); - // Find can ask again before the unfolded file reaches the viewer, so this must never fold. - const unfoldDiffFile = useStableCallback((fileKey: string) => { + const setDiffFileCollapsed = useStableCallback((fileKey: string, collapsed: boolean) => { updateCollapsedDiffFiles((current) => { - if (!current.has(fileKey)) return current; + if (current.has(fileKey) === collapsed) return current; const next = new Set(current); - next.delete(fileKey); + if (collapsed) next.add(fileKey); + else next.delete(fileKey); return next; }); }); @@ -1458,7 +1458,7 @@ export default function DiffPanel({ }, } : {})} - onRevealSearchMatch={unfoldDiffFile} + onFileCollapsedChange={setDiffFileCollapsed} renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { return ( null} renderHeaderPrefix={() => null} - onRevealSearchMatch={() => {}} + onFileCollapsedChange={() => {}} /> ); diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index f5a0da4b1a903..1fb8b99f7c4ca 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -99,7 +99,7 @@ interface AnnotatableCodeViewProps { fileKey: string, collapsed: boolean, ) => ReactNode; - onRevealSearchMatch: (fileKey: string) => void; + onFileCollapsedChange: (fileKey: string, collapsed: boolean) => void; } interface DiffSelectionContext { @@ -124,7 +124,7 @@ export function AnnotatableCodeView({ renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, - onRevealSearchMatch, + onFileCollapsedChange, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -280,7 +280,8 @@ export function AnnotatableCodeView({ enableGutterUtility: draft === null, enableLineSelection: draft === null, onGutterUtilityClick: beginComment, - onRevealSearchMatch: (item) => onRevealSearchMatch(item.id), + onItemCollapsedChange: (_item, nextItem) => + onFileCollapsedChange(nextItem.id, nextItem.collapsed === true), }} renderHeaderFilenameSuffix={(item) => item.type === "diff" ? renderHeaderFilenameSuffix(item.fileDiff) : null diff --git a/apps/web/src/components/diffs/codeViewSearch.browser.test.ts b/apps/web/src/components/diffs/codeViewSearch.browser.test.ts deleted file mode 100644 index 1d9b418ad7c2e..0000000000000 --- a/apps/web/src/components/diffs/codeViewSearch.browser.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - CodeView, - DEFAULT_THEMES, - parseDiffFromFile, - preloadHighlighter, - type CodeViewItem, -} from "@pierre/diffs"; -import { expect, it, vi } from "vite-plus/test"; - -const LINES = Array.from({ length: 80 }, (_, index) => `line ${index + 1}`); - -function contents(changes: Record) { - return `${LINES.map((line, index) => changes[index] ?? line).join("\n")}\n`; -} - -function currentMatchText() { - return Array.from(CSS.highlights.get("diffs-search-current") ?? [], String); -} - -// Upstream find skips folded files and collapsed context, and drops focus on close. Our @pierre/diffs patch covers all three. -it("finds and reveals matches in folded files and collapsed context, then hands focus back", async () => { - await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["text"] }); - let items: CodeViewItem[] = [ - { - id: "folded", - type: "diff", - collapsed: true, - fileDiff: parseDiffFromFile( - { name: "folded.txt", contents: "old\n" }, - { name: "folded.txt", contents: "NEEDLE added\n" }, - ), - }, - { - id: "context", - type: "diff", - fileDiff: parseDiffFromFile( - { name: "context.txt", contents: contents({ 39: "needle in context" }) }, - { - name: "context.txt", - contents: contents({ 0: "changed", 39: "needle in context", 79: "changed" }), - }, - ), - }, - ]; - const root = document.createElement("div"); - root.style.cssText = "width: 800px; height: 300px; overflow: auto;"; - document.body.append(root); - const search = { matchCount: 0, currentMatchIndex: undefined as number | undefined }; - const view = new CodeView({ - diffStyle: "unified", - searchOptions: { scrollBehavior: "instant" }, - onSearchStateChange: (state) => { - search.matchCount = state?.matchCount ?? 0; - search.currentMatchIndex = state?.currentMatchIndex; - }, - onRevealSearchMatch: (item) => { - items = items.map((candidate) => - candidate.id === item.id ? { ...candidate, collapsed: false, version: 1 } : candidate, - ); - view.setItems(items); - }, - }); - using _view = { - [Symbol.dispose]: () => { - view.cleanUp(); - root.remove(); - }, - }; - view.setup(root); - view.setItems(items); - - view.openSearch({ query: "needle" }); - await vi.waitFor(() => expect(search).toEqual({ matchCount: 2, currentMatchIndex: 0 })); - await vi.waitFor(() => expect(currentMatchText()).toEqual(["NEEDLE"])); - - view.navigateSearch("next"); - await vi.waitFor(() => expect(currentMatchText()).toEqual(["needle"])); - expect(search.currentMatchIndex).toBe(1); - - // Focus that leaves the viewer on close takes the find shortcut with it. - const input = root.querySelector("[data-search-panel]")?.shadowRoot?.querySelector("input"); - input!.dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true, composed: true }), - ); - expect(view.isSearchOpen()).toBe(false); - expect(document.activeElement).toBe(root); -}); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 3ea861cbf9ffd..a753471501b04 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1036,9 +1036,13 @@ function PullRequestCodeTab({ onGutterUtilityClick: beginComment, onLineSelectionEnd: beginComment, onPostRender: codeViewReveal.flush, - // Find may repeat requests before render; only unfold, never toggle. - onRevealSearchMatch: (item: CodeViewItem) => - setFoldChoices((current) => withFileDiffFold(current, item.id, false)), + onItemCollapsedChange: ( + _item: CodeViewItem, + nextItem: CodeViewItem, + ) => + setFoldChoices((current) => + withFileDiffFold(current, nextItem.id, nextItem.collapsed === true), + ), }; const runThreadCommand = async ( diff --git a/patches/@pierre__diffs@1.7.0-beta.3.patch b/patches/@pierre__diffs@1.7.0-beta.3.patch deleted file mode 100644 index 5704f9c799cf0..0000000000000 --- a/patches/@pierre__diffs@1.7.0-beta.3.patch +++ /dev/null @@ -1,423 +0,0 @@ -diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts -index 774c1abbf4c00586b4fd3af0bf525a5d7c45d015..8e27d2982f5c593e7204bc8fdae9a7ad5e8a85e4 100644 ---- a/dist/components/CodeView.d.ts -+++ b/dist/components/CodeView.d.ts -@@ -166,6 +166,13 @@ interface CodeViewOptions extends CodeViewPassThroughOptions - * change, and with undefined when it closes. - */ - onSearchStateChange?(state: SearchPanelState | undefined): void; -+ /** -+ * T3 patch. Makes find cover collapsed items and collapsed diff context. -+ * Called when find navigates to a match in a collapsed item: folding is host -+ * state, so the host unfolds it (a controlled update with `collapsed: false` -+ * and a new `version`) and the viewer then scrolls to the match. -+ */ -+ onRevealSearchMatch?(item: CodeViewItem): void; - /** - * Return an in-memory retention key for an item's editable draft and - * undo/redo history. Called only when CodeView creates the item's editor. -diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js -index 6ee5032aee523b4c3e10f70202874f141f28ff10..94cd3f4b2e16490994707c28fa45c4f43f0c6065 100644 ---- a/dist/components/CodeView.js -+++ b/dist/components/CodeView.js -@@ -213,7 +213,8 @@ var CodeView = class CodeView { - matchesByItem: /* @__PURE__ */ new Map(), - current: void 0, - centerNavigation: false, -- pendingHorizontalReveal: false -+ pendingHorizontalReveal: false, -+ pendingReveal: void 0 - }; - searchMatchesDirty = false; - searchHighlighter; -@@ -593,10 +594,29 @@ var CodeView = class CodeView { - this.searchState.centerNavigation = false; - this.searchMatchesDirty = false; - this.searchState.pendingHorizontalReveal = false; -+ this.searchState.pendingReveal = void 0; -+ } -+ /** T3 patch: whether a diff match sits in collapsed context. Deletion cells carry their row's addition line. */ -+ isSearchMatchHidden(item, match) { -+ const lineNumber = match.side === "additions" ? match.lineNumber : match.revealLineNumber; -+ return item.type === "diff" && lineNumber != null && !item.instance.isLineRenderable(lineNumber); -+ } -+ /** T3 patch: finish a reveal that waited for the host to unfold the item, or for its first render. */ -+ resumeSearchReveal(updatedItems) { -+ const { current: match, pendingReveal } = this.searchState; -+ if (pendingReveal == null || match == null) return; -+ for (const item of updatedItems) { -+ if (item.item.id !== match.itemId || item.item.collapsed === true || item.type === "diff" && item.instance.getRenderedDiff() == null) continue; -+ this.searchState.pendingReveal = void 0; -+ if (pendingReveal === "unfold" || this.isSearchMatchHidden(item, match)) queueMicrotask(() => { -+ if (areOptionalCodeViewSearchMatchesEqual(this.searchState.current, match)) this.scrollToSearchMatch(match); -+ }); -+ return; -+ } - } - isSearchMatchInView(match) { - const item = this.idToItem.get(match.itemId); -- if (item == null || item.type === "diff" && match.side === "additions" && !item.instance.isLineRenderable(match.lineNumber)) return false; -+ if (item == null || this.isSearchMatchHidden(item, match)) return false; - const rect = this.getScrollTargetRect({ - type: "line", - id: match.itemId, -@@ -616,13 +636,21 @@ var CodeView = class CodeView { - this.searchState.pendingHorizontalReveal = true; - this.searchState.current = match; - this.render(); -- if (!this.searchState.centerNavigation && this.isSearchMatchInView(match)) { -+ const item = this.idToItem.get(match.itemId); -+ const folded = item?.item.collapsed === true; -+ this.searchState.pendingReveal = void 0; -+ if (!folded && !this.searchState.centerNavigation && this.isSearchMatchInView(match)) { - this.clearPendingScroll(); - return; - } - this.searchState.centerNavigation = true; -- const item = this.idToItem.get(match.itemId); -- if (item?.type === "diff" && match.side === "additions" && !item.instance.isLineRenderable(match.lineNumber)) item.instance.revealLine(match.lineNumber); -+ // T3 patch: folding is host state, and a diff that has not rendered reports every line -+ // renderable. Scroll to the item now; resumeSearchReveal finishes once it has rendered. -+ if (folded) { -+ this.searchState.pendingReveal = "unfold"; -+ this.options.onRevealSearchMatch?.(item.item); -+ } else if (item?.type === "diff" && item.instance.getRenderedDiff() == null) this.searchState.pendingReveal = "render"; -+ else if (item != null && this.isSearchMatchHidden(item, match)) item.instance.revealLine(match.revealLineNumber ?? match.lineNumber); - this.scrollTo({ - type: "line", - id: match.itemId, -@@ -732,9 +760,12 @@ var CodeView = class CodeView { - for (const item of items) { - const remaining = MAX_SEARCH_MATCHES - matches.length; - if (remaining <= 0) break; -- if (item.item.collapsed === true) continue; -+ // T3 patch: a host that can unfold items (onRevealSearchMatch) gets them searched. -+ if (item.item.collapsed === true && this.options.onRevealSearchMatch == null) continue; - const nextMatches = (scope != null || item.type === "file" ? this.searchEditedItem(item, searchParams, remaining) : void 0) ?? (item.type === "file" ? this.searchFileItem(item, searchParams, remaining) : this.searchDiffItem(item, searchParams, remaining)); - const bounds = item.element == null ? void 0 : item.instance.getRenderedBounds(); -+ // T3 patch: a mounted folded item has no rendered lines, so its header stands in for them. -+ if (item.item.collapsed === true && item.element != null && nextMatches.length > 0) firstRendered ??= matches.length; - for (const match of nextMatches) { - const canBeRendered = firstVisible == null && bounds != null; - const canBeAfter = firstAfterRendered == null && lastRenderedItem != null && item.index >= lastRenderedItem.index; -@@ -744,7 +775,7 @@ var CodeView = class CodeView { - lineNumber: match.lineNumber, - side: match.side - }) : void 0; -- if (canBeRendered && position != null && position.height > 0 && position.top >= bounds.top && position.top < bounds.bottom && (item.type !== "diff" || match.side !== "additions" || item.instance.isLineRenderable(match.lineNumber))) { -+ if (canBeRendered && position != null && position.height > 0 && position.top >= bounds.top && position.top < bounds.bottom) { - firstRendered ??= matches.length; - if (this.isSearchMatchInView(match)) firstVisible = matches.length; - } -@@ -791,9 +822,10 @@ var CodeView = class CodeView { - const fileDiff = doc == null ? item.item.fileDiff : item.instance.getDiffForSearch() ?? item.item.fileDiff; - const scoped = this.searchState.scope?.itemId === item.item.id; - const diffStyle = this.options.diffStyle ?? "split"; -- const expandedHunks = scoped || this.options.expandUnchanged === true ? true : item.instance.getExpandedHunksForSearch(); -+ // T3 patch: the same opt-in searches collapsed context; navigating to a match expands it. -+ const expandedHunks = scoped || this.options.expandUnchanged === true || this.options.onRevealSearchMatch != null ? true : item.instance.getExpandedHunksForSearch(); - const lines = []; -- const addLine = (side, line) => { -+ const addLine = (side, line, revealLineNumber) => { - if (line === void 0) return; - const sourceLines = side === "additions" ? fileDiff.additionLines : fileDiff.deletionLines; - const text = side === "additions" && doc != null ? doc.getLineText(line.lineIndex, true) : sourceLines[line.lineIndex]; -@@ -805,7 +837,8 @@ var CodeView = class CodeView { - itemType: "diff", - side, - lineNumber: line.lineNumber, -- lineIndex: line.lineIndex -+ lineIndex: line.lineIndex, -+ revealLineNumber - } - }); - }; -@@ -824,7 +857,7 @@ var CodeView = class CodeView { - else addLine("deletions", deletionLine); - return; - } -- addLine("deletions", deletionLine); -+ addLine("deletions", deletionLine, additionLine?.lineNumber); - addLine("additions", additionLine); - } - }); -@@ -1081,6 +1114,9 @@ var CodeView = class CodeView { - this.resetSearchState(); - if (searchPanel == null) return; - searchPanel.cleanup(); -+ // T3 patch: a panel that closes while focused drops focus to the body; keep it in the viewer. -+ const doc = this.root?.ownerDocument; -+ if (doc != null && doc.activeElement === doc.body) this.root.focus({ preventScroll: true }); - const { onSearchStateChange } = this.options; - onSearchStateChange?.(void 0); - } -@@ -2239,6 +2275,7 @@ var CodeView = class CodeView { - if (measured) this.validateStickyContainerHeight(); - this.fixContainerFocus(); - this.paintSearchHighlights(); -+ this.resumeSearchReveal(updatedItems); - if (this.pendingScrollTarget == null) this.finishPendingScroll("settled"); - if (fitPerfectly || this.scrollAnimation != null || this.renderState.measurementsPending) this.render(); - }; -@@ -2340,6 +2377,7 @@ var CodeView = class CodeView { - clearPendingScroll = (event) => { - if (event != null && !isUserScrollInput(event)) return; - if (event != null) this.searchState.pendingHorizontalReveal = false; -+ if (event != null) this.searchState.pendingReveal = void 0; - this.pendingLayoutAnchor = void 0; - this.cancelScroll(); - }; -diff --git a/dist/components/FileDiff.js b/dist/components/FileDiff.js -index 734ff9862cdfe65b8082599c6c221c4886436e01..f18798a3d56dd0749550b5aff62a06fb0513b28e 100644 ---- a/dist/components/FileDiff.js -+++ b/dist/components/FileDiff.js -@@ -1,7 +1,8 @@ --import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, EMPTY_LINE_ANNOTATIONS, EMPTY_RENDER_RANGE, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; -+import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, EMPTY_LINE_ANNOTATIONS, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; - import { isSafari } from "../utils/platform.js"; - import { HighlighterDisposedError } from "../highlighter/highlighterType.js"; - import { getThemes } from "../utils/getThemes.js"; -+import { getHighlighterThemeStyles } from "../utils/getHighlighterThemeStyles.js"; - import { getHighlighterIfLoaded, getSharedHighlighter } from "../highlighter/shared_highlighter.js"; - import { dequeueRender, queueRender } from "../managers/UniversalRenderingManager.js"; - import { areObjectsEqual } from "../utils/areObjectsEqual.js"; -@@ -733,7 +734,7 @@ var FileDiff = class { - if (expandUnchanged) this.loadFilesIfNecessary(); - this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); - this.syncInteractionOptions(); -- this.prepareRenderData(this.hunksRenderer.getDiffForNextRender(workingDiff)); -+ this.prepareRenderData(collapsed ? workingDiff : this.hunksRenderer.getDiffForNextRender(workingDiff)); - const { disableErrorHandling = false, disableFileHeader = false } = this.options; - if (disableFileHeader) { - if (this.headerElement != null) { -@@ -750,11 +751,22 @@ var FileDiff = class { - this.removeRenderedCode(); - this.clearAuxiliaryNodes(); - try { -- const hunksResult = this.hunksRenderer.renderDiff(workingDiff, EMPTY_RENDER_RANGE); -- if (hunksResult != null) this.applyThemeState(fileContainer, hunksResult.themeStyles, themeType, hunksResult.baseThemeType); -- if (hunksResult?.headerElement != null) this.applyHeaderToDOM(hunksResult.headerElement, fileContainer, hunksResult.fileDiff); -+ // An empty render range still highlights the full diff. Headers only need the theme. -+ const theme = this.getTheme(); -+ const highlighter = getHighlighterIfLoaded({ theme, lang: "text" }); -+ if (highlighter != null) this.applyThemeState(fileContainer, getHighlighterThemeStyles({ theme, highlighter }), themeType, typeof theme === "string" ? highlighter.getTheme(theme).type : void 0); -+ else getSharedHighlighter({ -+ themes: getThemes(theme), -+ langs: ["text"], -+ preferredHighlighter: resolvePreferredHighlighter(this.workerManager, this.options) -+ }).then(() => { -+ if (this.enabled && this.fileContainer === fileContainer && this.options.collapsed && areThemesEqual(theme, this.getTheme())) this.rerender(); -+ }).catch((error) => { -+ if (!(error instanceof HighlighterDisposedError)) console.error(error); -+ }); -+ if (!disableFileHeader) this.applyHeaderToDOM(this.hunksRenderer.renderHeader(workingDiff), fileContainer, workingDiff); - this.renderSeparators([]); -- this.adoptRenderData(hunksResult?.fileDiff ?? workingDiff); -+ this.adoptRenderData(workingDiff); - this.injectUnsafeCSS(); - } catch (error) { - if (disableErrorHandling) throw error; -diff --git a/dist/components/VirtualizedFileDiff.js b/dist/components/VirtualizedFileDiff.js -index 984f049ff3694dd353fa6637b0f09fc7dce74992..4d6599905be0fe7b5b5c1a225c0b3ff8d810c72a 100644 ---- a/dist/components/VirtualizedFileDiff.js -+++ b/dist/components/VirtualizedFileDiff.js -@@ -264,7 +264,8 @@ var VirtualizedFileDiff = class extends FileDiff { - const { pendingExpansions, pendingHydratedDiff, options: { collapsed = false } } = this; - if (pendingHydratedDiff != null) { - this.pendingHydratedDiff = void 0; -- if (pendingHydratedDiff.expectedDiff === fileDiff) { -+ // T3 patch: a host may re-supply an unchanged diff as a new object; it is still this diff. -+ if (areDiffTargetsEqual(pendingHydratedDiff.expectedDiff, fileDiff)) { - Object.assign(fileDiff, pendingHydratedDiff.nextDiff); - this.setHydratedState(pendingHydratedDiff.files); - this.installHydratedDiff(fileDiff); -@@ -626,7 +627,7 @@ var VirtualizedFileDiff = class extends FileDiff { - rerender() { - if (!this.enabled || this.getInputDiff() == null && this.additionFile == null && this.deletionFile == null) return; - const workingDiff = this.getWorkingDiff(); -- const nextRenderDiff = workingDiff == null ? void 0 : this.hunksRenderer.getDiffForNextRender(workingDiff); -+ const nextRenderDiff = workingDiff == null || this.options.collapsed === true ? workingDiff : this.hunksRenderer.getDiffForNextRender(workingDiff); - if (this.isSimpleMode()) this.pendingRender = void 0; - this.forceRenderOverride = true; - if (this.isSimpleMode() && this.requestRender()) return; -@@ -790,7 +791,7 @@ var VirtualizedFileDiff = class extends FileDiff { - const previousRenderedDiff = this.getRenderedDiff(); - const { pendingRender } = this; - const previousLayoutDiff = pendingRender?.diff ?? previousRenderedDiff; -- const pendingRenderDiff = this.hunksRenderer.getDiffForNextRender(workingDiff); -+ const pendingRenderDiff = this.options.collapsed === true ? workingDiff : this.hunksRenderer.getDiffForNextRender(workingDiff); - this.pendingRender = { - workingDiff, - diff: pendingRenderDiff -diff --git a/dist/editor/editor.js b/dist/editor/editor.js -index a9905d43382de2ca03d6111ba9ab3725d8cb6981..294f537622b9a62d89111d7ed4a7c07cc24dac30 100644 ---- a/dist/editor/editor.js -+++ b/dist/editor/editor.js -@@ -106,6 +106,7 @@ var Editor = class { - #deletionsColumn; - #overlayElement; - #overlayElements; -+ #fontProbeElement; - #caretElements; - #caretHighlightElements; - #primaryCaretElement; -@@ -490,7 +491,9 @@ var Editor = class { - selections: this.#selections - }); - const primarySelection = resolvedSelections.at(-1); -- if (primarySelection !== void 0) this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line); -+ // Clearing keeps the selection while search matches or markers exist; scrolling to it steals focus. -+ if (primarySelection === void 0) return; -+ this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line); - this.#scrollToPrimaryCaret(); - } - setMarkers(markers) { -@@ -595,6 +598,7 @@ var Editor = class { - this.#contentHasFocus = false; - this.#overlayElement?.remove(); - this.#overlayElement = void 0; -+ this.#fontProbeElement = void 0; - this.#resizeObserver?.disconnect(); - this.#resizeObserver = void 0; - this.#fontRemeasureScheduled = false; -@@ -1033,6 +1037,19 @@ var Editor = class { - this.#tokenizer?.applyStyle(); - this.#spriteElement = createSpriteElement(); - this.#overlayElement = h("div", { dataset: "editorOverlay" }); -+ // Resizes when the inherited code font changes, so the observer can re-measure. -+ this.#fontProbeElement = h("span", { -+ ariaHidden: "true", -+ style: { -+ position: "absolute", -+ top: "0", -+ left: "0", -+ visibility: "hidden", -+ pointerEvents: "none", -+ whiteSpace: "pre" -+ }, -+ textContent: "0" -+ }, this.#overlayElement); - const replacementFocusTargetIsContent = (event) => { - const target = event.composedPath()[0]; - return target instanceof Node && this.#contentElement?.contains(target) === true; -@@ -1409,6 +1426,7 @@ var Editor = class { - this.#resizeObserver = new ResizeObserver(this.#handleLayoutResize); - this.#resizeObserver.observe(contentEl); - this.#resizeObserver.observe(contentEl.parentElement); -+ if (this.#fontProbeElement !== void 0) this.#resizeObserver.observe(this.#fontProbeElement); - this.#computeContentOffset(contentEl); - } - #handleCustomPasteEvent = async () => { -@@ -1864,7 +1882,9 @@ var Editor = class { - const change = textDocument.applyEdits(edits, true, selections, nextSelections, true); - if (change !== void 0) this.#applyChange(change, nextSelections); - } -- #handleLayoutResize = () => { -+ #handleLayoutResize = (entries) => { -+ const contentElement = this.#contentElement; -+ const metricsChanged = contentElement !== void 0 && entries.some((entry) => entry.target === this.#fontProbeElement) && this.#metrics.init(contentElement); - const lineAnnotations = this.#lineAnnotations?.length ?? 0; - const prevGutterWidth = this.#gutterWidthCache; - const prevContentWidth = this.#contentWidthCache; -@@ -1872,12 +1892,12 @@ var Editor = class { - this.#contentWidthCache = void 0; - const gutterWidthChanged = this.#getGutterWidth() !== prevGutterWidth; - const contentWidthChanged = this.#getContentWidth() !== prevContentWidth; -- if (!gutterWidthChanged && !contentWidthChanged) return; -+ if (!gutterWidthChanged && !contentWidthChanged && !metricsChanged) return; - this.#lastAccessedCharX = void 0; - this.#metrics.clearTextWidthCache(); -- if (contentWidthChanged) { -+ if (contentWidthChanged || metricsChanged) { - this.#wrapLineOffsetsCache.clear(); -- if (this.#isWrap || lineAnnotations > 0) this.#lineYCache.clear(); -+ if (metricsChanged || this.#isWrap || lineAnnotations > 0) this.#lineYCache.clear(); - } - if (this.#selections !== void 0 || this.#matches !== void 0 || this.#markerRenderer !== void 0) { - this.#updateSelections(this.#selections ?? []); -diff --git a/dist/editor/textMeasure.d.ts b/dist/editor/textMeasure.d.ts -index 89e938d89eca77d351aeed65f3c5495383effd97..cde1d6ba89a97bbb1614754a48f8994388c186bd 100644 ---- a/dist/editor/textMeasure.d.ts -+++ b/dist/editor/textMeasure.d.ts -@@ -9,8 +9,8 @@ declare class Metrics { - lineHeight: number; - /** Padding top of the root element. */ - paddingTop: number; -- /** initialize the metrics */ -- init(root: HTMLElement): void; -+ /** (re)read the metrics from root, returning true when they changed */ -+ init(root: HTMLElement): boolean; - /** - * Re-measure the '0' character width against the font that is loaded right - * now, returning true when measurement-dependent UI should refresh. -diff --git a/dist/editor/textMeasure.js b/dist/editor/textMeasure.js -index 96d4fb137ea683b66381b1ce6e47e8708a89366c..13f2564897a5bf53a8f311300003b3ea875c73a5 100644 ---- a/dist/editor/textMeasure.js -+++ b/dist/editor/textMeasure.js -@@ -16,9 +16,10 @@ var Metrics = class { - lineHeight = 20; - /** Padding top of the root element. */ - paddingTop = 0; -- /** initialize the metrics */ -+ /** (re)read the metrics from root, returning true when they changed */ - init(root) { -- if (this.#root === root && this.#canvasCtx !== void 0 && this.ch !== -1) return; -+ const previousLineHeight = this.lineHeight; -+ let fontChanged = false; - this.#root = root; - this.#canvas ??= h("canvas", { style: { display: "none" } }); - this.#canvasCtx ??= this.#canvas.getContext("2d") ?? void 0; -@@ -37,9 +38,11 @@ var Metrics = class { - this.#setCanvasFont(font); - this.ch = this.canvasMeasureTextWidth("0"); - this.clearTextWidthCache(); -+ fontChanged = true; - } - const nextTabSize = parseInt(tabSize, 10); - if (!Number.isNaN(nextTabSize)) this.tabSize = nextTabSize; -+ return fontChanged || this.lineHeight !== previousLineHeight; - } - /** - * Re-measure the '0' character width against the font that is loaded right -diff --git a/dist/utils/createFileHeaderElement.js b/dist/utils/createFileHeaderElement.js -index 19934176d0787ae1304e771c707230d5c8837e83..298b3386eac5126a7c1ec985674e799aac0ffd42 100644 ---- a/dist/utils/createFileHeaderElement.js -+++ b/dist/utils/createFileHeaderElement.js -@@ -73,12 +73,12 @@ function createMetadataElement(fileDiff) { - } - if (deletions > 0 || additions === 0) children.push(createHastElement({ - tagName: "span", -- children: [createTextNodeElement(`-${deletions}`)], -+ children: [createTextNodeElement(`-${deletions.toLocaleString()}`)], - properties: { "data-deletions-count": "" } - })); - if (additions > 0 || deletions === 0) children.push(createHastElement({ - tagName: "span", -- children: [createTextNodeElement(`+${additions}`)], -+ children: [createTextNodeElement(`+${additions.toLocaleString()}`)], - properties: { "data-additions-count": "" } - })); - } -diff --git a/package.json b/package.json -index 0a0acc4c8734a61cf2d684162914002adf74960d..935937d68f52ed96b7af6b1dbb890dbf512eb0fe 100644 ---- a/package.json -+++ b/package.json -@@ -60,6 +60,18 @@ - "./worker/worker-portable.js": { - "types": "./dist/worker/worker-portable.d.ts", - "import": "./dist/worker/worker-portable.js" -+ }, -+ "./types": { -+ "types": "./dist/types.d.ts", -+ "import": "./dist/types.js" -+ }, -+ "./utils/getFiletypeFromFileName": { -+ "types": "./dist/utils/getFiletypeFromFileName.d.ts", -+ "import": "./dist/utils/getFiletypeFromFileName.js" -+ }, -+ "./utils/parsePatchFiles": { -+ "types": "./dist/utils/parsePatchFiles.d.ts", -+ "import": "./dist/utils/parsePatchFiles.js" - } - }, - "publishConfig": { diff --git a/patches/@pierre__diffs@1.7.0-beta.4.patch b/patches/@pierre__diffs@1.7.0-beta.4.patch new file mode 100644 index 0000000000000..adfe946dab74e --- /dev/null +++ b/patches/@pierre__diffs@1.7.0-beta.4.patch @@ -0,0 +1,142 @@ +diff --git a/dist/editor/editor.js b/dist/editor/editor.js +index b4c280390b52434f63fbe7916d81b150d9006fb4..8564980bdfe2ef8e17e8ab0a87627709091101ee 100644 +--- a/dist/editor/editor.js ++++ b/dist/editor/editor.js +@@ -95,6 +95,7 @@ var Editor = class { + #deletionsColumn; + #overlayElement; + #overlayElements; ++ #fontProbeElement; + #caretElements; + #caretHighlightElements; + #primaryCaretElement; +@@ -584,6 +585,7 @@ var Editor = class { + this.#contentHasFocus = false; + this.#overlayElement?.remove(); + this.#overlayElement = void 0; ++ this.#fontProbeElement = void 0; + this.#resizeObserver?.disconnect(); + this.#resizeObserver = void 0; + this.#fontRemeasureScheduled = false; +@@ -1022,6 +1024,19 @@ var Editor = class { + this.#tokenizer?.applyStyle(); + this.#spriteElement = createSpriteElement(); + this.#overlayElement = h("div", { dataset: "editorOverlay" }); ++ // Resizes when the inherited code font changes, so the observer can re-measure. ++ this.#fontProbeElement = h("span", { ++ ariaHidden: "true", ++ style: { ++ position: "absolute", ++ top: "0", ++ left: "0", ++ visibility: "hidden", ++ pointerEvents: "none", ++ whiteSpace: "pre" ++ }, ++ textContent: "0" ++ }, this.#overlayElement); + const replacementFocusTargetIsContent = (event) => { + const target = event.composedPath()[0]; + return target instanceof Node && this.#contentElement?.contains(target) === true; +@@ -1397,6 +1412,7 @@ var Editor = class { + this.#resizeObserver = new ResizeObserver(this.#handleLayoutResize); + this.#resizeObserver.observe(contentEl); + this.#resizeObserver.observe(contentEl.parentElement); ++ if (this.#fontProbeElement !== void 0) this.#resizeObserver.observe(this.#fontProbeElement); + this.#computeContentOffset(contentEl); + } + #handleCustomPasteEvent = async () => { +@@ -1852,7 +1868,9 @@ var Editor = class { + const change = textDocument.applyEdits(edits, true, selections, nextSelections, true); + if (change !== void 0) this.#applyChange(change, nextSelections); + } +- #handleLayoutResize = () => { ++ #handleLayoutResize = (entries) => { ++ const contentElement = this.#contentElement; ++ const metricsChanged = contentElement !== void 0 && entries.some((entry) => entry.target === this.#fontProbeElement) && this.#metrics.init(contentElement); + const lineAnnotations = this.#lineAnnotations?.length ?? 0; + const prevGutterWidth = this.#gutterWidthCache; + const prevContentWidth = this.#contentWidthCache; +@@ -1860,12 +1878,12 @@ var Editor = class { + this.#contentWidthCache = void 0; + const gutterWidthChanged = this.#getGutterWidth() !== prevGutterWidth; + const contentWidthChanged = this.#getContentWidth() !== prevContentWidth; +- if (!gutterWidthChanged && !contentWidthChanged) return; ++ if (!gutterWidthChanged && !contentWidthChanged && !metricsChanged) return; + this.#lastAccessedCharX = void 0; + this.#metrics.clearTextWidthCache(); +- if (contentWidthChanged) { ++ if (contentWidthChanged || metricsChanged) { + this.#wrapLineOffsetsCache.clear(); +- if (this.#isWrap || lineAnnotations > 0) this.#lineYCache.clear(); ++ if (metricsChanged || this.#isWrap || lineAnnotations > 0) this.#lineYCache.clear(); + } + if (this.#selections !== void 0 || this.#matches !== void 0 || this.#markerRenderer !== void 0) { + this.#updateSelections(this.#selections ?? []); +diff --git a/dist/editor/textMeasure.d.ts b/dist/editor/textMeasure.d.ts +index 89e938d89eca77d351aeed65f3c5495383effd97..cde1d6ba89a97bbb1614754a48f8994388c186bd 100644 +--- a/dist/editor/textMeasure.d.ts ++++ b/dist/editor/textMeasure.d.ts +@@ -9,8 +9,8 @@ declare class Metrics { + lineHeight: number; + /** Padding top of the root element. */ + paddingTop: number; +- /** initialize the metrics */ +- init(root: HTMLElement): void; ++ /** (re)read the metrics from root, returning true when they changed */ ++ init(root: HTMLElement): boolean; + /** + * Re-measure the '0' character width against the font that is loaded right + * now, returning true when measurement-dependent UI should refresh. +diff --git a/dist/editor/textMeasure.js b/dist/editor/textMeasure.js +index 96d4fb137ea683b66381b1ce6e47e8708a89366c..13f2564897a5bf53a8f311300003b3ea875c73a5 100644 +--- a/dist/editor/textMeasure.js ++++ b/dist/editor/textMeasure.js +@@ -16,9 +16,10 @@ var Metrics = class { + lineHeight = 20; + /** Padding top of the root element. */ + paddingTop = 0; +- /** initialize the metrics */ ++ /** (re)read the metrics from root, returning true when they changed */ + init(root) { +- if (this.#root === root && this.#canvasCtx !== void 0 && this.ch !== -1) return; ++ const previousLineHeight = this.lineHeight; ++ let fontChanged = false; + this.#root = root; + this.#canvas ??= h("canvas", { style: { display: "none" } }); + this.#canvasCtx ??= this.#canvas.getContext("2d") ?? void 0; +@@ -37,9 +38,11 @@ var Metrics = class { + this.#setCanvasFont(font); + this.ch = this.canvasMeasureTextWidth("0"); + this.clearTextWidthCache(); ++ fontChanged = true; + } + const nextTabSize = parseInt(tabSize, 10); + if (!Number.isNaN(nextTabSize)) this.tabSize = nextTabSize; ++ return fontChanged || this.lineHeight !== previousLineHeight; + } + /** + * Re-measure the '0' character width against the font that is loaded right +diff --git a/package.json b/package.json +index d1fa4d5d79ba70b6cd990b0ac9df5206fe55b58d..8762624a075174f5d9f35224cf091596d951a1a7 100644 +--- a/package.json ++++ b/package.json +@@ -60,6 +60,18 @@ + "./worker/worker-portable.js": { + "types": "./dist/worker/worker-portable.d.ts", + "import": "./dist/worker/worker-portable.js" ++ }, ++ "./types": { ++ "types": "./dist/types.d.ts", ++ "import": "./dist/types.js" ++ }, ++ "./utils/getFiletypeFromFileName": { ++ "types": "./dist/utils/getFiletypeFromFileName.d.ts", ++ "import": "./dist/utils/getFiletypeFromFileName.js" ++ }, ++ "./utils/parsePatchFiles": { ++ "types": "./dist/utils/parsePatchFiles.d.ts", ++ "import": "./dist/utils/parsePatchFiles.js" + } + }, + "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5dcfa67f5858..27c453780e8c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,7 +282,7 @@ overrides: expo-widgets: 58.0.27 '@ff-labs/fff-node': 0.11.0 '@legendapp/list': 3.7.2 - '@pierre/diffs': 1.7.0-beta.3 + '@pierre/diffs': 1.7.0-beta.4 '@react-native-ai/apple': 0.12.0 '@react-native-menu/menu': 2.0.0 '@react-navigation/native-stack': 7.21.0 @@ -369,7 +369,7 @@ patchedDependencies: '@ff-labs/fff-node@0.11.0': 3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 - '@pierre/diffs@1.7.0-beta.3': 4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8 + '@pierre/diffs@1.7.0-beta.4': dd078a7448ce6727aaaa8b8b6702fe0ff02e85f18efa7c441a768530cabbe0cc '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': 6872ba71b9847c5a0210b1b896923dadc3860faedfe28d9f062bcedfe786ef1d '@react-navigation/native-stack@7.21.0': 990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42 @@ -569,8 +569,8 @@ importers: specifier: 0.3.0 version: 0.3.0 '@pierre/diffs': - specifier: 1.7.0-beta.3 - version: 1.7.0-beta.3(patch_hash=4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.7.0-beta.4 + version: 1.7.0-beta.4(patch_hash=dd078a7448ce6727aaaa8b8b6702fe0ff02e85f18efa7c441a768530cabbe0cc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@pierre/highlights': specifier: 'catalog:' version: 0.5.0 @@ -1090,8 +1090,8 @@ importers: specifier: 'catalog:' version: 1.8.0 '@pierre/diffs': - specifier: 1.7.0-beta.3 - version: 1.7.0-beta.3(patch_hash=4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.7.0-beta.4 + version: 1.7.0-beta.4(patch_hash=dd078a7448ce6727aaaa8b8b6702fe0ff02e85f18efa7c441a768530cabbe0cc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -1506,8 +1506,8 @@ importers: packages/client-runtime: dependencies: '@pierre/diffs': - specifier: 1.7.0-beta.3 - version: 1.7.0-beta.3(patch_hash=4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.7.0-beta.4 + version: 1.7.0-beta.4(patch_hash=dd078a7448ce6727aaaa8b8b6702fe0ff02e85f18efa7c441a768530cabbe0cc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -6352,8 +6352,8 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@pierre/diffs@1.7.0-beta.3': - resolution: {integrity: sha512-eAoAoL9aoO7vR9VV5NCLIuIAEr4yu5LsBnhTbyoy8wDFjT0y7bDrlhX8doO7FUb/MCd0Ldcd15Ga/yTOq2mjBA==} + '@pierre/diffs@1.7.0-beta.4': + resolution: {integrity: sha512-QtsUN1eO1hfAie0SUBRZ8xfjzybetaOzq3xFPw7UYl/XLkx9KxQ1uRDXHat4B44S2SW8HBt63yWKCSzQ6OQmEQ==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -6366,6 +6366,9 @@ packages: '@pierre/highlights@0.5.0': resolution: {integrity: sha512-G8gscrHrik3YjKd+tPTcmivJ/W0zRrtg4adVxnfX7+415+0Qplo6qUWuEX5R0UkA1fMhmE2sOSuLBMNGDtLhIw==} + '@pierre/highlights@0.5.1': + resolution: {integrity: sha512-c2WF6sgwgKdxXU6FpWDLWUYHRca/cJy+3QJkWAqYqEAFxqlPf8eSH0ehHcFFjKBmT+9KBl9H7q6z7HMRU7+m6g==} + '@pierre/patches@0.0.1': resolution: {integrity: sha512-NycJQVQNuqmBdr7u1LsUcmkiIz1fRm9xhWZyRbm21oJYd0MVVCa45t9Lsuj5IKrTMBonxOuh4LMv+R3BpL4bmQ==} @@ -16431,9 +16434,9 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.7.0-beta.3(patch_hash=4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.7.0-beta.4(patch_hash=dd078a7448ce6727aaaa8b8b6702fe0ff02e85f18efa7c441a768530cabbe0cc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@pierre/highlights': 0.5.0 + '@pierre/highlights': 0.5.1 '@pierre/patches': 0.0.1 '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.2(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -16449,6 +16452,8 @@ snapshots: '@pierre/highlights@0.5.0': {} + '@pierre/highlights@0.5.1': {} + '@pierre/patches@0.0.1': {} '@pierre/theme@2.0.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd951c320e527..5fbf40111b3ea 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -57,7 +57,7 @@ catalog: "@legendapp/list": 3.7.2 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 - "@pierre/diffs": 1.7.0-beta.3 + "@pierre/diffs": 1.7.0-beta.4 "@pierre/highlights": 0.5.0 "@tailwindcss/node": 4.3.3 "@tailwindcss/oxide": 4.3.3 @@ -115,7 +115,7 @@ overrides: expo-widgets: 58.0.27 "@ff-labs/fff-node": 0.11.0 "@legendapp/list": 3.7.2 - "@pierre/diffs": 1.7.0-beta.3 + "@pierre/diffs": 1.7.0-beta.4 "@react-native-ai/apple": 0.12.0 "@react-native-menu/menu": 2.0.0 "@react-navigation/native-stack": 7.21.0 @@ -247,9 +247,9 @@ patchedDependencies: # scroll target a Material bottom sheet picks, so sheet content can sit below its header. "@expo/ui@58.0.27": patches/@expo__ui@58.0.27.patch "@ff-labs/fff-node@0.11.0": patches/@ff-labs__fff-node@0.11.0.patch - # Exports the dependency-light subpaths mobile and the web diff parser import, and makes find - # cover folded files and collapsed context (onRevealSearchMatch). - "@pierre/diffs@1.7.0-beta.3": patches/@pierre__diffs@1.7.0-beta.3.patch + # Exports the dependency-light subpaths mobile and the web diff parser import, and re-measures + # editor carets when the code font changes. + "@pierre/diffs@1.7.0-beta.4": patches/@pierre__diffs@1.7.0-beta.4.patch "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.21.0": patches/@react-navigation%2Fnative-stack@7.21.0.patch @@ -327,5 +327,5 @@ minimumReleaseAgeExclude: - "@effect/tsgo-win32-x64@0.67.1" - "@effect/tsgo@0.67.1" - "@anthropic-ai/claude-agent-sdk@0.3.321" - - "@pierre/diffs@1.7.0-beta.3" + - "@pierre/diffs@1.7.0-beta.4" - "@pierre/highlights@0.5.0"