authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-11 16:20:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 20:20:20-03:00
log9e8c7206c873fc2e4c042a442abce269c20b5cd8
treebd41cf53b8f0567781a6cf3ec7e8d3ba54fae938
parent55fc13ff68d2598b0613721041533dd1160bc5bd
signature Signed by PGP key B5690EEEBB952194

fix(web): terminal selection drags scroll at the window edge (#17653)


2 files changed, 29 insertions(+), 4 deletions(-)

apps/web/src/terminal/ghostty/surface.test.ts+26-1
......@@ -157,6 +157,7 @@ describe("GhosttyTerminalSurface visibility", () => {
157157 "window",
158158 Object.assign(new EventTarget(), {
159159 devicePixelRatio: 1,
160 innerHeight: 104,
160161 requestAnimationFrame: requestFrame,
161162 cancelAnimationFrame: (id: number) => frames.delete(id),
162163 setTimeout,
......@@ -214,11 +215,12 @@ describe("GhosttyTerminalSurface visibility", () => {
214215 buttons: number,
215216 modifiers: boolean | Partial<Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">> = {},
216217 button = 0,
218 clientY = 5,
217219 ) {
218220 canvas.dispatchEvent(
219221 Object.assign(new Event(type, { cancelable: true }), {
220222 clientX,
221 clientY: 5,
223 clientY,
222224 pointerId: 1,
223225 button,
224226 buttons,
......@@ -611,6 +613,29 @@ describe("GhosttyTerminalSurface visibility", () => {
611613 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
612614 });
613615
616 it("scrolls a selection drag held at the terminal edge, even when the window ends there", async () => {
617 const harness = createHarness();
618 const surface = await harness.create();
619 surface.write(Array.from({ length: 40 }, (_, index) => `line ${index}`).join("\r\n"));
620 harness.flushFrame();
621 const latest = harness.renderedSnapshot.rowData[0]?.text;
622
623 harness.pointer("pointerdown", 5, 1, {}, 0, 50);
624 harness.pointer("pointermove", 5, 1, {}, 0, 1);
625 vi.advanceTimersByTime(400);
626 harness.flushFrame();
627 expect(surface.isAtBottom()).toBe(false);
628
629 harness.pointer("pointermove", 5, 1, {}, 0, 103);
630 vi.advanceTimersByTime(2_000);
631 harness.pointer("pointerup", 5, 0, {}, 0, 103);
632 harness.flushFrame();
633 expect(surface.isAtBottom()).toBe(true);
634 expect(harness.renderedSnapshot.rowData[0]?.text).toBe(latest);
635 expect(surface.getSelection()).not.toBe("");
636 expect(surface.getSelectionPosition()?.end.y).toBe(39);
637 });
638
614639 it("pastes the terminal selection, and only that, on a Linux middle click", async () => {
615640 const harness = createHarness();
616641 const readText = vi.fn(async () => "clipboard text");
apps/web/src/terminal/ghostty/surface.ts+3-3
......@@ -1490,9 +1490,9 @@ export class GhosttyTerminalSurface {
14901490 this.clearHoveredLink();
14911491 this.selectionPointer = { x: event.clientX, y: event.clientY };
14921492 const bounds = this.canvas.getBoundingClientRect();
1493 this.setSelectionAutoscroll(
1494 event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0,
1495 );
1493 const top = Math.max(bounds.top, 0) + CONTENT_PADDING;
1494 const bottom = Math.min(bounds.bottom, window.innerHeight) - CONTENT_PADDING;
1495 this.setSelectionAutoscroll(event.clientY < top ? -1 : event.clientY > bottom ? 1 : 0);
14961496 const cell = this.cellAt(event.clientX, event.clientY);
14971497 if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return;
14981498 this.extendSelectionTo(event.clientX, event.clientY);