Skip to content

Commit 93e642d

Browse files
committed
fix(web): tighten mobile line-copy hit testing
1 parent d75f4f9 commit 93e642d

4 files changed

Lines changed: 212 additions & 10 deletions

File tree

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ function stubRowsGeometry(
213213
hostRect?: { x: number; y: number; width: number; height: number };
214214
rowsRect: { x: number; y: number; width: number; height: number };
215215
rowHeight: number;
216+
screenRect?: { x: number; y: number; width: number; height: number };
216217
}
217218
) {
218219
vi.spyOn(host, "getBoundingClientRect").mockReturnValue(
@@ -230,6 +231,13 @@ function stubRowsGeometry(
230231
height: options.rowHeight,
231232
});
232233
});
234+
235+
if (options.screenRect) {
236+
const screenElement = host.querySelector(".xterm-screen");
237+
if (screenElement instanceof HTMLDivElement) {
238+
screenElement.getBoundingClientRect = () => createMockDomRect(options.screenRect!);
239+
}
240+
}
233241
}
234242

235243
const mockTerminal = {
@@ -7723,6 +7731,91 @@ describe("XtermHost", () => {
77237731
vi.useRealTimers();
77247732
});
77257733

7734+
it("mobile line copy does not copy when a long press lands in the right gutter outside the xterm screen grid", async () => {
7735+
vi.useFakeTimers();
7736+
viewportMocks.viewport = "mobile";
7737+
7738+
const originalMatchMedia = window.matchMedia;
7739+
const writeText = vi.fn().mockResolvedValue(undefined);
7740+
const vibrate = vi.fn();
7741+
const store = createStore();
7742+
7743+
mockTerminal.cols = 80;
7744+
mockTerminal.buffer.active.viewportY = 15;
7745+
setMockBufferLines([[15, "x".repeat(80), false]]);
7746+
7747+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
7748+
matches: query === "(pointer: coarse)",
7749+
media: query,
7750+
onchange: null,
7751+
addListener: vi.fn(),
7752+
removeListener: vi.fn(),
7753+
addEventListener: vi.fn(),
7754+
removeEventListener: vi.fn(),
7755+
dispatchEvent: vi.fn(),
7756+
})) as typeof window.matchMedia;
7757+
7758+
Object.defineProperty(navigator, "clipboard", {
7759+
configurable: true,
7760+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
7761+
});
7762+
Object.defineProperty(navigator, "vibrate", {
7763+
configurable: true,
7764+
value: vibrate,
7765+
});
7766+
7767+
store.set(localeAtom, "en");
7768+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7769+
store.set(wsClientAtom, {
7770+
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
7771+
subscribe: vi.fn(() => () => {}),
7772+
getStatus: vi.fn(() => "connected"),
7773+
onStatus: vi.fn(() => () => {}),
7774+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
7775+
} as never);
7776+
7777+
const { container } = render(
7778+
<Provider store={store}>
7779+
<XtermHost
7780+
terminalId="mobile-line-copy-right-gutter-terminal"
7781+
workspaceId="test-workspace"
7782+
/>
7783+
</Provider>
7784+
);
7785+
7786+
const host = container.querySelector(".xterm-host") as HTMLDivElement | null;
7787+
expect(host).toBeTruthy();
7788+
7789+
const screenElement = document.createElement("div");
7790+
screenElement.className = "xterm-screen";
7791+
host!.appendChild(screenElement);
7792+
7793+
const rowsElement = document.createElement("div");
7794+
rowsElement.className = "xterm-rows";
7795+
rowsElement.innerHTML = `<div><span>${"x".repeat(80)}</span></div>`;
7796+
host!.appendChild(rowsElement);
7797+
stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], {
7798+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7799+
screenRect: { x: 0, y: 100, width: 280, height: 20 },
7800+
rowHeight: 20,
7801+
});
7802+
7803+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 300, clientY: 110 }]);
7804+
7805+
await act(async () => {
7806+
vi.advanceTimersByTime(500);
7807+
await Promise.resolve();
7808+
await Promise.resolve();
7809+
});
7810+
7811+
expect(writeText).not.toHaveBeenCalled();
7812+
expect(vibrate).not.toHaveBeenCalled();
7813+
expect(store.get(toastsAtom)).toEqual([]);
7814+
7815+
window.matchMedia = originalMatchMedia;
7816+
vi.useRealTimers();
7817+
});
7818+
77267819
it("mobile line copy still succeeds after the row DOM is replaced before long press matures", async () => {
77277820
vi.useFakeTimers();
77287821
viewportMocks.viewport = "mobile";

packages/web/src/features/terminal-panel/mobile/long-press-copy-line.test.ts

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ interface MockBufferLine {
88
translateToString(trimRight?: boolean): string;
99
}
1010

11-
function createBufferLine(text: string, isWrapped = false): MockBufferLine {
11+
function createBufferLine(
12+
text: string,
13+
isWrapped = false,
14+
noBgTrimmedLength = text.replace(/\s+$/u, "").length
15+
): MockBufferLine {
1216
return {
1317
isWrapped,
1418
getNoBgTrimmedLength() {
15-
return text.replace(/\s+$/u, "").length;
19+
return noBgTrimmedLength;
1620
},
1721
translateToString(trimRight = false) {
1822
return trimRight ? text.replace(/\s+$/u, "") : text;
@@ -426,6 +430,105 @@ describe("getLogicalLineTextFromTouchPoint", () => {
426430
rows.remove();
427431
});
428432

433+
it("returns null when the touch point lands in bg-only trailing cells with no copyable text", () => {
434+
const { rows, secondRow } = createRowsDom();
435+
document.body.appendChild(rows);
436+
rows.getBoundingClientRect = () =>
437+
({
438+
x: 20,
439+
y: 100,
440+
top: 100,
441+
left: 20,
442+
width: 320,
443+
height: 60,
444+
right: 340,
445+
bottom: 160,
446+
toJSON: () => ({}),
447+
}) as DOMRect;
448+
secondRow.getBoundingClientRect = () =>
449+
({
450+
x: 20,
451+
y: 120,
452+
top: 120,
453+
left: 20,
454+
width: 320,
455+
height: 20,
456+
right: 340,
457+
bottom: 140,
458+
toJSON: () => ({}),
459+
}) as DOMRect;
460+
461+
const terminal = createTerminal(16, 90, [[91, createBufferLine("status", false, 10)]]);
462+
463+
expect(
464+
getLogicalLineTextFromTouchPoint({
465+
clientX: 185,
466+
clientY: 130,
467+
rowsElement: rows,
468+
terminal,
469+
})
470+
).toBeNull();
471+
472+
rows.remove();
473+
});
474+
475+
it("returns null when the touch point lands in the right gutter outside the screen cell grid", () => {
476+
const { rows, secondRow } = createRowsDom();
477+
const screenElement = document.createElement("div");
478+
document.body.append(rows, screenElement);
479+
rows.getBoundingClientRect = () =>
480+
({
481+
x: 20,
482+
y: 100,
483+
top: 100,
484+
left: 20,
485+
width: 320,
486+
height: 60,
487+
right: 340,
488+
bottom: 160,
489+
toJSON: () => ({}),
490+
}) as DOMRect;
491+
screenElement.getBoundingClientRect = () =>
492+
({
493+
x: 20,
494+
y: 100,
495+
top: 100,
496+
left: 20,
497+
width: 280,
498+
height: 60,
499+
right: 300,
500+
bottom: 160,
501+
toJSON: () => ({}),
502+
}) as DOMRect;
503+
secondRow.getBoundingClientRect = () =>
504+
({
505+
x: 20,
506+
y: 120,
507+
top: 120,
508+
left: 20,
509+
width: 320,
510+
height: 20,
511+
right: 340,
512+
bottom: 140,
513+
toJSON: () => ({}),
514+
}) as DOMRect;
515+
516+
const terminal = createTerminal(16, 100, [[101, createBufferLine("1234567890abcdef")]]);
517+
518+
expect(
519+
getLogicalLineTextFromTouchPoint({
520+
clientX: 310,
521+
clientY: 130,
522+
rowsElement: rows,
523+
screenElement,
524+
terminal,
525+
})
526+
).toBeNull();
527+
528+
rows.remove();
529+
screenElement.remove();
530+
});
531+
429532
it("returns null when terminal columns are unavailable for horizontal hit testing", () => {
430533
const { rows, secondRow } = createRowsDom();
431534
document.body.appendChild(rows);

packages/web/src/features/terminal-panel/mobile/long-press-copy-line.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
interface BufferLineLike {
22
isWrapped?: boolean;
3-
getNoBgTrimmedLength?(): number;
43
translateToString(trimRight?: boolean): string;
54
}
65

@@ -20,14 +19,11 @@ export interface GetLogicalLineTextFromTouchPointArgs {
2019
clientX: number;
2120
clientY: number;
2221
rowsElement: HTMLElement;
22+
screenElement?: HTMLElement;
2323
terminal: TerminalLikeForLongPressCopy;
2424
}
2525

2626
function getVisualRowContentLength(line: BufferLineLike): number {
27-
if (typeof line.getNoBgTrimmedLength === "function") {
28-
return line.getNoBgTrimmedLength();
29-
}
30-
3127
return line.translateToString(true).length;
3228
}
3329

@@ -50,11 +46,14 @@ function getVisualRowIndexFromTouchPoint(rowsElement: HTMLElement, clientY: numb
5046

5147
function getBufferRowFromTouchPoint(
5248
rowsElement: HTMLElement,
49+
screenElement: HTMLElement | undefined,
5350
terminal: TerminalLikeForLongPressCopy,
5451
clientX: number,
5552
clientY: number
5653
): number | null {
5754
const rowsRect = rowsElement.getBoundingClientRect();
55+
const horizontalBoundsElement = screenElement ?? rowsElement;
56+
const horizontalRect = horizontalBoundsElement.getBoundingClientRect();
5857
if (
5958
clientX < rowsRect.left ||
6059
clientX > rowsRect.right ||
@@ -75,7 +74,7 @@ function getBufferRowFromTouchPoint(
7574
return null;
7675
}
7776

78-
if (!Number.isFinite(terminal.cols) || terminal.cols <= 0 || rowsRect.width <= 0) {
77+
if (!Number.isFinite(terminal.cols) || terminal.cols <= 0 || horizontalRect.width <= 0) {
7978
return null;
8079
}
8180

@@ -84,12 +83,16 @@ function getBufferRowFromTouchPoint(
8483
return bufferRow;
8584
}
8685

87-
const cellWidth = rowsRect.width / terminal.cols;
86+
if (clientX < horizontalRect.left || clientX > horizontalRect.right) {
87+
return null;
88+
}
89+
90+
const cellWidth = horizontalRect.width / terminal.cols;
8891
if (cellWidth <= 0) {
8992
return null;
9093
}
9194

92-
const columnIndex = Math.floor((clientX - rowsRect.left) / cellWidth);
95+
const columnIndex = Math.floor((clientX - horizontalRect.left) / cellWidth);
9396
if (columnIndex < 0 || columnIndex >= terminal.cols) {
9497
return null;
9598
}
@@ -148,6 +151,7 @@ export function getLogicalLineTextFromTouchPoint(
148151
): string | null {
149152
const bufferRow = getBufferRowFromTouchPoint(
150153
args.rowsElement,
154+
args.screenElement,
151155
args.terminal,
152156
args.clientX,
153157
args.clientY

packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,12 +715,14 @@ export function XtermHost({
715715
longPressStartClientY = touch.clientY;
716716
const terminal = terminalRef.current;
717717
const rowsElement = container.querySelector(".xterm-rows");
718+
const screenElement = container.querySelector(".xterm-screen");
718719
longPressLineText =
719720
terminal && rowsElement instanceof HTMLElement
720721
? getLogicalLineTextFromTouchPoint({
721722
clientX: touch.clientX,
722723
clientY: touch.clientY,
723724
rowsElement,
725+
screenElement: screenElement instanceof HTMLElement ? screenElement : undefined,
724726
terminal,
725727
})
726728
: null;

0 commit comments

Comments
 (0)