Skip to content

Commit 88bb377

Browse files
committed
fix(web): converge mobile line-copy hit testing on buffer cells
1 parent 1260330 commit 88bb377

4 files changed

Lines changed: 298 additions & 49 deletions

File tree

e2e/specs/settings/mobile-copy-on-select.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { openSettingsSection } from "../../fixtures/phase2-i18n";
44

55
const MOBILE_VIEWPORT = { width: 430, height: 932 };
66
const LONG_LINE_TEXT = "MOBILE_COPY_MODE_LONG_LINE_0123456789_".repeat(12);
7+
const SHORT_LINE_TEXT = "MOBILE_COPY_MODE_SHORT";
78

89
function directoryRow(page: Page, name: string): Locator {
910
return page
@@ -163,6 +164,20 @@ async function seedLongTerminalLine(page: Page): Promise<void> {
163164
);
164165
}
165166

167+
async function seedShortTerminalLine(page: Page): Promise<void> {
168+
const terminalInput = page.locator(".mobile-sheet--terminal .xterm textarea").first();
169+
await expect(terminalInput).toBeVisible({ timeout: 10000 });
170+
await terminalInput.click();
171+
await page.keyboard.type(`printf '${SHORT_LINE_TEXT}\\n'`);
172+
await page.keyboard.press("Enter");
173+
await expect(page.locator(".mobile-sheet--terminal .xterm-rows").first()).toContainText(
174+
SHORT_LINE_TEXT,
175+
{
176+
timeout: 10000,
177+
}
178+
);
179+
}
180+
166181
async function longPressTerminalRows(page: Page, rowIndex = 1): Promise<void> {
167182
const rows = page.locator(".mobile-sheet--terminal .xterm-rows").first();
168183
await expect(rows).toBeVisible({ timeout: 10000 });
@@ -214,6 +229,57 @@ async function findPrintedLineRowIndex(page: Page): Promise<number> {
214229
return rowTexts.findIndex((text) => text.startsWith("MOBILE_COPY_MODE_LONG_LINE"));
215230
}
216231

232+
async function findShortPrintedLineRowIndex(page: Page): Promise<number> {
233+
const rowTexts = await page
234+
.locator(".mobile-sheet--terminal .xterm-rows > div")
235+
.allTextContents();
236+
return rowTexts.findIndex((text) => text.startsWith(SHORT_LINE_TEXT));
237+
}
238+
239+
async function longPressTerminalRowBlankRightSide(page: Page, rowIndex: number): Promise<void> {
240+
const rows = page.locator(".mobile-sheet--terminal .xterm-rows").first();
241+
await expect(rows).toBeVisible({ timeout: 10000 });
242+
243+
const targetRow = rows.locator(":scope > div").nth(rowIndex);
244+
await expect(targetRow).toBeVisible({ timeout: 10000 });
245+
246+
const box = await targetRow.boundingBox();
247+
expect(box).toBeTruthy();
248+
if (!box) {
249+
throw new Error("xterm row bounding box missing");
250+
}
251+
252+
const x = box.x + Math.max(box.width - 12, box.width * 0.85);
253+
const y = box.y + Math.min(16, Math.max(8, box.height / 2));
254+
255+
await targetRow.evaluate(
256+
(node, { clientX, clientY }) => {
257+
if (!(node instanceof HTMLElement)) {
258+
throw new Error("xterm row missing");
259+
}
260+
261+
const touches = [{ identifier: 1, clientX, clientY, target: node }];
262+
const buildEvent = (
263+
type: string,
264+
activeTouches: typeof touches,
265+
changedTouches = activeTouches
266+
) => {
267+
const event = new Event(type, { bubbles: true, cancelable: true });
268+
Object.defineProperty(event, "touches", { value: activeTouches });
269+
Object.defineProperty(event, "targetTouches", { value: activeTouches });
270+
Object.defineProperty(event, "changedTouches", { value: changedTouches });
271+
return event;
272+
};
273+
274+
node.dispatchEvent(buildEvent("touchstart", touches));
275+
window.setTimeout(() => {
276+
node.dispatchEvent(buildEvent("touchend", [], touches));
277+
}, 650);
278+
},
279+
{ clientX: x, clientY: y }
280+
);
281+
}
282+
217283
async function setMobileCopyOnSelect(page: Page, enabled: boolean): Promise<void> {
218284
await page.goto("/settings");
219285
await expect(page.locator(".settings-page")).toBeVisible({ timeout: 15000 });
@@ -331,4 +397,33 @@ test.describe("mobile copy on select", () => {
331397
})
332398
.toBe("");
333399
});
400+
401+
test("mobile long press does not copy when pressing the blank area to the right of a short row", async ({
402+
page,
403+
}) => {
404+
await setMobileCopyOnSelect(page, true);
405+
await openMobileWorkspace(page);
406+
await openMobileTerminalSheet(page);
407+
await ensureTerminalExists(page);
408+
await seedShortTerminalLine(page);
409+
410+
await expect
411+
.poll(async () => {
412+
return findShortPrintedLineRowIndex(page);
413+
})
414+
.not.toBe(-1);
415+
const printedLineRowIndex = await findShortPrintedLineRowIndex(page);
416+
await longPressTerminalRowBlankRightSide(page, printedLineRowIndex);
417+
418+
await expect(page.getByText(translateForE2E("terminal.copied_current_line", "en"))).toHaveCount(
419+
0
420+
);
421+
await expect
422+
.poll(async () => {
423+
return page.evaluate(() => {
424+
return (window as Window & { __mobileCopiedText?: string }).__mobileCopiedText ?? "";
425+
});
426+
})
427+
.toBe("");
428+
});
334429
});

packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ const mockTerminal = {
246246
dispose: vi.fn(),
247247
focus: vi.fn(),
248248
loadAddon: vi.fn(),
249+
cols: 80,
249250
buffer: {
250251
active: {
251252
viewportY: 0,
@@ -6998,6 +6999,7 @@ describe("XtermHost", () => {
69986999
const vibrate = vi.fn();
69997000
const store = createStore();
70007001

7002+
mockTerminal.cols = 80;
70017003
mockTerminal.rows = 3;
70027004
mockTerminal.buffer.active.viewportY = 10;
70037005
setMockBufferLines([
@@ -7062,7 +7064,7 @@ describe("XtermHost", () => {
70627064
rowHeight: 20,
70637065
});
70647066

7065-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 150 }]);
7067+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 6, clientY: 150 }]);
70667068

70677069
await act(async () => {
70687070
vi.advanceTimersByTime(500);
@@ -7094,6 +7096,7 @@ describe("XtermHost", () => {
70947096
const vibrate = vi.fn();
70957097
const store = createStore();
70967098

7099+
mockTerminal.cols = 80;
70977100
mockTerminal.buffer.active.viewportY = 14;
70987101
setMockBufferLines([[14, "", false]]);
70997102

@@ -7145,7 +7148,7 @@ describe("XtermHost", () => {
71457148
rowHeight: 20,
71467149
});
71477150

7148-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
7151+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]);
71497152

71507153
await act(async () => {
71517154
vi.advanceTimersByTime(500);
@@ -7177,6 +7180,7 @@ describe("XtermHost", () => {
71777180
const vibrate = vi.fn();
71787181
const store = createStore();
71797182

7183+
mockTerminal.cols = 80;
71807184
mockTerminal.buffer.active.viewportY = 3;
71817185
setMockBufferLines([[3, "disabled line", false]]);
71827186

@@ -7228,7 +7232,7 @@ describe("XtermHost", () => {
72287232
rowHeight: 20,
72297233
});
72307234

7231-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
7235+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]);
72327236

72337237
await act(async () => {
72347238
vi.advanceTimersByTime(500);
@@ -7248,6 +7252,7 @@ describe("XtermHost", () => {
72487252

72497253
const originalMatchMedia = window.matchMedia;
72507254
const writeText = vi.fn().mockResolvedValue(undefined);
7255+
mockTerminal.cols = 80;
72517256
mockTerminal.rows = 20;
72527257
mockTerminal.buffer.active.viewportY = 6;
72537258
mockTerminal.buffer.active.baseY = 80;
@@ -7296,7 +7301,7 @@ describe("XtermHost", () => {
72967301
}
72977302
);
72987303

7299-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
7304+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]);
73007305
dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 40, clientY: 88 }]);
73017306

73027307
await act(async () => {
@@ -7318,6 +7323,7 @@ describe("XtermHost", () => {
73187323
const originalMatchMedia = window.matchMedia;
73197324
const writeText = vi.fn().mockResolvedValue(undefined);
73207325
const vibrate = vi.fn();
7326+
mockTerminal.cols = 80;
73217327
mockTerminal.rows = 20;
73227328
mockTerminal.buffer.active.viewportY = 6;
73237329
mockTerminal.buffer.active.baseY = 80;
@@ -7373,7 +7379,7 @@ describe("XtermHost", () => {
73737379
}
73747380
);
73757381

7376-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
7382+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]);
73777383
dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 56, clientY: 110 }]);
73787384

73797385
await act(async () => {
@@ -7477,6 +7483,7 @@ describe("XtermHost", () => {
74777483
const vibrate = vi.fn();
74787484
const store = createStore();
74797485

7486+
mockTerminal.cols = 80;
74807487
mockTerminal.buffer.active.viewportY = 7;
74817488
setMockBufferLines([[7, "toast line", false]]);
74827489

@@ -7528,7 +7535,7 @@ describe("XtermHost", () => {
75287535
rowHeight: 20,
75297536
});
75307537

7531-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
7538+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]);
75327539

75337540
await act(async () => {
75347541
vi.advanceTimersByTime(500);
@@ -7560,6 +7567,7 @@ describe("XtermHost", () => {
75607567
const vibrate = vi.fn();
75617568
const store = createStore();
75627569

7570+
mockTerminal.cols = 80;
75637571
mockTerminal.buffer.active.viewportY = 9;
75647572
setMockBufferLines([[10, "from coords", false]]);
75657573

@@ -7635,6 +7643,86 @@ describe("XtermHost", () => {
76357643
vi.useRealTimers();
76367644
});
76377645

7646+
it("mobile line copy does not copy when a long press lands in the blank area to the right of a short row", async () => {
7647+
vi.useFakeTimers();
7648+
viewportMocks.viewport = "mobile";
7649+
7650+
const originalMatchMedia = window.matchMedia;
7651+
const writeText = vi.fn().mockResolvedValue(undefined);
7652+
const vibrate = vi.fn();
7653+
const store = createStore();
7654+
7655+
mockTerminal.cols = 80;
7656+
mockTerminal.buffer.active.viewportY = 12;
7657+
setMockBufferLines([[12, "short", false]]);
7658+
7659+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
7660+
matches: query === "(pointer: coarse)",
7661+
media: query,
7662+
onchange: null,
7663+
addListener: vi.fn(),
7664+
removeListener: vi.fn(),
7665+
addEventListener: vi.fn(),
7666+
removeEventListener: vi.fn(),
7667+
dispatchEvent: vi.fn(),
7668+
})) as typeof window.matchMedia;
7669+
7670+
Object.defineProperty(navigator, "clipboard", {
7671+
configurable: true,
7672+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
7673+
});
7674+
Object.defineProperty(navigator, "vibrate", {
7675+
configurable: true,
7676+
value: vibrate,
7677+
});
7678+
7679+
store.set(localeAtom, "en");
7680+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7681+
store.set(wsClientAtom, {
7682+
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
7683+
subscribe: vi.fn(() => () => {}),
7684+
getStatus: vi.fn(() => "connected"),
7685+
onStatus: vi.fn(() => () => {}),
7686+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
7687+
} as never);
7688+
7689+
const { container } = render(
7690+
<Provider store={store}>
7691+
<XtermHost
7692+
terminalId="mobile-line-copy-short-row-blank-area-terminal"
7693+
workspaceId="test-workspace"
7694+
/>
7695+
</Provider>
7696+
);
7697+
7698+
const host = container.querySelector(".xterm-host") as HTMLDivElement | null;
7699+
expect(host).toBeTruthy();
7700+
7701+
const rowsElement = document.createElement("div");
7702+
rowsElement.className = "xterm-rows";
7703+
rowsElement.innerHTML = "<div><span>short</span></div>";
7704+
host!.appendChild(rowsElement);
7705+
stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], {
7706+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7707+
rowHeight: 20,
7708+
});
7709+
7710+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 220, clientY: 110 }]);
7711+
7712+
await act(async () => {
7713+
vi.advanceTimersByTime(500);
7714+
await Promise.resolve();
7715+
await Promise.resolve();
7716+
});
7717+
7718+
expect(writeText).not.toHaveBeenCalled();
7719+
expect(vibrate).not.toHaveBeenCalled();
7720+
expect(store.get(toastsAtom)).toEqual([]);
7721+
7722+
window.matchMedia = originalMatchMedia;
7723+
vi.useRealTimers();
7724+
});
7725+
76387726
it("mobile line copy still succeeds after the row DOM is replaced before long press matures", async () => {
76397727
vi.useFakeTimers();
76407728
viewportMocks.viewport = "mobile";
@@ -7643,6 +7731,7 @@ describe("XtermHost", () => {
76437731
const writeText = vi.fn().mockResolvedValue(undefined);
76447732
const store = createStore();
76457733

7734+
mockTerminal.cols = 80;
76467735
mockTerminal.buffer.active.viewportY = 4;
76477736
setMockBufferLines([[5, "stable row text", false]]);
76487737

0 commit comments

Comments
 (0)