mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df67f21403 | ||
|
|
1c35da2bdb | ||
|
|
a1cd62fbc9 | ||
|
|
78669b1f5b |
@@ -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";
|
||||
@@ -487,3 +488,175 @@ 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<void>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Diagnostic-only: application modes emitted before the first attachment.
|
||||
// The existing live-mode tests above are the control for this failure.
|
||||
test.describe("mouse mode enabled before terminal attachment", () => {
|
||||
test.use({ isMobile: true, hasTouch: true, viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("preserves SGR wheel reports when opening a running terminal", async ({ page }) => {
|
||||
await openPreAttachedMouseCapture(page);
|
||||
await expectFingerNavigationInput(page, "mouse");
|
||||
});
|
||||
});
|
||||
|
||||
async function openPreAttachedMouseCapture(page: Page) {
|
||||
await harness.createTerminal({
|
||||
name: "Mouse mode before attachment",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.on("data", chunk => {
|
||||
process.stdout.write("\\r\\nTOUCH_BYTES:" + chunk.toString("hex") + ":END\\r\\n");
|
||||
});
|
||||
process.stdout.write("\\x1b[?1000h\\x1b[?1006hTOUCH_INPUT_READY\\r\\n");
|
||||
`,
|
||||
],
|
||||
});
|
||||
await openPhoneTerminal(page);
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("TOUCH_INPUT_READY");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__paseoTerminal!.modes.mouseTrackingMode))
|
||||
.toBe("vt200");
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>(".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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user