Skip to content

Commit 9d002a3

Browse files
committed
fix mobile copy-on-select settings behavior
1 parent 851bf21 commit 9d002a3

8 files changed

Lines changed: 165 additions & 35 deletions

File tree

packages/web/src/features/settings/components/settings-page.test.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,7 +1078,7 @@ describe("SettingsPage", () => {
10781078
expect(await screen.findByRole("switch", { name: "选中自动复制" })).toBeInTheDocument();
10791079
});
10801080

1081-
it("does not show copy-on-select on mobile general settings", async () => {
1081+
it("shows copy-on-select on mobile general settings with mobile-specific hint", async () => {
10821082
viewportMocks.viewport = "mobile";
10831083
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
10841084
if (op === "settings.get") {
@@ -1093,10 +1093,8 @@ describe("SettingsPage", () => {
10931093
renderSettingsPage(store);
10941094
fireEvent.click(screen.getByRole("button", { name: "通用" }));
10951095

1096-
await screen.findByText("通知");
1097-
1098-
expect(screen.queryByRole("switch", { name: "选中自动复制" })).not.toBeInTheDocument();
1099-
expect(screen.queryByText("选中自动复制")).not.toBeInTheDocument();
1096+
expect(await screen.findByRole("switch", { name: "选中自动复制" })).toBeInTheDocument();
1097+
expect(screen.getByText("选中文本后自动复制到系统剪贴板")).toBeInTheDocument();
11001098
});
11011099

11021100
it("updates theme through a single shared appearance picker", async () => {

packages/web/src/features/settings/components/settings-page.tsx

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -978,28 +978,26 @@ function GeneralSettings({
978978
</Pill>
979979
</div>
980980

981-
{isMobile ? null : (
982-
<div className="settings-toggle-row">
983-
<div className="settings-toggle-info">
984-
<span className="settings-toggle-label" id={copyOnSelectLabelId}>
985-
{t("settings.copy_on_select")}
986-
</span>
987-
<span className="settings-toggle-desc" id={copyOnSelectDescId}>
988-
{t("settings.copy_on_select_hint")}
989-
</span>
990-
</div>
991-
<Switch
992-
aria-describedby={copyOnSelectDescId}
993-
aria-labelledby={copyOnSelectLabelId}
994-
checked={terminalCopyOnSelect}
995-
className="settings-toggle"
996-
onCheckedChange={(nextValue) => {
997-
setTerminalCopyOnSelect(nextValue);
998-
void saveSettings({ appearance: { terminalCopyOnSelect: nextValue } });
999-
}}
1000-
/>
981+
<div className="settings-toggle-row">
982+
<div className="settings-toggle-info">
983+
<span className="settings-toggle-label" id={copyOnSelectLabelId}>
984+
{t("settings.copy_on_select")}
985+
</span>
986+
<span className="settings-toggle-desc" id={copyOnSelectDescId}>
987+
{t("settings.copy_on_select_hint")}
988+
</span>
1001989
</div>
1002-
)}
990+
<Switch
991+
aria-describedby={copyOnSelectDescId}
992+
aria-labelledby={copyOnSelectLabelId}
993+
checked={terminalCopyOnSelect}
994+
className="settings-toggle"
995+
onCheckedChange={(nextValue) => {
996+
setTerminalCopyOnSelect(nextValue);
997+
void saveSettings({ appearance: { terminalCopyOnSelect: nextValue } });
998+
}}
999+
/>
1000+
</div>
10031001
</div>
10041002

10051003
<div className="settings-group">

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

Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6920,6 +6920,7 @@ describe("XtermHost", () => {
69206920
});
69216921

69226922
store.set(localeAtom, "en");
6923+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
69236924
store.set(wsClientAtom, {
69246925
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
69256926
subscribe: vi.fn(() => () => {}),
@@ -7014,6 +7015,109 @@ describe("XtermHost", () => {
70147015
vi.useRealTimers();
70157016
});
70167017

7018+
it("does not enter mobile copy mode when copy-on-select is disabled", async () => {
7019+
vi.useFakeTimers();
7020+
viewportMocks.viewport = "mobile";
7021+
7022+
const originalMatchMedia = window.matchMedia;
7023+
const vibrate = vi.fn();
7024+
const store = createStore();
7025+
7026+
mockTerminal.cols = 4;
7027+
mockTerminal.rows = 2;
7028+
7029+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
7030+
matches: query === "(pointer: coarse)",
7031+
media: query,
7032+
onchange: null,
7033+
addListener: vi.fn(),
7034+
removeListener: vi.fn(),
7035+
addEventListener: vi.fn(),
7036+
removeEventListener: vi.fn(),
7037+
dispatchEvent: vi.fn(),
7038+
})) as typeof window.matchMedia;
7039+
7040+
Object.defineProperty(navigator, "vibrate", {
7041+
configurable: true,
7042+
value: vibrate,
7043+
});
7044+
7045+
store.set(localeAtom, "en");
7046+
store.set(terminalPreferencesAtom, { copyOnSelect: false });
7047+
store.set(wsClientAtom, {
7048+
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
7049+
subscribe: vi.fn(() => () => {}),
7050+
getStatus: vi.fn(() => "connected"),
7051+
onStatus: vi.fn(() => () => {}),
7052+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
7053+
} as never);
7054+
7055+
const { container } = render(
7056+
<Provider store={store}>
7057+
<XtermHost terminalId="mobile-copy-mode-disabled-terminal" workspaceId="test-workspace" />
7058+
</Provider>
7059+
);
7060+
7061+
const host = container.querySelector(".xterm-host") as HTMLDivElement | null;
7062+
expect(host).toBeTruthy();
7063+
7064+
vi.spyOn(host!, "getBoundingClientRect").mockReturnValue({
7065+
width: 320,
7066+
height: 160,
7067+
top: 0,
7068+
right: 320,
7069+
bottom: 160,
7070+
left: 0,
7071+
x: 0,
7072+
y: 0,
7073+
toJSON: () => ({}),
7074+
} as DOMRect);
7075+
7076+
const rowsElement = document.createElement("div");
7077+
rowsElement.className = "xterm-rows";
7078+
vi.spyOn(rowsElement, "getBoundingClientRect").mockReturnValue({
7079+
width: 200,
7080+
height: 40,
7081+
top: 12,
7082+
right: 212,
7083+
bottom: 52,
7084+
left: 8,
7085+
x: 8,
7086+
y: 12,
7087+
toJSON: () => ({}),
7088+
} as DOMRect);
7089+
7090+
const row = document.createElement("div");
7091+
const span = document.createElement("span");
7092+
span.textContent = "ab";
7093+
row.appendChild(span);
7094+
rowsElement.appendChild(row);
7095+
host!.appendChild(rowsElement);
7096+
7097+
const dispatchTouchEvent = (
7098+
type: string,
7099+
touches: Array<{ identifier: number; clientY: number }>,
7100+
changedTouches: Array<{ identifier: number; clientY: number }> = touches
7101+
) => {
7102+
const event = new Event(type, { bubbles: true, cancelable: true });
7103+
Object.defineProperty(event, "touches", { value: touches });
7104+
Object.defineProperty(event, "changedTouches", { value: changedTouches });
7105+
host?.dispatchEvent(event);
7106+
};
7107+
7108+
dispatchTouchEvent("touchstart", [{ identifier: 1, clientY: 120 }]);
7109+
7110+
await act(async () => {
7111+
vi.advanceTimersByTime(500);
7112+
});
7113+
7114+
expect(container.querySelector(".mobile-terminal-copy-mode")).toBeNull();
7115+
expect(vibrate).not.toHaveBeenCalled();
7116+
7117+
window.matchMedia = originalMatchMedia;
7118+
vi.useRealTimers();
7119+
});
7120+
70177121
it("does not enter mobile copy mode when the gesture becomes a scroll", async () => {
70187122
vi.useFakeTimers();
70197123
viewportMocks.viewport = "mobile";
@@ -7034,10 +7138,14 @@ describe("XtermHost", () => {
70347138
dispatchEvent: vi.fn(),
70357139
})) as typeof window.matchMedia;
70367140

7141+
const store = createStore();
7142+
store.set(localeAtom, "en");
7143+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7144+
70377145
const { container } = render(
7038-
<JotaiProvider>
7146+
<Provider store={store}>
70397147
<XtermHost terminalId="mobile-copy-mode-scroll-terminal" workspaceId="test-workspace" />
7040-
</JotaiProvider>
7148+
</Provider>
70417149
);
70427150

70437151
const host = container.querySelector(".xterm-host");
@@ -7095,13 +7203,17 @@ describe("XtermHost", () => {
70957203
value: vibrate,
70967204
});
70977205

7206+
const store = createStore();
7207+
store.set(localeAtom, "en");
7208+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7209+
70987210
const { container } = render(
7099-
<JotaiProvider>
7211+
<Provider store={store}>
71007212
<XtermHost
71017213
terminalId="mobile-copy-mode-horizontal-drift-terminal"
71027214
workspaceId="test-workspace"
71037215
/>
7104-
</JotaiProvider>
7216+
</Provider>
71057217
);
71067218

71077219
const host = container.querySelector(".xterm-host");
@@ -7190,6 +7302,7 @@ describe("XtermHost", () => {
71907302
})) as typeof window.matchMedia;
71917303

71927304
store.set(localeAtom, "en");
7305+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
71937306
store.set(wsClientAtom, {
71947307
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
71957308
subscribe: vi.fn(() => () => {}),
@@ -7306,13 +7419,17 @@ describe("XtermHost", () => {
73067419
}) as typeof requestAnimationFrame;
73077420
global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame;
73087421

7422+
const store = createStore();
7423+
store.set(localeAtom, "en");
7424+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7425+
73097426
const { container } = render(
7310-
<JotaiProvider>
7427+
<Provider store={store}>
73117428
<XtermHost
73127429
terminalId="mobile-copy-mode-below-tolerance-momentum-terminal"
73137430
workspaceId="test-workspace"
73147431
/>
7315-
</JotaiProvider>
7432+
</Provider>
73167433
);
73177434

73187435
const host = container.querySelector(".xterm-host");
@@ -7370,6 +7487,7 @@ describe("XtermHost", () => {
73707487
})) as typeof window.matchMedia;
73717488

73727489
store.set(localeAtom, "en");
7490+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
73737491
store.set(wsClientAtom, {
73747492
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
73757493
subscribe: vi.fn(() => () => {}),
@@ -7472,6 +7590,7 @@ describe("XtermHost", () => {
74727590
})) as typeof window.matchMedia;
74737591

74747592
store.set(localeAtom, "en");
7593+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
74757594
store.set(wsClientAtom, {
74767595
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
74777596
subscribe: vi.fn(() => () => {}),
@@ -7568,6 +7687,7 @@ describe("XtermHost", () => {
75687687
})) as typeof window.matchMedia;
75697688

75707689
store.set(localeAtom, "en");
7690+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
75717691
store.set(wsClientAtom, {
75727692
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
75737693
subscribe: vi.fn(() => () => {}),
@@ -7674,6 +7794,7 @@ describe("XtermHost", () => {
76747794
})) as typeof window.matchMedia;
76757795

76767796
store.set(localeAtom, "en");
7797+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
76777798
store.set(wsClientAtom, {
76787799
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
76797800
subscribe: vi.fn(() => () => {}),
@@ -7769,6 +7890,7 @@ describe("XtermHost", () => {
77697890
})) as typeof window.matchMedia;
77707891

77717892
store.set(localeAtom, "zh");
7893+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
77727894
store.set(wsClientAtom, {
77737895
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
77747896
subscribe: vi.fn(() => () => {}),
@@ -7903,6 +8025,7 @@ describe("XtermHost", () => {
79038025
})) as typeof window.matchMedia;
79048026

79058027
store.set(localeAtom, "en");
8028+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
79068029
store.set(wsClientAtom, {
79078030
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
79088031
subscribe: vi.fn(() => () => {}),

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,11 @@ export function XtermHost({
963963
);
964964

965965
const enterMobileCopyMode = useCallback(() => {
966+
if (!terminalPreferences.copyOnSelect) {
967+
resetTouchStateRef.current();
968+
return;
969+
}
970+
966971
const container = containerRef.current;
967972
const terminal = terminalRef.current;
968973

@@ -998,7 +1003,7 @@ export function XtermHost({
9981003
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
9991004
navigator.vibrate(10);
10001005
}
1001-
}, [pushCopyModeFailureToast]);
1006+
}, [pushCopyModeFailureToast, terminalPreferences.copyOnSelect]);
10021007

10031008
useEffect(() => {
10041009
enterMobileCopyModeRef.current = enterMobileCopyMode;

packages/web/src/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@
524524
"terminal_renderer": "Terminal Renderer",
525525
"terminal_renderer_hint": "Choose terminal rendering mode",
526526
"copy_on_select": "Copy on select",
527-
"copy_on_select_hint": "Automatically copy selected text to the system clipboard in desktop terminals",
527+
"copy_on_select_hint": "Automatically copy selected text to the system clipboard",
528528
"copy_on_select_failed_title": "Copy on select failed",
529529
"copy_on_select_failed_body": "Use Ctrl/Cmd+C to copy manually",
530530
"terminal_standard": "Standard",

packages/web/src/locales/zh.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@
524524
"terminal_renderer": "终端渲染器",
525525
"terminal_renderer_hint": "选择终端渲染模式",
526526
"copy_on_select": "选中自动复制",
527-
"copy_on_select_hint": "在桌面端终端中,选中文本后自动复制到系统剪贴板",
527+
"copy_on_select_hint": "选中文本后自动复制到系统剪贴板",
528528
"copy_on_select_failed_title": "自动复制失败",
529529
"copy_on_select_failed_body": "请使用 Ctrl/Cmd+C 手动复制",
530530
"terminal_standard": "标准",

packages/web/src/styles/components.css

Lines changed: 3 additions & 0 deletions
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+
overflow: hidden;
91239124
background: color-mix(in srgb, var(--bg-terminal) 94%, var(--bg-page) 6%);
91249125
color: var(--text-primary);
91259126
user-select: text;
@@ -9152,6 +9153,7 @@ textarea.input {
91529153
.mobile-terminal-copy-mode__content {
91539154
flex: 1;
91549155
min-height: 0;
9156+
max-width: 100%;
91559157
overflow: auto;
91569158
user-select: text;
91579159
-webkit-user-select: text;
@@ -9161,6 +9163,7 @@ textarea.input {
91619163

91629164
.mobile-terminal-copy-mode__text {
91639165
display: inline-block;
9166+
min-width: 100%;
91649167
margin: 0;
91659168
white-space: pre;
91669169
user-select: text;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,16 +299,19 @@ 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("overflow: hidden");
302303
expect(overlay).toContain("user-select: text");
303304
expect(overlay).toContain("-webkit-user-select: text");
304305
expect(toolbar).toContain("display: flex");
305306
expect(toolbar).toContain("align-items: center");
306307
expect(done).toContain("margin-left: auto");
307308
expect(content).toContain("overflow: auto");
309+
expect(content).toContain("max-width: 100%");
308310
expect(content).toContain("-webkit-overflow-scrolling: touch");
309311
expect(content).toContain("user-select: text");
310312
expect(content).toContain("-webkit-user-select: text");
311313
expect(content).toContain("-webkit-touch-callout: default");
314+
expect(text).toContain("min-width: 100%");
312315
expect(text).toContain("white-space: pre");
313316
expect(text).toContain("user-select: text");
314317
expect(text).toContain("-webkit-user-select: text");

0 commit comments

Comments
 (0)