This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 448c1511ad2 Keep task log text selection alive while scrolling (#71148)
448c1511ad2 is described below

commit 448c1511ad21657ee364e6276d52d249b70c54af
Author: Andrew Chang <[email protected]>
AuthorDate: Thu Aug 6 02:57:45 2026 +0800

    Keep task log text selection alive while scrolling (#71148)
    
    Virtualized log rows are unmounted as they leave the viewport. This 
destroys selection boundaries and makes text selections collapse or drift 
during scrolling.
---
 .../ui/src/pages/TaskInstance/Logs/Logs.test.tsx   |  93 ++++++++++++-
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx |  23 +++-
 .../pages/TaskInstance/Logs/logSelection.test.ts   | 147 +++++++++++++++++++++
 .../ui/src/pages/TaskInstance/Logs/logSelection.ts |  73 ++++++++++
 4 files changed, 334 insertions(+), 2 deletions(-)

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 f7ae3fc24aa..a1992708d91 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 = <T,>(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(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
 />,
+    );
+    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(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
 />,
+    );
+    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 e2f4986c7e5..b720c081ed5 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<boolean>(true);
   const prevVisibleCountRef = useRef<number>(0);
+  const pinnedRowsRef = useRef<Array<number>>([]);
+
+  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 00000000000..b359f1d1bd2
--- /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 00000000000..6b0c6afc689
--- /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<number> => {
+  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<number>,
+  pinnedIndexes: Array<number>,
+  count: number,
+): Array<number> => {
+  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);
+};

Reply via email to