From 48bef1b7c0d2dde91e19aeff3cbf53e2fc7f43ec Mon Sep 17 00:00:00 2001 From: Andrew Chang Date: Tue, 4 Aug 2026 16:47:40 +0800 Subject: [PATCH] Keep task log text selection alive while scrolling Virtualized log rows are unmounted as they leave the viewport. This destroys selection boundaries and makes text selections collapse or drift during scrolling. --- .../src/pages/TaskInstance/Logs/Logs.test.tsx | 93 ++++++++++- .../TaskInstance/Logs/TaskLogContent.tsx | 23 ++- .../TaskInstance/Logs/logSelection.test.ts | 147 ++++++++++++++++++ .../pages/TaskInstance/Logs/logSelection.ts | 73 +++++++++ 4 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts create mode 100644 airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx index f7ae3fc24aa52..a1992708d9151 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx @@ -18,7 +18,7 @@ */ import "@testing-library/jest-dom"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, vi } from "vitest"; import { AppWrapper } from "src/utils/AppWrapper"; @@ -438,3 +438,94 @@ describe("Task log search", () => { await expectRenderedLineNumber(/starting attempt 1 of 3/iu, 3); }, 10_000); }); + +const findRow = (text: string) => { + const container = screen.getByTestId("virtual-scroll-container"); + + return [...container.querySelectorAll("[data-index]")].find((row) => + row.textContent.includes(text), + ) as HTMLElement; +}; + +const withFakeSelection = (selection: Selection, callback: () => T): T => { + const getSelectionSpy = vi.spyOn(document, "getSelection").mockReturnValue(selection); + const result = callback(); + + getSelectionSpy.mockRestore(); + + return result; +}; + +describe("Selection pinning across scrolling", () => { + it("keeps the selection-anchor row mounted after scrolling it out of the render window", async () => { + render( + , + ); + await waitForLogs(); + + fireEvent.click(screen.getByTestId("summary-Pre task execution logs")); + await waitFor(() => expect(screen.getByText(/starting attempt 1 of 3/iu)).toBeInTheDocument()); + + const anchorRow = findRow("Starting attempt 1 of 3"); + const anchorIndex = Number(anchorRow.getAttribute("data-index")); + const neighborIndex = anchorIndex + 1; + const textNode = anchorRow.querySelector("span")?.firstChild as Node; + const range = document.createRange(); + + range.setStart(textNode, 0); + range.setEnd(textNode, 0); + + const selection = { getRangeAt: () => range, isCollapsed: true, rangeCount: 1 } as unknown as Selection; + + withFakeSelection(selection, () => { + document.dispatchEvent(new Event("selectionchange")); + }); + + const container = screen.getByTestId("virtual-scroll-container"); + + fireEvent.scroll(container, { target: { scrollTop: ITEM_HEIGHT * (anchorIndex + 15) } }); + + await waitFor(() => { + expect(container.querySelector(`[data-index="${neighborIndex}"]`)).toBeNull(); + }); + expect(container.querySelector(`[data-index="${anchorIndex}"]`)).not.toBeNull(); + }); + + it("unpins once the selection is cleared", async () => { + render( + , + ); + await waitForLogs(); + + fireEvent.click(screen.getByTestId("summary-Pre task execution logs")); + await waitFor(() => expect(screen.getByText(/starting attempt 1 of 3/iu)).toBeInTheDocument()); + + const anchorRow = findRow("Starting attempt 1 of 3"); + const anchorIndex = Number(anchorRow.getAttribute("data-index")); + const textNode = anchorRow.querySelector("span")?.firstChild as Node; + const range = document.createRange(); + + range.setStart(textNode, 0); + range.setEnd(textNode, 0); + + const selection = { getRangeAt: () => range, isCollapsed: true, rangeCount: 1 } as unknown as Selection; + + withFakeSelection(selection, () => { + document.dispatchEvent(new Event("selectionchange")); + }); + + const noSelection = null as unknown as Selection; + + withFakeSelection(noSelection, () => { + document.dispatchEvent(new Event("selectionchange")); + }); + + const container = screen.getByTestId("virtual-scroll-container"); + + fireEvent.scroll(container, { target: { scrollTop: ITEM_HEIGHT * (anchorIndex + 15) } }); + + await waitFor(() => { + expect(container.querySelector(`[data-index="${anchorIndex}"]`)).toBeNull(); + }); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx index e2f4986c7e5a7..b720c081ed5ee 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx @@ -17,7 +17,8 @@ * under the License. */ import { Box, Code, VStack } from "@chakra-ui/react"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"; +import type { Range as VirtualizerRange } from "@tanstack/react-virtual"; import { useLayoutEffect, useRef, useCallback, useEffect } from "react"; import { ErrorAlert } from "src/components/ErrorAlert"; @@ -28,6 +29,7 @@ import type { ParsedLogEntry } from "src/queries/useLogs"; import { HighlightedText } from "./HighlightedText"; import { ScrollToButton } from "./ScrollToButton"; +import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; import { useLogGroups } from "./useLogGroups"; import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } from "./utils"; @@ -75,12 +77,17 @@ export const TaskLogContent = ({ const isAtBottomRef = useRef(true); const prevVisibleCountRef = useRef(0); + const pinnedRowsRef = useRef>([]); + + const rangeExtractor = (range: VirtualizerRange) => + mergePinnedIndexes(defaultRangeExtractor(range), pinnedRowsRef.current, range.count); const rowVirtualizer = useVirtualizer({ count: visibleItems.length, estimateSize: () => 20, getScrollElement: () => parentRef.current, overscan: 10, + rangeExtractor, }); const contentHeight = rowVirtualizer.getTotalSize(); @@ -104,6 +111,20 @@ export const TaskLogContent = ({ return () => el?.removeEventListener("scroll", handleScroll); }, [handleScroll]); + useEffect(() => { + const container = parentRef.current; + const handleSelectionChange = () => { + if (!container) { + return; + } + pinnedRowsRef.current = getSelectionPinnedRows(document.getSelection(), container); + }; + + document.addEventListener("selectionchange", handleSelectionChange); + + return () => document.removeEventListener("selectionchange", handleSelectionChange); + }, []); + useLayoutEffect(() => { if (visibleItems.length === 0) { return; diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts new file mode 100644 index 0000000000000..b359f1d1bd205 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.test.ts @@ -0,0 +1,147 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { getSelectionPinnedRows, mergePinnedIndexes } from "./logSelection"; + +const buildLogContainer = (rows: Array<{ index: number; text: string }>): HTMLElement => { + const container = document.createElement("div"); + + rows.forEach(({ index, text }) => { + const row = document.createElement("div"); + + row.setAttribute("data-index", String(index)); + row.textContent = text; + container.append(row); + }); + document.body.append(container); + + return container; +}; + +const getRowTextNode = (container: HTMLElement, index: number): Node => + container.querySelector(`[data-index="${index}"]`)?.firstChild as Node; + +const makeSelection = (range: Range): Selection => + ({ + getRangeAt: () => range, + isCollapsed: range.collapsed, + rangeCount: 1, + }) as unknown as Selection; + +type SelectBetweenOptions = { + end: Node; + endOffset: number; + start: Node; + startOffset: number; +}; + +const selectBetween = ({ end, endOffset, start, startOffset }: SelectBetweenOptions): Selection => { + const range = document.createRange(); + + range.setStart(start, startOffset); + range.setEnd(end, endOffset); + + return makeSelection(range); +}; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("getSelectionPinnedRows", () => { + it("pins both rows when both boundaries are inside log rows", () => { + const container = buildLogContainer([ + { index: 2, text: "line 2" }, + { index: 7, text: "line 7" }, + ]); + const selection = selectBetween({ + end: getRowTextNode(container, 7), + endOffset: 3, + start: getRowTextNode(container, 2), + startOffset: 1, + }); + + expect(getSelectionPinnedRows(selection, container)).toEqual([2, 7]); + }); + + it("keeps the anchor row pinned when the drag focus leaves the rows", () => { + const toolbar = document.createElement("div"); + + toolbar.textContent = "search toolbar"; + document.body.prepend(toolbar); + + const container = buildLogContainer([{ index: 100, text: "anchor line" }]); + + const selection = selectBetween({ + end: getRowTextNode(container, 100), + endOffset: 5, + start: toolbar.firstChild as Node, + startOffset: 0, + }); + + expect(getSelectionPinnedRows(selection, container)).toEqual([100]); + }); + + it("pins only the mapped row when one boundary sits on the container padding", () => { + const container = buildLogContainer([ + { index: 0, text: "line 0" }, + { index: 5, text: "line 5" }, + ]); + const range = document.createRange(); + + range.setStart(container, 0); + range.setEnd(getRowTextNode(container, 5), 3); + + expect(getSelectionPinnedRows(makeSelection(range), container)).toEqual([5]); + }); + + it("pins the caret row for a collapsed selection so shift-click extension survives scrolling", () => { + const container = buildLogContainer([{ index: 3, text: "caret line" }]); + const node = getRowTextNode(container, 3); + + expect( + getSelectionPinnedRows( + selectBetween({ end: node, endOffset: 2, start: node, startOffset: 2 }), + container, + ), + ).toEqual([3, 3]); + }); + + it("returns no pins for a null selection", () => { + const container = buildLogContainer([{ index: 0, text: "line 0" }]); + + expect(getSelectionPinnedRows(null, container)).toEqual([]); + }); +}); + +describe("mergePinnedIndexes", () => { + it("returns the default range untouched when there is nothing to pin", () => { + expect(mergePinnedIndexes([5, 6, 7], [], 10)).toEqual([5, 6, 7]); + }); + + it("merges pinned indexes into the range, sorted and deduplicated", () => { + expect(mergePinnedIndexes([5, 6, 7], [12, 2, 6], 20)).toEqual([2, 5, 6, 7, 12]); + }); + + it("drops pinned indexes outside [0, count)", () => { + expect(mergePinnedIndexes([5, 6], [-1, 99], 10)).toEqual([5, 6]); + expect(mergePinnedIndexes([5, 6], [-1, 2, 99], 10)).toEqual([2, 5, 6]); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts new file mode 100644 index 0000000000000..6b0c6afc689cb --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/logSelection.ts @@ -0,0 +1,73 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Map a DOM node inside the virtualized log list to the `data-index` of the + * row containing it. + */ +export const getRowIndexForNode = (node: Node | null, container: HTMLElement): number | undefined => { + const element = node instanceof Element ? node : node?.parentElement; + const row = element?.closest("[data-index]"); + + if (!row || !container.contains(row)) { + return undefined; + } + const index = Number(row.getAttribute("data-index")); + + return Number.isInteger(index) ? index : undefined; +}; + +/** + * Row indexes to pin so the virtualizer keeps selection-boundary rows + * mounted. Boundaries map independently (the drag focus may sit off the + * rows) and a collapsed caret pins too, for shift-click extension. + */ +export const getSelectionPinnedRows = ( + selection: Selection | null, + container: HTMLElement, +): Array => { + if (!selection || selection.rangeCount === 0) { + return []; + } + const range = selection.getRangeAt(0); + + return [ + getRowIndexForNode(range.startContainer, container), + getRowIndexForNode(range.endContainer, container), + ].filter((index): index is number => index !== undefined); +}; + +/** + * Merge selection-pinned row indexes into the virtualizer's default render + * range. Rows holding selection boundaries must stay mounted while the user + * scrolls — unmounting a boundary node collapses the browser selection. + */ +export const mergePinnedIndexes = ( + defaultIndexes: Array, + pinnedIndexes: Array, + count: number, +): Array => { + const validPins = pinnedIndexes.filter((index) => index >= 0 && index < count); + + if (validPins.length === 0) { + return defaultIndexes; + } + + return [...new Set([...validPins, ...defaultIndexes])].sort((first, second) => first - second); +};