Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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]);
});
});
Original file line number Diff line number Diff line change
@@ -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);
};
Loading