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", () => {...@@ -157,6 +157,7 @@ describe("GhosttyTerminalSurface visibility", () => {
157 "window",157 "window",
158 Object.assign(new EventTarget(), {158 Object.assign(new EventTarget(), {
159 devicePixelRatio: 1,159 devicePixelRatio: 1,
160 innerHeight: 104,
160 requestAnimationFrame: requestFrame,161 requestAnimationFrame: requestFrame,
161 cancelAnimationFrame: (id: number) => frames.delete(id),162 cancelAnimationFrame: (id: number) => frames.delete(id),
162 setTimeout,163 setTimeout,
...@@ -214,11 +215,12 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -214,11 +215,12 @@ describe("GhosttyTerminalSurface visibility", () => {
214 buttons: number,215 buttons: number,
215 modifiers: boolean | Partial<Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">> = {},216 modifiers: boolean | Partial<Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">> = {},
216 button = 0,217 button = 0,
218 clientY = 5,
217 ) {219 ) {
218 canvas.dispatchEvent(220 canvas.dispatchEvent(
219 Object.assign(new Event(type, { cancelable: true }), {221 Object.assign(new Event(type, { cancelable: true }), {
220 clientX,222 clientX,
221 clientY: 5,223 clientY,
222 pointerId: 1,224 pointerId: 1,
223 button,225 button,
224 buttons,226 buttons,
...@@ -611,6 +613,29 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -611,6 +613,29 @@ describe("GhosttyTerminalSurface visibility", () => {
611 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);613 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
612 });614 });
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
614 it("pastes the terminal selection, and only that, on a Linux middle click", async () => {639 it("pastes the terminal selection, and only that, on a Linux middle click", async () => {
615 const harness = createHarness();640 const harness = createHarness();
616 const readText = vi.fn(async () => "clipboard text");641 const readText = vi.fn(async () => "clipboard text");
apps/web/src/terminal/ghostty/surface.ts+3-3
...@@ -1490,9 +1490,9 @@ export class GhosttyTerminalSurface {...@@ -1490,9 +1490,9 @@ export class GhosttyTerminalSurface {
1490 this.clearHoveredLink();1490 this.clearHoveredLink();
1491 this.selectionPointer = { x: event.clientX, y: event.clientY };1491 this.selectionPointer = { x: event.clientX, y: event.clientY };
1492 const bounds = this.canvas.getBoundingClientRect();1492 const bounds = this.canvas.getBoundingClientRect();
1493 this.setSelectionAutoscroll(1493 const top = Math.max(bounds.top, 0) + CONTENT_PADDING;
1494 event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0,1494 const bottom = Math.min(bounds.bottom, window.innerHeight) - CONTENT_PADDING;
1495 );1495 this.setSelectionAutoscroll(event.clientY < top ? -1 : event.clientY > bottom ? 1 : 0);
1496 const cell = this.cellAt(event.clientX, event.clientY);1496 const cell = this.cellAt(event.clientX, event.clientY);
1497 if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return;1497 if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return;
1498 this.extendSelectionTo(event.clientX, event.clientY);1498 this.extendSelectionTo(event.clientX, event.clientY);