Skip to content

Commit 6180dad

Browse files
committed
fix mobile terminal copy mode layout
1 parent 9d002a3 commit 6180dad

3 files changed

Lines changed: 356 additions & 1 deletion

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
import { expect, type Locator, type Page, test } from "@playwright/test";
2+
import { translateForE2E, translatePatternForE2E } from "../../fixtures/i18n";
3+
import { openSettingsSection } from "../../fixtures/phase2-i18n";
4+
5+
const MOBILE_VIEWPORT = { width: 430, height: 932 };
6+
const LONG_LINE_TEXT = "MOBILE_COPY_MODE_LONG_LINE_0123456789_".repeat(12);
7+
8+
function directoryRow(page: Page, name: string): Locator {
9+
return page
10+
.locator(".fp-dir")
11+
.filter({
12+
has: page.locator(".fp-dir-name").filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) }),
13+
})
14+
.first();
15+
}
16+
17+
function escapeRegExp(value: string): string {
18+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19+
}
20+
21+
async function openMobileWorkspace(page: Page): Promise<void> {
22+
await page.goto("/workspace");
23+
await page.waitForFunction(
24+
() => {
25+
const loading = document.querySelector(
26+
'.app-loading-shell, [data-testid="workspace-resolving-shell"]'
27+
);
28+
const shell = document.querySelector('[data-testid="mobile-shell"]');
29+
const workspaceEntry = Array.from(document.querySelectorAll("button")).some((button) => {
30+
const label = (button.getAttribute("aria-label") || button.textContent || "").trim();
31+
return /^(Open Workspace||New workspace|)$/.test(label);
32+
});
33+
34+
return !loading && (Boolean(shell) || workspaceEntry);
35+
},
36+
{ timeout: 20000 }
37+
);
38+
39+
if (
40+
await page
41+
.getByTestId("mobile-shell")
42+
.isVisible()
43+
.catch(() => false)
44+
) {
45+
return;
46+
}
47+
48+
const openWorkspace = page.getByRole("button", {
49+
name: translatePatternForE2E("action.open_workspace"),
50+
});
51+
const newWorkspace = page.getByRole("button", {
52+
name: translatePatternForE2E("tooltip.new_workspace"),
53+
});
54+
55+
if (
56+
await openWorkspace
57+
.first()
58+
.isVisible()
59+
.catch(() => false)
60+
) {
61+
try {
62+
await openWorkspace.first().click();
63+
} catch (error) {
64+
if (
65+
await page
66+
.getByTestId("mobile-shell")
67+
.isVisible()
68+
.catch(() => false)
69+
) {
70+
return;
71+
}
72+
73+
if (
74+
await page
75+
.locator(".mobile-sheet--launch, .launch-modal")
76+
.first()
77+
.isVisible()
78+
.catch(() => false)
79+
) {
80+
// launch UI is already open
81+
} else {
82+
throw error;
83+
}
84+
}
85+
} else if (
86+
await newWorkspace
87+
.first()
88+
.isVisible()
89+
.catch(() => false)
90+
) {
91+
await newWorkspace.first().click();
92+
} else {
93+
await page
94+
.getByRole("button", {
95+
name: translatePatternForE2E("mobile.topbar.switch_workspace"),
96+
})
97+
.click();
98+
await page
99+
.getByRole("button", {
100+
name: translatePatternForE2E("tooltip.new_workspace"),
101+
})
102+
.click();
103+
}
104+
105+
await expect(page.locator(".mobile-sheet--launch, .launch-modal").first()).toBeVisible({
106+
timeout: 10000,
107+
});
108+
109+
const homeChip = page.locator(".fp-chip").filter({ hasText: "/home/spencer" }).first();
110+
if (await homeChip.isVisible().catch(() => false)) {
111+
await homeChip.click();
112+
await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0);
113+
}
114+
115+
await directoryRow(page, "workspace").dblclick();
116+
await expect(page.locator(".fp-dir-list .directory-loading")).toHaveCount(0);
117+
await directoryRow(page, "coder-studio").click();
118+
119+
const startButton = page.getByRole("button", {
120+
name: translatePatternForE2E("workspace.launch.start"),
121+
});
122+
await expect(startButton).toBeEnabled();
123+
await startButton.click();
124+
125+
await expect(page).toHaveURL(/\/workspace$/, { timeout: 15000 });
126+
await expect(page.getByTestId("mobile-shell")).toBeVisible({ timeout: 15000 });
127+
}
128+
129+
async function openMobileTerminalSheet(page: Page): Promise<void> {
130+
await page
131+
.getByRole("button", { name: translatePatternForE2E("mobile.dock.open_terminal") })
132+
.click();
133+
await expect(page.locator(".mobile-sheet--terminal")).toBeVisible({ timeout: 10000 });
134+
}
135+
136+
async function ensureTerminalExists(page: Page): Promise<void> {
137+
const xterm = page.locator(".mobile-sheet--terminal .xterm").first();
138+
if (await xterm.isVisible().catch(() => false)) {
139+
return;
140+
}
141+
142+
await page
143+
.locator(".mobile-sheet--terminal")
144+
.getByRole("button", { name: translatePatternForE2E("terminal.new_terminal") })
145+
.first()
146+
.click();
147+
await expect(page.locator(".mobile-sheet--terminal .xterm textarea").first()).toBeVisible({
148+
timeout: 15000,
149+
});
150+
}
151+
152+
async function seedLongTerminalLine(page: Page): Promise<void> {
153+
const terminalInput = page.locator(".mobile-sheet--terminal .xterm textarea").first();
154+
await expect(terminalInput).toBeVisible({ timeout: 10000 });
155+
await terminalInput.click();
156+
await page.keyboard.type(`printf '${LONG_LINE_TEXT}\\n'`);
157+
await page.keyboard.press("Enter");
158+
await expect(page.locator(".mobile-sheet--terminal .xterm-rows").first()).toContainText(
159+
"MOBILE_COPY_MODE_LONG_LINE",
160+
{
161+
timeout: 10000,
162+
}
163+
);
164+
}
165+
166+
async function longPressTerminalRows(page: Page): Promise<void> {
167+
const rows = page.locator(".mobile-sheet--terminal .xterm-rows").first();
168+
const host = page.locator(".mobile-sheet--terminal .xterm-host").first();
169+
await expect(rows).toBeVisible({ timeout: 10000 });
170+
await expect(host).toBeVisible({ timeout: 10000 });
171+
const box = await rows.boundingBox();
172+
expect(box).toBeTruthy();
173+
if (!box) {
174+
throw new Error("xterm rows bounding box missing");
175+
}
176+
177+
const x = box.x + Math.min(48, Math.max(16, box.width / 4));
178+
const y = box.y + Math.min(32, Math.max(12, box.height / 3));
179+
180+
await host.evaluate(
181+
(node, { clientX, clientY }) => {
182+
if (!(node instanceof HTMLElement)) {
183+
throw new Error("xterm host missing");
184+
}
185+
186+
const touches = [{ identifier: 1, clientX, clientY }];
187+
const buildEvent = (
188+
type: string,
189+
activeTouches: typeof touches,
190+
changedTouches = activeTouches
191+
) => {
192+
const event = new Event(type, { bubbles: true, cancelable: true });
193+
Object.defineProperty(event, "touches", { value: activeTouches });
194+
Object.defineProperty(event, "targetTouches", { value: activeTouches });
195+
Object.defineProperty(event, "changedTouches", { value: changedTouches });
196+
return event;
197+
};
198+
199+
node.dispatchEvent(buildEvent("touchstart", touches));
200+
window.setTimeout(() => {
201+
node.dispatchEvent(buildEvent("touchend", [], touches));
202+
}, 650);
203+
},
204+
{ clientX: x, clientY: y }
205+
);
206+
}
207+
208+
async function setMobileCopyOnSelect(page: Page, enabled: boolean): Promise<void> {
209+
await page.goto("/settings");
210+
await expect(page.locator(".settings-page")).toBeVisible({ timeout: 15000 });
211+
await openSettingsSection(page, "general");
212+
213+
const toggle = page.getByRole("switch", {
214+
name: translatePatternForE2E("settings.copy_on_select"),
215+
});
216+
await expect(toggle).toBeVisible({ timeout: 10000 });
217+
218+
const checked = (await toggle.getAttribute("aria-checked")) === "true";
219+
if (checked !== enabled) {
220+
await toggle.click();
221+
await expect(toggle).toHaveAttribute("aria-checked", enabled ? "true" : "false");
222+
}
223+
}
224+
225+
test.describe("mobile copy on select", () => {
226+
test.use({ viewport: MOBILE_VIEWPORT, hasTouch: true, isMobile: true });
227+
228+
test.beforeEach(async ({ page }) => {
229+
await page.addInitScript(() => {
230+
window.localStorage.setItem("ui.locale", JSON.stringify("en"));
231+
});
232+
});
233+
234+
test("mobile copy mode respects the setting and does not expand page or sheet layout", async ({
235+
page,
236+
}) => {
237+
await setMobileCopyOnSelect(page, true);
238+
await openMobileWorkspace(page);
239+
await openMobileTerminalSheet(page);
240+
await ensureTerminalExists(page);
241+
await seedLongTerminalLine(page);
242+
243+
const beforeMetrics = await page.evaluate(() => {
244+
const sheet = document.querySelector(".mobile-sheet--terminal");
245+
const terminalSheet = document.querySelector(".mobile-terminal-sheet");
246+
const body = document.body;
247+
const doc = document.documentElement;
248+
const viewportWidth = window.innerWidth;
249+
const viewportHeight = window.innerHeight;
250+
251+
return {
252+
viewportWidth,
253+
viewportHeight,
254+
docScrollWidth: doc.scrollWidth,
255+
docScrollHeight: doc.scrollHeight,
256+
bodyScrollWidth: body.scrollWidth,
257+
bodyScrollHeight: body.scrollHeight,
258+
sheetRect: sheet?.getBoundingClientRect().toJSON() ?? null,
259+
terminalSheetRect: terminalSheet?.getBoundingClientRect().toJSON() ?? null,
260+
};
261+
});
262+
263+
await longPressTerminalRows(page);
264+
265+
const copyMode = page.locator(".mobile-terminal-copy-mode");
266+
await expect(copyMode).toBeVisible({ timeout: 5000 });
267+
await expect(
268+
copyMode.getByText(translateForE2E("terminal.copy_mode_title", "en"))
269+
).toBeVisible();
270+
271+
const afterMetrics = await page.evaluate(() => {
272+
const body = document.body;
273+
const doc = document.documentElement;
274+
const overlay = document.querySelector(".mobile-terminal-copy-mode");
275+
const content = document.querySelector(".mobile-terminal-copy-mode__content");
276+
const text = document.querySelector(".mobile-terminal-copy-mode__text");
277+
const sheet = document.querySelector(".mobile-sheet--terminal");
278+
const terminalSheet = document.querySelector(".mobile-terminal-sheet");
279+
const viewportWidth = window.innerWidth;
280+
const viewportHeight = window.innerHeight;
281+
282+
const rect = (node: Element | null) => node?.getBoundingClientRect().toJSON() ?? null;
283+
284+
return {
285+
viewportWidth,
286+
viewportHeight,
287+
docScrollWidth: doc.scrollWidth,
288+
docScrollHeight: doc.scrollHeight,
289+
bodyScrollWidth: body.scrollWidth,
290+
bodyScrollHeight: body.scrollHeight,
291+
overlayRect: rect(overlay),
292+
contentRect: rect(content),
293+
textRect: rect(text),
294+
sheetRect: rect(sheet),
295+
terminalSheetRect: rect(terminalSheet),
296+
};
297+
});
298+
299+
expect(afterMetrics.docScrollWidth).toBeLessThanOrEqual(afterMetrics.viewportWidth);
300+
expect(afterMetrics.bodyScrollWidth).toBeLessThanOrEqual(afterMetrics.viewportWidth);
301+
expect(afterMetrics.docScrollHeight).toBeLessThanOrEqual(afterMetrics.viewportHeight);
302+
expect(afterMetrics.bodyScrollHeight).toBeLessThanOrEqual(afterMetrics.viewportHeight);
303+
304+
expect(afterMetrics.overlayRect?.width ?? 0).toBeLessThanOrEqual(afterMetrics.viewportWidth);
305+
expect(afterMetrics.overlayRect?.height ?? 0).toBeLessThanOrEqual(afterMetrics.viewportHeight);
306+
expect(afterMetrics.contentRect?.width ?? 0).toBeLessThanOrEqual(
307+
(afterMetrics.overlayRect?.width ?? afterMetrics.viewportWidth) + 1
308+
);
309+
expect(afterMetrics.sheetRect?.width ?? 0).toBeLessThanOrEqual(afterMetrics.viewportWidth);
310+
expect(afterMetrics.sheetRect?.height ?? 0).toBeLessThanOrEqual(afterMetrics.viewportHeight);
311+
312+
expect(
313+
Math.abs((afterMetrics.sheetRect?.width ?? 0) - (beforeMetrics.sheetRect?.width ?? 0))
314+
).toBeLessThanOrEqual(1);
315+
expect(
316+
Math.abs((afterMetrics.sheetRect?.height ?? 0) - (beforeMetrics.sheetRect?.height ?? 0))
317+
).toBeLessThanOrEqual(1);
318+
expect(
319+
Math.abs(
320+
(afterMetrics.terminalSheetRect?.width ?? 0) - (beforeMetrics.terminalSheetRect?.width ?? 0)
321+
)
322+
).toBeLessThanOrEqual(1);
323+
expect(
324+
Math.abs(
325+
(afterMetrics.terminalSheetRect?.height ?? 0) -
326+
(beforeMetrics.terminalSheetRect?.height ?? 0)
327+
)
328+
).toBeLessThanOrEqual(1);
329+
});
330+
331+
test("mobile long press does not enter copy mode when copy on select is disabled", async ({
332+
page,
333+
}) => {
334+
await setMobileCopyOnSelect(page, false);
335+
await openMobileWorkspace(page);
336+
await openMobileTerminalSheet(page);
337+
await ensureTerminalExists(page);
338+
await seedLongTerminalLine(page);
339+
340+
await longPressTerminalRows(page);
341+
342+
await expect(page.locator(".mobile-terminal-copy-mode")).toHaveCount(0);
343+
});
344+
});

