diff --git a/changelog.d/split-drop-on-redrawn-node.md b/changelog.d/split-drop-on-redrawn-node.md new file mode 100644 index 0000000000..6975ab0801 --- /dev/null +++ b/changelog.d/split-drop-on-redrawn-node.md @@ -0,0 +1,3 @@ +### Fixed +- A pane dragged from the sidebar onto the pane area opens in a half even when the terminal or + chat under the pointer redraws just before the drop; Chrome refused that drop before. diff --git a/scripts/split-view-regression.ts b/scripts/split-view-regression.ts index 54dc8ad5ad..c17be6e0e0 100644 --- a/scripts/split-view-regression.ts +++ b/scripts/split-view-regression.ts @@ -56,7 +56,7 @@ export async function checkSplitView(browser: Browser, origin: string): Promise< const half = (page: Page, side: string) => page.locator(`.pane-slot[data-side="${side}"]`); const isActive = (page: Page, side: string) => page.waitForFunction((which) => document.querySelector(`.pane-slot[data-side="${which}"]`)?.classList.contains("is-active") === true, side); /** the pane dropped on the right half, which is then the active one */ - const splitRight = async (page: Page, pane: string): Promise => { + const splitRight = async (page: Page, pane: string, options: { newTargetAtDrop?: boolean } = {}): Promise => { const area = page.locator(".pane-split"); const source = page.locator(`.pane-select[title^="${pane} —"]`); await source.waitFor({ state: "visible" }); @@ -77,6 +77,16 @@ export async function checkSplitView(browser: Browser, origin: string): Promise< const guide = document.querySelector(".split-drop"); return guide?.classList.contains("is-visible") === true && guide.getAttribute("data-side") === "right"; }); + if (options.newTargetAtDrop) { + // The node under the pointer is replaced between the last dragover and the drop (xterm redraws + // a row, a transcript renders again): the drop's own hit test then meets a new node and sends + // it a dragenter, and Chromium refuses a drop whose last drag event nobody accepted. The cover + // is the test's own node, so React never sees the swap. + await page.evaluate(() => { + const cover = document.querySelector("#split-drop-cover")!; + cover.replaceWith(cover.cloneNode()); + }); + } await page.mouse.up(); await page.waitForFunction(() => document.querySelectorAll(".pane-slot").length === 2); await isActive(page, "right"); @@ -522,6 +532,24 @@ export async function checkSplitView(browser: Browser, origin: string): Promise< console.log("PASS an inactive-half lens choice works and survives activation when storage writes fail"); } + // 9b. A drop just after the node under the pointer was replaced still opens the half. + { + const [left, right] = await panesFor("new-target-left", "new-target-right") as [string, string]; + const { page, errors } = await open([left, right], "terminal"); + await page.locator(".pane-split").evaluate((area) => { + const cover = document.createElement("div"); + cover.id = "split-drop-cover"; + const box = area.getBoundingClientRect(); + cover.style.cssText = `position:fixed;left:${box.left}px;top:${box.top}px;width:${box.width}px;height:${box.height}px;z-index:10`; + area.append(cover); + }); + await splitRight(page, right, { newTargetAtDrop: true }); + await page.locator("#split-drop-cover").evaluate((cover) => cover.remove()); + await half(page, "right").locator(".xterm").waitFor(); + assert.deepEqual(errors, []); + console.log("PASS a drop on a node that replaced the hovered one still opens the half"); + } + // 10. The phone's glass header follows the nested pane DOM, but never crosses a notice or tab row. { const [pane] = await panesFor("mobile-glass") as [string]; diff --git a/src/App.tsx b/src/App.tsx index 358fa761c5..e8925bcde7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1014,6 +1014,9 @@ export function App() { const inside = !!box && !!point && point.x >= box.left && point.x < box.right && point.y >= box.top && point.y < box.bottom; showDropTarget(box && point && inside ? dropSide(point.x, box.left, box.width, dropAtRef.current) : null); }, [tabDrag, splitDragOn, showDropTarget]); + // dragenter too: a drop hit-tests once more, and when the node under the pointer was replaced since + // the last dragover (xterm redraws its rows, a transcript renders again) the new node gets a + // dragenter instead. Chromium refuses a drop whose last drag event nobody accepted. const paneDragOver = (event: DragEvent): void => { if (!splitDragOn || !event.dataTransfer.types.includes(PANE_DRAG_TYPE)) return; event.preventDefault(); @@ -1506,7 +1509,7 @@ export function App() { {health?.lan_exposure && { setSettingsLinkPage("devices"); setSettingsOpen(true); }} />} {/* the pane's tab, or two tabs side by side (lib/split.ts): a tab dragged down out of its strip, or a sidebar row dragged over this area, opens its pane in the half it is dropped on */} -
+
{(["a", "b"] as const).map((slot) => { // one pane: the solo slot, or a split's active half where the window is too narrow for both if (!splitOn && slot !== (split ? split.active : soloSlot)) return null;