Skip to content

Commit a8c4fd9

Browse files
committed
feat: add settings UI for terminal copy-on-select
1 parent 62b5e13 commit a8c4fd9

4 files changed

Lines changed: 167 additions & 1 deletion

File tree

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "../../../atoms/connection";
1111
import { activeWorkspaceIdAtom } from "../../../atoms/workspaces";
1212
import { CommandResultError } from "../../../ws/client";
13+
import { terminalPreferencesAtom } from "../../terminal-panel/preferences";
1314
import { SettingsPage } from "./settings-page";
1415

1516
const viewportMocks = vi.hoisted(() => ({
@@ -1022,6 +1023,103 @@ describe("SettingsPage", () => {
10221023
expect(screen.getByRole("button", { name: "标准" })).toHaveAttribute("aria-pressed", "false");
10231024
});
10241025

1026+
it("renders the copy-on-select switch from loaded appearance settings", async () => {
1027+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
1028+
if (op === "settings.get") {
1029+
return {
1030+
"appearance.terminalCopyOnSelect": true,
1031+
};
1032+
}
1033+
return {};
1034+
});
1035+
const store = createConnectedStore(sendCommand);
1036+
1037+
renderSettingsPage(store);
1038+
fireEvent.click(screen.getByRole("button", { name: "外观" }));
1039+
1040+
expect(await screen.findByRole("switch", { name: "选中自动复制" })).toHaveAttribute(
1041+
"aria-checked",
1042+
"true"
1043+
);
1044+
expect(store.get(terminalPreferencesAtom)).toEqual({ copyOnSelect: true });
1045+
});
1046+
1047+
it("updates copy-on-select through the appearance switch and syncs the global atom", async () => {
1048+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
1049+
if (op === "settings.get") {
1050+
return {
1051+
"appearance.terminalCopyOnSelect": false,
1052+
};
1053+
}
1054+
return {};
1055+
});
1056+
const store = createConnectedStore(sendCommand);
1057+
1058+
renderSettingsPage(store);
1059+
fireEvent.click(screen.getByRole("button", { name: "外观" }));
1060+
fireEvent.click(await screen.findByRole("switch", { name: "选中自动复制" }));
1061+
1062+
await waitFor(() => {
1063+
expect(sendCommand).toHaveBeenCalledWith(
1064+
"settings.update",
1065+
{
1066+
settings: {
1067+
appearance: {
1068+
terminalCopyOnSelect: true,
1069+
},
1070+
},
1071+
},
1072+
undefined
1073+
);
1074+
});
1075+
1076+
expect(store.get(terminalPreferencesAtom)).toEqual({ copyOnSelect: true });
1077+
});
1078+
1079+
it("preserves copy-on-select when a stale settings load resolves afterward", async () => {
1080+
let resolveSettingsGet: ((value: Record<string, unknown>) => void) | undefined;
1081+
const settingsGetPromise = new Promise<Record<string, unknown>>((resolve) => {
1082+
resolveSettingsGet = resolve;
1083+
});
1084+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
1085+
if (op === "settings.get") {
1086+
return await settingsGetPromise;
1087+
}
1088+
return {};
1089+
});
1090+
const store = createConnectedStore(sendCommand);
1091+
1092+
renderSettingsPage(store);
1093+
fireEvent.click(screen.getByRole("button", { name: "外观" }));
1094+
fireEvent.click(await screen.findByRole("switch", { name: "选中自动复制" }));
1095+
1096+
await waitFor(() => {
1097+
expect(sendCommand).toHaveBeenCalledWith(
1098+
"settings.update",
1099+
{
1100+
settings: {
1101+
appearance: {
1102+
terminalCopyOnSelect: true,
1103+
},
1104+
},
1105+
},
1106+
undefined
1107+
);
1108+
});
1109+
1110+
await act(async () => {
1111+
resolveSettingsGet?.({
1112+
"appearance.terminalCopyOnSelect": false,
1113+
});
1114+
await settingsGetPromise;
1115+
});
1116+
1117+
expect(screen.getByRole("switch", { name: "选中自动复制" })).toHaveAttribute(
1118+
"aria-checked",
1119+
"true"
1120+
);
1121+
});
1122+
10251123
it("updates language selection through the shared appearance pills", async () => {
10261124
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
10271125
if (op === "settings.get") {

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import { useViewport } from "../../../hooks/use-viewport";
2525
import { useTranslation } from "../../../lib/i18n";
2626
import { notificationPreferencesAtom } from "../../notifications/atoms";
2727
import { MobilePageHeader } from "../../shared/components/mobile-page-header";
28+
import {
29+
resolveTerminalCopyOnSelectSetting,
30+
terminalPreferencesAtom,
31+
} from "../../terminal-panel/preferences";
2832
import { type ProviderInfo, ProviderSettings } from "./provider-settings";
2933
import { resolveSettingsExitTargetFromBrowserHistory } from "./settings-navigation";
3034
import {
@@ -147,6 +151,7 @@ export function SettingsPage() {
147151
const [terminalRenderer, setTerminalRendererState] = useState<"standard" | "compatibility">(
148152
"standard"
149153
);
154+
const [terminalCopyOnSelect, setTerminalCopyOnSelectState] = useState(false);
150155
const [providerAdditionalArgsById, setProviderAdditionalArgsById] = useState<
151156
Record<string, string>
152157
>({});
@@ -156,10 +161,12 @@ export function SettingsPage() {
156161
const [locale, setLocaleState] = useAtom(localeAtom);
157162
const [theme, setTheme] = useAtom(themeAtom);
158163
const setNotificationPreferences = useSetAtom(notificationPreferencesAtom);
164+
const setTerminalPreferences = useSetAtom(terminalPreferencesAtom);
159165
const settingsLoadFailedUnknownRef = useRef(settingsLoadFailedUnknown);
160166
const appearanceSelectionVersionRef = useRef({
161167
locale: 0,
162168
terminalRenderer: 0,
169+
terminalCopyOnSelect: 0,
163170
});
164171
const detailSection =
165172
navigationState.kind === "detail" ? navigationState.section : navigationState.lastSection;
@@ -233,6 +240,14 @@ export function SettingsPage() {
233240
setTerminalRendererState(settings["appearance.terminalRenderer"]);
234241
}
235242
}
243+
if (typeof settings["appearance.terminalCopyOnSelect"] === "boolean") {
244+
if (
245+
appearanceSelectionVersionRef.current.terminalCopyOnSelect ===
246+
appearanceSelectionVersionAtRequestStart.terminalCopyOnSelect
247+
) {
248+
setTerminalCopyOnSelectState(settings["appearance.terminalCopyOnSelect"]);
249+
}
250+
}
236251
if (settings["appearance.locale"] === "zh" || settings["appearance.locale"] === "en") {
237252
if (
238253
appearanceSelectionVersionRef.current.locale ===
@@ -241,14 +256,24 @@ export function SettingsPage() {
241256
setLocaleState(settings["appearance.locale"]);
242257
}
243258
}
259+
setTerminalPreferences({
260+
copyOnSelect: resolveTerminalCopyOnSelectSetting(settings),
261+
});
244262
setProviderAdditionalArgsById(loadProviderAdditionalArgs(settings, providers));
245263
};
246264

247265
void loadSettings();
248266
return () => {
249267
cancelled = true;
250268
};
251-
}, [connectionStatus, dispatch, setLocaleState, setNotificationPreferences, settingsRefreshKey]);
269+
}, [
270+
connectionStatus,
271+
dispatch,
272+
setLocaleState,
273+
setNotificationPreferences,
274+
setTerminalPreferences,
275+
settingsRefreshKey,
276+
]);
252277

253278
const handleLocaleSelection = (value: "zh" | "en") => {
254279
appearanceSelectionVersionRef.current.locale += 1;
@@ -260,6 +285,12 @@ export function SettingsPage() {
260285
setTerminalRendererState(value);
261286
};
262287

288+
const handleTerminalCopyOnSelectSelection = (value: boolean) => {
289+
appearanceSelectionVersionRef.current.terminalCopyOnSelect += 1;
290+
setTerminalCopyOnSelectState(value);
291+
setTerminalPreferences({ copyOnSelect: value });
292+
};
293+
263294
useEffect(() => {
264295
if (detailSection !== "providers") {
265296
setContentLayoutMode("default");
@@ -307,6 +338,8 @@ export function SettingsPage() {
307338
setLocale={handleLocaleSelection}
308339
terminalRenderer={terminalRenderer}
309340
setTerminalRenderer={handleTerminalRendererSelection}
341+
terminalCopyOnSelect={terminalCopyOnSelect}
342+
setTerminalCopyOnSelect={handleTerminalCopyOnSelectSelection}
310343
theme={theme}
311344
setTheme={setTheme}
312345
/>
@@ -727,6 +760,8 @@ interface AppearanceSettingsProps {
727760
setLocale: (value: "zh" | "en") => void;
728761
terminalRenderer: "standard" | "compatibility";
729762
setTerminalRenderer: (value: "standard" | "compatibility") => void;
763+
terminalCopyOnSelect: boolean;
764+
setTerminalCopyOnSelect: (value: boolean) => void;
730765
theme: "dark" | "light";
731766
setTheme: (value: "dark" | "light") => void;
732767
}
@@ -736,6 +771,8 @@ function AppearanceSettings({
736771
setLocale,
737772
terminalRenderer,
738773
setTerminalRenderer,
774+
terminalCopyOnSelect,
775+
setTerminalCopyOnSelect,
739776
theme,
740777
setTheme,
741778
}: AppearanceSettingsProps) {
@@ -744,6 +781,8 @@ function AppearanceSettings({
744781
const themeDescId = useId();
745782
const terminalRendererTitleId = useId();
746783
const terminalRendererDescId = useId();
784+
const copyOnSelectLabelId = useId();
785+
const copyOnSelectDescId = useId();
747786
const languageTitleId = useId();
748787
const languageDescId = useId();
749788
const dispatch = useAtomValue(dispatchCommandAtom);
@@ -826,6 +865,27 @@ function AppearanceSettings({
826865
{t("settings.terminal_compatibility")}
827866
</Pill>
828867
</div>
868+
869+
<div className="settings-toggle-row">
870+
<div className="settings-toggle-info">
871+
<span className="settings-toggle-label" id={copyOnSelectLabelId}>
872+
{t("settings.copy_on_select")}
873+
</span>
874+
<span className="settings-toggle-desc" id={copyOnSelectDescId}>
875+
{t("settings.copy_on_select_hint")}
876+
</span>
877+
</div>
878+
<Switch
879+
aria-describedby={copyOnSelectDescId}
880+
aria-labelledby={copyOnSelectLabelId}
881+
checked={terminalCopyOnSelect}
882+
className="settings-toggle"
883+
onCheckedChange={(nextValue) => {
884+
setTerminalCopyOnSelect(nextValue);
885+
void saveSettings({ appearance: { terminalCopyOnSelect: nextValue } });
886+
}}
887+
/>
888+
</div>
829889
</div>
830890

831891
<div className="settings-group">

packages/web/src/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@
476476
},
477477
"terminal_renderer": "Terminal Renderer",
478478
"terminal_renderer_hint": "Choose terminal rendering mode",
479+
"copy_on_select": "Copy on select",
480+
"copy_on_select_hint": "Automatically copy selected text to the system clipboard in desktop terminals",
481+
"copy_on_select_failed_title": "Copy on select failed",
482+
"copy_on_select_failed_body": "Use Ctrl/Cmd+C to copy manually",
479483
"terminal_standard": "Standard",
480484
"terminal_compatibility": "Compatibility Mode",
481485
"provider": {

packages/web/src/locales/zh.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@
476476
},
477477
"terminal_renderer": "终端渲染器",
478478
"terminal_renderer_hint": "选择终端渲染模式",
479+
"copy_on_select": "选中自动复制",
480+
"copy_on_select_hint": "在桌面端终端中,选中文本后自动复制到系统剪贴板",
481+
"copy_on_select_failed_title": "自动复制失败",
482+
"copy_on_select_failed_body": "请使用 Ctrl/Cmd+C 手动复制",
479483
"terminal_standard": "标准",
480484
"terminal_compatibility": "兼容模式",
481485
"provider": {

0 commit comments

Comments
 (0)