Skip to content

Commit 981944d

Browse files
committed
test(web): add mobile terminal line-copy helper
1 parent dd5e043 commit 981944d

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { getLogicalLineTextFromTouchTarget } from "./long-press-copy-line";
4+
5+
interface MockBufferLine {
6+
isWrapped?: boolean;
7+
translateToString(trimRight?: boolean): string;
8+
}
9+
10+
function createBufferLine(text: string, isWrapped = false): MockBufferLine {
11+
return {
12+
isWrapped,
13+
translateToString(trimRight = false) {
14+
return trimRight ? text.replace(/\s+$/u, "") : text;
15+
},
16+
};
17+
}
18+
19+
function createTerminal(
20+
viewportY: number,
21+
lines: Array<[row: number, line: MockBufferLine]>
22+
): {
23+
buffer: {
24+
active: {
25+
viewportY: number;
26+
getLine(row: number): MockBufferLine | undefined;
27+
};
28+
};
29+
} {
30+
const byRow = new Map(lines);
31+
return {
32+
buffer: {
33+
active: {
34+
viewportY,
35+
getLine(row: number) {
36+
return byRow.get(row);
37+
},
38+
},
39+
},
40+
};
41+
}
42+
43+
function createRowsDom() {
44+
const rows = document.createElement("div");
45+
rows.className = "xterm-rows";
46+
47+
const firstRow = document.createElement("div");
48+
firstRow.innerHTML = "<span>first</span>";
49+
50+
const secondRow = document.createElement("div");
51+
secondRow.innerHTML = "<span><span>second</span></span>";
52+
53+
const thirdRow = document.createElement("div");
54+
thirdRow.innerHTML = "<span><span>third</span></span>";
55+
56+
rows.append(firstRow, secondRow, thirdRow);
57+
58+
return {
59+
rows,
60+
firstTarget: firstRow.querySelector("span") as HTMLSpanElement,
61+
secondTarget: secondRow.querySelector("span span") as HTMLSpanElement,
62+
thirdTarget: thirdRow.querySelector("span span") as HTMLSpanElement,
63+
};
64+
}
65+
66+
describe("getLogicalLineTextFromTouchTarget", () => {
67+
it("maps the touched visual row through viewportY", () => {
68+
const { rows, secondTarget } = createRowsDom();
69+
document.body.appendChild(rows);
70+
71+
const terminal = createTerminal(10, [[11, createBufferLine("beta")]]);
72+
73+
expect(getLogicalLineTextFromTouchTarget({ target: secondTarget, terminal })).toBe("beta");
74+
75+
rows.remove();
76+
});
77+
78+
it("walks upward and downward across wrapped rows and trims only the final segment", () => {
79+
const { rows, thirdTarget } = createRowsDom();
80+
document.body.appendChild(rows);
81+
82+
const terminal = createTerminal(20, [
83+
[20, createBufferLine("unrelated line")],
84+
[21, createBufferLine("prefix ", false)],
85+
[22, createBufferLine("middle ", true)],
86+
[23, createBufferLine("suffix ", true)],
87+
]);
88+
89+
expect(getLogicalLineTextFromTouchTarget({ target: thirdTarget, terminal })).toBe(
90+
"prefix middle suffix"
91+
);
92+
93+
rows.remove();
94+
});
95+
96+
it("preserves meaningful internal spaces from wrapped intermediate segments", () => {
97+
const { rows, secondTarget } = createRowsDom();
98+
document.body.appendChild(rows);
99+
100+
const terminal = createTerminal(30, [
101+
[30, createBufferLine("double ", false)],
102+
[31, createBufferLine("space ", true)],
103+
]);
104+
105+
expect(getLogicalLineTextFromTouchTarget({ target: secondTarget, terminal })).toBe(
106+
"double space"
107+
);
108+
109+
rows.remove();
110+
});
111+
112+
it("returns null when the touch target does not resolve to a direct xterm row", () => {
113+
const outside = document.createElement("div");
114+
outside.innerHTML = "<span>outside</span>";
115+
document.body.appendChild(outside);
116+
117+
const terminal = createTerminal(0, [[0, createBufferLine("ignored")]]);
118+
119+
expect(
120+
getLogicalLineTextFromTouchTarget({
121+
target: outside.querySelector("span"),
122+
terminal,
123+
})
124+
).toBeNull();
125+
126+
outside.remove();
127+
});
128+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
interface BufferLineLike {
2+
isWrapped?: boolean;
3+
translateToString(trimRight?: boolean): string;
4+
}
5+
6+
interface ActiveBufferLike {
7+
viewportY: number;
8+
getLine(row: number): BufferLineLike | undefined;
9+
}
10+
11+
export interface TerminalLikeForLongPressCopy {
12+
buffer: {
13+
active: ActiveBufferLike;
14+
};
15+
}
16+
17+
export interface GetLogicalLineTextFromTouchTargetArgs {
18+
target: EventTarget | null;
19+
terminal: TerminalLikeForLongPressCopy;
20+
}
21+
22+
function toElement(target: EventTarget | null): Element | null {
23+
if (target instanceof Element) {
24+
return target;
25+
}
26+
27+
if (target instanceof Node) {
28+
return target.parentElement;
29+
}
30+
31+
return null;
32+
}
33+
34+
function findDirectXtermRow(target: EventTarget | null): HTMLElement | null {
35+
let current: Element | null = toElement(target);
36+
37+
while (current) {
38+
const parent = current.parentElement;
39+
if (parent?.classList.contains("xterm-rows") && current instanceof HTMLElement) {
40+
return current;
41+
}
42+
current = parent;
43+
}
44+
45+
return null;
46+
}
47+
48+
export function getLogicalLineTextFromTouchTarget(
49+
args: GetLogicalLineTextFromTouchTargetArgs
50+
): string | null {
51+
const rowElement = findDirectXtermRow(args.target);
52+
if (!rowElement) {
53+
return null;
54+
}
55+
56+
const rowsElement = rowElement.parentElement;
57+
if (!rowsElement || !rowsElement.classList.contains("xterm-rows")) {
58+
return null;
59+
}
60+
61+
const visualRowIndex = Array.prototype.indexOf.call(rowsElement.children, rowElement) as number;
62+
if (visualRowIndex < 0) {
63+
return null;
64+
}
65+
66+
const activeBuffer = args.terminal.buffer.active;
67+
const bufferRow = activeBuffer.viewportY + visualRowIndex;
68+
69+
let startRow = bufferRow;
70+
let currentLine = activeBuffer.getLine(startRow);
71+
if (!currentLine) {
72+
return null;
73+
}
74+
75+
while (currentLine.isWrapped === true) {
76+
startRow -= 1;
77+
if (startRow < 0) {
78+
return null;
79+
}
80+
81+
currentLine = activeBuffer.getLine(startRow);
82+
if (!currentLine) {
83+
return null;
84+
}
85+
}
86+
87+
const segments: BufferLineLike[] = [currentLine];
88+
let scanRow = startRow;
89+
90+
while (true) {
91+
const nextLine = activeBuffer.getLine(scanRow + 1);
92+
if (!nextLine || nextLine.isWrapped !== true) {
93+
break;
94+
}
95+
96+
segments.push(nextLine);
97+
scanRow += 1;
98+
}
99+
100+
return segments
101+
.map((line, index) => line.translateToString(index === segments.length - 1))
102+
.join("");
103+
}

0 commit comments

Comments
 (0)