packages/web/src/styles/components.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9120,6 +9120,7 @@ textarea.input {
91209120
flex-direction: column;
91219121
min-height: 0;
91229122
padding: var(--sp-3);
9123+
box-sizing: border-box;
91239124
overflow: hidden;
91249125
background: color-mix(in srgb, var(--bg-terminal) 94%, var(--bg-page) 6%);
91259126
color: var(--text-primary);
@@ -9153,7 +9154,9 @@ textarea.input {
91539154
.mobile-terminal-copy-mode__content {
91549155
flex: 1;
91559156
min-height: 0;
9157+
min-width: 0;
91569158
max-width: 100%;
9159+
max-height: 100%;
91579160
overflow: auto;
91589161
user-select: text;
91599162
-webkit-user-select: text;
@@ -9162,7 +9165,9 @@ textarea.input {
91629165
}
91639166

91649167
.mobile-terminal-copy-mode__text {
9165-
display: inline-block;
9168+
display: block;
9169+
width: max-content;
9170+
max-width: 100%;
91669171
min-width: 100%;
91679172
margin: 0;
91689173
white-space: pre;

packages/web/src/styles/components.theme.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,24 @@ describe("components.css theme-sensitive surfaces", () => {
299299
expect(overlay).toContain("position: absolute");
300300
expect(overlay).toContain("inset: 0");
301301
expect(overlay).toContain("z-index: 6");
302+
expect(overlay).toContain("box-sizing: border-box");
302303
expect(overlay).toContain("overflow: hidden");
303304
expect(overlay).toContain("user-select: text");
304305
expect(overlay).toContain("-webkit-user-select: text");
305306
expect(toolbar).toContain("display: flex");
306307
expect(toolbar).toContain("align-items: center");
307308
expect(done).toContain("margin-left: auto");
308309
expect(content).toContain("overflow: auto");
310+
expect(content).toContain("min-width: 0");
309311
expect(content).toContain("max-width: 100%");
312+
expect(content).toContain("max-height: 100%");
310313
expect(content).toContain("-webkit-overflow-scrolling: touch");
311314
expect(content).toContain("user-select: text");
312315
expect(content).toContain("-webkit-user-select: text");
313316
expect(content).toContain("-webkit-touch-callout: default");
317+
expect(text).toContain("display: block");
318+
expect(text).toContain("width: max-content");
319+
expect(text).toContain("max-width: 100%");
314320
expect(text).toContain("min-width: 100%");
315321
expect(text).toContain("white-space: pre");
316322
expect(text).toContain("user-select: text");

0 commit comments

Comments
 (0)