diff --git a/packages/app/e2e/browser/terminal-find.spec.ts b/packages/app/e2e/browser/terminal-find.spec.ts index e4dde5cfaa..26a5b52063 100644 --- a/packages/app/e2e/browser/terminal-find.spec.ts +++ b/packages/app/e2e/browser/terminal-find.spec.ts @@ -1,3 +1,4 @@ +import { getServerId } from "../support/helpers/server-id"; import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions"; import { spawnSync } from "node:child_process"; import { readFile, writeFile } from "node:fs/promises"; @@ -489,3 +490,142 @@ test("keeps the shared Find controls usable in a compact dark browser", async ({ await page.getByRole("button", { name: "Close Find" }).click(); await expect(input(page)).toBeFocused(); }); + +// A phone browser installs xterm's document-level touch gesture listener too. +// Keep this journey touch-enabled so it catches duplicate scroll handling. +test.describe("terminal history with a finger", () => { + test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } }); + + test("scrolls history once per finger drag", async ({ page }) => { + await openPhoneTerminalHistory(page); + await expectSingleFingerScroll(page, 24); + await expectSingleFingerScroll(page, 1); + await expectMouseWheelScrollsHistory(page); + }); + + for (const mode of ["alternate", "mouse"] as const) { + test(`preserves finger navigation in ${mode} input mode`, async ({ page }) => { + await openPhoneInputCapture(page, mode); + await expectFingerNavigationInput(page, mode); + }); + } +}); + +async function expectFingerNavigationInput(page: Page, mode: "alternate" | "mouse") { + await dragTerminalWithFinger(page, 24, async () => { + const prefix = mode === "alternate" ? "TOUCH_BYTES:1b4f41" : "TOUCH_BYTES:1b5b3c3634"; + await expect.poll(() => getTerminalBufferText(page)).toContain(prefix); + }); +} + +async function openPhoneTerminalHistory(page: Page) { + await harness.createTerminal({ + name: "Finger scroll history", + command: "bash", + args: [ + "--noprofile", + "--norc", + "-c", + "for i in $(seq 1 150); do echo HISTORY_$i; done; exec sleep 300", + ], + }); + await openPhoneTerminal(page); + await expect.poll(() => getTerminalBufferText(page)).toContain("HISTORY_150"); +} + +async function openPhoneInputCapture(page: Page, mode: "alternate" | "mouse") { + const escapes = mode === "alternate" ? "\x1b[?1049h\x1b[?1h" : "\x1b[?1000h\x1b[?1006h"; + await harness.createTerminal({ + name: "Touch application input", + command: process.execPath, + args: [ + "-e", + ` + process.stdin.setRawMode(true); + process.stdin.once("data", () => { + process.stdin.on("data", chunk => { + process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n"); + }); + process.stdout.write(${JSON.stringify(escapes)} + "TOUCH_INPUT_READY\\r\\n"); + }); + process.stdout.write("TOUCH_START_READY\\r\\n"); + `, + ], + }); + await openPhoneTerminal(page); + await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_START_READY"); + await input(page).press("x"); + await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY"); + await expect + .poll(() => page.evaluate(() => window.__paseoTerminal!.buffer.active.type)) + .toBe(mode === "alternate" ? "alternate" : "normal"); + await expect + .poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode)) + .toBe(mode === "mouse" ? "vt200" : "none"); +} + +async function openPhoneTerminal(page: Page) { + await page.goto("/"); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${harness.workspaceId}`).click(); + await expect(page.getByRole("textbox", { name: "Terminal input" })).toBeVisible(); + await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toBeVisible(); +} + +async function expectMouseWheelScrollsHistory(page: Page) { + const beforeWheel = (await viewport(page))!; + await page.mouse.move(200, 300); + await page.mouse.wheel(0, -120); + await expect.poll(() => viewport(page)).toBeLessThan(beforeWheel); +} + +async function expectSingleFingerScroll(page: Page, moves: number) { + await dragTerminalWithFinger(page, moves, async ({ lineHeight, before }) => { + await expect.poll(async () => before - (await viewport(page))!).toBeGreaterThan(0); + const moved = before - (await viewport(page))!; + await test.info().attach("finger-scroll", { + body: JSON.stringify({ moves, distancePx: 120, lineHeight, rowsMoved: moved }), + contentType: "application/json", + }); + expect(moved, "one gesture must not scroll twice its physical distance").toBeLessThanOrEqual( + Math.ceil(120 / lineHeight), + ); + }); +} + +async function dragTerminalWithFinger( + page: Page, + moves: number, + inspect: (state: { lineHeight: number; before: number }) => Promise, +) { + await test.step(`Drag history with ${moves} touch moves`, async () => { + const screen = page + .getByTestId("terminal-surface") + .filter({ visible: true }) + .locator(".xterm-screen"); + const box = await screen.boundingBox(); + if (!box) throw new Error("Terminal screen is not visible"); + const rows = await page.evaluate(() => window.__paseoTerminal!.rows); + const lineHeight = box.height / rows; + const before = (await viewport(page))!; + const session = await page.context().newCDPSession(page); + try { + const start = { x: box.x + box.width / 2, y: box.y + box.height / 3 }; + await session.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ ...start, id: 1 }], + }); + for (let step = 1; step <= moves; step++) { + await session.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: start.x, y: start.y + (120 * step) / moves, id: 1 }], + }); + } + // Check before lifting the finger to exclude inertial scrolling. + await inspect({ lineHeight, before }); + } finally { + await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await session.detach(); + } + }); +} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index b1cab84d0e..ae74a4945f 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -123,7 +123,6 @@ interface TerminalEmulatorRuntimeDisposables { removeVisualViewportResize: () => void; clearFitTimeouts: () => void; removeFontListeners: () => void; - removeTouchListeners: () => void; removeFilePasteListener: () => void; restoreDocumentStyles: () => void; restoreViewportStyles: () => void; @@ -155,7 +154,6 @@ const isAppleHandheld = maxTouchPoints: navigator.maxTouchPoints, }); -const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0); @@ -764,11 +762,6 @@ export class TerminalEmulatorRuntime { }; input.host.addEventListener("paste", filePasteHandler, true); - const removeTouchListeners = this.setupTouchScrollHandlers({ - root: input.root, - host: input.host, - terminal, - }); const resizeObserver = new ResizeObserver(() => { fitAndEmitResize({ shouldClaim: false }); }); @@ -848,7 +841,6 @@ export class TerminalEmulatorRuntime { removeFontListeners: () => { fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); }, - removeTouchListeners, removeFilePasteListener: () => { input.host.removeEventListener("paste", filePasteHandler, true); }, @@ -880,7 +872,6 @@ export class TerminalEmulatorRuntime { disposables.removeVisualViewportResize(); disposables.clearFitTimeouts(); disposables.removeFontListeners(); - disposables.removeTouchListeners(); disposables.removeFilePasteListener(); disposables.disposeFitAddon(); disposables.disposeWebglAddon(); @@ -1420,120 +1411,4 @@ export class TerminalEmulatorRuntime { } }; } - - private setupTouchScrollHandlers(input: { - root: HTMLDivElement; - host: HTMLDivElement; - terminal: Terminal; - }): () => void { - let touchScrollRemainderPx = 0; - const measuredLineHeight = - input.host.querySelector(".xterm-rows > div")?.getBoundingClientRect().height ?? - 0; - const touchScrollLineHeightPx = - measuredLineHeight > 0 ? measuredLineHeight : DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX; - - const activeTouch = { - identifier: -1, - startX: 0, - startY: 0, - lastX: 0, - lastY: 0, - mode: null as "vertical" | "horizontal" | null, - }; - - const touchStartHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - return; - } - - const touch = event.touches[0]; - if (!touch) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - return; - } - - activeTouch.identifier = touch.identifier; - activeTouch.startX = touch.clientX; - activeTouch.startY = touch.clientY; - activeTouch.lastX = touch.clientX; - activeTouch.lastY = touch.clientY; - activeTouch.mode = null; - touchScrollRemainderPx = 0; - }; - - const touchMoveHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - return; - } - - const touch = Array.from(event.touches).find( - (candidate) => candidate.identifier === activeTouch.identifier, - ); - if (!touch) { - return; - } - - const totalDeltaX = touch.clientX - activeTouch.startX; - const totalDeltaY = touch.clientY - activeTouch.startY; - if (activeTouch.mode === null) { - const absX = Math.abs(totalDeltaX); - const absY = Math.abs(totalDeltaY); - if (absX > 8 || absY > 8) { - activeTouch.mode = absY >= absX ? "vertical" : "horizontal"; - } - } - - const deltaY = touch.clientY - activeTouch.lastY; - activeTouch.lastX = touch.clientX; - activeTouch.lastY = touch.clientY; - - if (activeTouch.mode !== "vertical") { - return; - } - - touchScrollRemainderPx += deltaY; - const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx); - if (lineDelta !== 0) { - input.terminal.scrollLines(-lineDelta); - touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; - } - - event.preventDefault(); - }; - - const touchEndHandler = (event: TouchEvent) => { - const activeTouchEnded = Array.from(event.changedTouches).some( - (touch) => touch.identifier === activeTouch.identifier, - ); - if (activeTouchEnded || event.touches.length === 0) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - } - }; - - const touchCancelHandler = () => { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - }; - - input.root.addEventListener("touchstart", touchStartHandler, { passive: true }); - input.root.addEventListener("touchmove", touchMoveHandler, { passive: false }); - input.root.addEventListener("touchend", touchEndHandler, { passive: true }); - input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true }); - - return () => { - input.root.removeEventListener("touchstart", touchStartHandler); - input.root.removeEventListener("touchmove", touchMoveHandler); - input.root.removeEventListener("touchend", touchEndHandler); - input.root.removeEventListener("touchcancel", touchCancelHandler); - }; - } }