Skip to content
Merged
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
3 changes: 3 additions & 0 deletions changelog.d/split-drop-on-redrawn-node.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 29 additions & 1 deletion scripts/split-view-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
const splitRight = async (page: Page, pane: string, options: { newTargetAtDrop?: boolean } = {}): Promise<void> => {
const area = page.locator(".pane-split");
const source = page.locator(`.pane-select[title^="${pane} —"]`);
await source.waitFor({ state: "visible" });
Expand All @@ -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");
Expand Down Expand Up @@ -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];
Expand Down
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>): void => {
if (!splitDragOn || !event.dataTransfer.types.includes(PANE_DRAG_TYPE)) return;
event.preventDefault();
Expand Down Expand Up @@ -1506,7 +1509,7 @@ export function App() {
{health?.lan_exposure && <LanExposureNotice host={health.lan_exposure.host} onOpen={() => { 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 */}
<div ref={paneAreaRef} className={`pane-split${splitOn ? " is-split" : ""}`} onDragOverCapture={paneDragOver} onDragLeave={paneDragLeave} onDropCapture={paneDrop}>
<div ref={paneAreaRef} className={`pane-split${splitOn ? " is-split" : ""}`} onDragEnterCapture={paneDragOver} onDragOverCapture={paneDragOver} onDragLeave={paneDragLeave} onDropCapture={paneDrop}>
{(["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;
Expand Down
Loading