Skip to content

Commit 3a1eafe

Browse files
committed
fix: address review feedback for terminal copy-on-select
1 parent 9660c49 commit 3a1eafe

6 files changed

Lines changed: 383 additions & 25 deletions

File tree

packages/web/src/app/providers.lifecycle.test.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,99 @@ describe("AppProviders lifecycle recovery", () => {
414414
expect(sendCommand).toHaveBeenCalledWith("settings.get", {}, undefined);
415415
});
416416

417+
it("preserves a newer local terminal copy-on-select update when startup hydration resolves later", async () => {
418+
const store = createStore();
419+
setVisibilityState("visible");
420+
421+
let resolveSettingsGet: ((value: Record<string, unknown>) => void) | undefined;
422+
const settingsGetPromise = new Promise<Record<string, unknown>>((resolve) => {
423+
resolveSettingsGet = resolve;
424+
});
425+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
426+
if (op === "settings.get") {
427+
return await settingsGetPromise;
428+
}
429+
430+
return undefined;
431+
});
432+
wsState.client!.sendCommand = sendCommand;
433+
434+
renderProviders(store);
435+
436+
await vi.waitFor(() => {
437+
expect(wsState.client?.connect).toHaveBeenCalled();
438+
});
439+
440+
act(() => {
441+
wsState.client?.statusHandler?.("connected");
442+
});
443+
444+
await vi.waitFor(() => {
445+
expect(sendCommand).toHaveBeenCalledWith("settings.get", {}, undefined);
446+
});
447+
448+
act(() => {
449+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
450+
});
451+
452+
await act(async () => {
453+
resolveSettingsGet?.({});
454+
await settingsGetPromise;
455+
});
456+
457+
expect(store.get(terminalPreferencesAtom)).toEqual({ copyOnSelect: true });
458+
});
459+
460+
it("preserves an ABA local terminal copy-on-select update when startup hydration resolves later", async () => {
461+
const store = createStore();
462+
setVisibilityState("visible");
463+
464+
act(() => {
465+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
466+
});
467+
468+
let resolveSettingsGet: ((value: Record<string, unknown>) => void) | undefined;
469+
const settingsGetPromise = new Promise<Record<string, unknown>>((resolve) => {
470+
resolveSettingsGet = resolve;
471+
});
472+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
473+
if (op === "settings.get") {
474+
return await settingsGetPromise;
475+
}
476+
477+
return undefined;
478+
});
479+
wsState.client!.sendCommand = sendCommand;
480+
481+
renderProviders(store);
482+
483+
await vi.waitFor(() => {
484+
expect(wsState.client?.connect).toHaveBeenCalled();
485+
});
486+
487+
act(() => {
488+
wsState.client?.statusHandler?.("connected");
489+
});
490+
491+
await vi.waitFor(() => {
492+
expect(sendCommand).toHaveBeenCalledWith("settings.get", {}, undefined);
493+
});
494+
495+
act(() => {
496+
store.set(terminalPreferencesAtom, { copyOnSelect: false });
497+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
498+
});
499+
500+
await act(async () => {
501+
resolveSettingsGet?.({
502+
"appearance.terminalCopyOnSelect": false,
503+
});
504+
await settingsGetPromise;
505+
});
506+
507+
expect(store.get(terminalPreferencesAtom)).toEqual({ copyOnSelect: true });
508+
});
509+
417510
it("marks the session authenticated when /auth/status confirms an existing server session", async () => {
418511
globalThis.fetch = vi.fn().mockResolvedValue({
419512
json: async () => ({ authEnabled: true, authenticated: true }),

packages/web/src/app/providers.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,13 +208,21 @@ export function AppProviders({ children }: AppProvidersProps) {
208208
}
209209

210210
let cancelled = false;
211+
let localTerminalPreferencesUpdated = false;
212+
const unsubscribeTerminalPreferences = store.sub(terminalPreferencesAtom, () => {
213+
localTerminalPreferencesUpdated = true;
214+
});
211215

212216
const hydrateTerminalPreferences = async () => {
213217
const result = await dispatch<Record<string, unknown>>("settings.get", {});
214218
if (cancelled || !result.ok || !result.data) {
215219
return;
216220
}
217221

222+
if (localTerminalPreferencesUpdated) {
223+
return;
224+
}
225+
218226
setTerminalPreferences({
219227
copyOnSelect: resolveTerminalCopyOnSelectSetting(result.data),
220228
});
@@ -224,8 +232,9 @@ export function AppProviders({ children }: AppProvidersProps) {
224232

225233
return () => {
226234
cancelled = true;
235+
unsubscribeTerminalPreferences();
227236
};
228-
}, [connectionStatus, dispatch, setTerminalPreferences]);
237+
}, [connectionStatus, dispatch, setTerminalPreferences, store]);
229238

230239
useEffect(() => {
231240
activeWorkspaceIdRef.current = activeWorkspaceId;

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,44 @@ describe("SettingsPage", () => {
905905
expect(chineseLanguagePill).toHaveAttribute("aria-pressed", "true");
906906
});
907907

908+
it("keeps copy-on-select visible on desktop appearance settings", async () => {
909+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
910+
if (op === "settings.get") {
911+
return {
912+
"appearance.terminalCopyOnSelect": true,
913+
};
914+
}
915+
return {};
916+
});
917+
const store = createConnectedStore(sendCommand);
918+
919+
renderSettingsPage(store);
920+
fireEvent.click(screen.getByRole("button", { name: "外观" }));
921+
922+
expect(await screen.findByRole("switch", { name: "选中自动复制" })).toBeInTheDocument();
923+
});
924+
925+
it("does not show copy-on-select on mobile appearance settings", async () => {
926+
viewportMocks.viewport = "mobile";
927+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
928+
if (op === "settings.get") {
929+
return {
930+
"appearance.terminalCopyOnSelect": true,
931+
};
932+
}
933+
return {};
934+
});
935+
const store = createConnectedStore(sendCommand);
936+
937+
renderSettingsPage(store);
938+
fireEvent.click(screen.getByRole("button", { name: "外观" }));
939+
940+
await screen.findByText("主题");
941+
942+
expect(screen.queryByRole("switch", { name: "选中自动复制" })).not.toBeInTheDocument();
943+
expect(screen.queryByText("选中自动复制")).not.toBeInTheDocument();
944+
});
945+
908946
it("updates theme selection through the shared appearance pills", async () => {
909947
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
910948
if (op === "settings.get") {

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

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ export function SettingsPage() {
331331
case "appearance":
332332
return (
333333
<AppearanceSettings
334+
isMobile={isMobile}
334335
locale={locale}
335336
setLocale={handleLocaleSelection}
336337
terminalRenderer={terminalRenderer}
@@ -753,6 +754,7 @@ function GeneralSettings({
753754
}
754755

755756
interface AppearanceSettingsProps {
757+
isMobile: boolean;
756758
locale: string;
757759
setLocale: (value: "zh" | "en") => void;
758760
terminalRenderer: "standard" | "compatibility";
@@ -764,6 +766,7 @@ interface AppearanceSettingsProps {
764766
}
765767

766768
function AppearanceSettings({
769+
isMobile,
767770
locale,
768771
setLocale,
769772
terminalRenderer,
@@ -863,26 +866,28 @@ function AppearanceSettings({
863866
</Pill>
864867
</div>
865868

866-
<div className="settings-toggle-row">
867-
<div className="settings-toggle-info">
868-
<span className="settings-toggle-label" id={copyOnSelectLabelId}>
869-
{t("settings.copy_on_select")}
870-
</span>
871-
<span className="settings-toggle-desc" id={copyOnSelectDescId}>
872-
{t("settings.copy_on_select_hint")}
873-
</span>
869+
{isMobile ? null : (
870+
<div className="settings-toggle-row">
871+
<div className="settings-toggle-info">
872+
<span className="settings-toggle-label" id={copyOnSelectLabelId}>
873+
{t("settings.copy_on_select")}
874+
</span>
875+
<span className="settings-toggle-desc" id={copyOnSelectDescId}>
876+
{t("settings.copy_on_select_hint")}
877+
</span>
878+
</div>
879+
<Switch
880+
aria-describedby={copyOnSelectDescId}
881+
aria-labelledby={copyOnSelectLabelId}
882+
checked={terminalCopyOnSelect}
883+
className="settings-toggle"
884+
onCheckedChange={(nextValue) => {
885+
setTerminalCopyOnSelect(nextValue);
886+
void saveSettings({ appearance: { terminalCopyOnSelect: nextValue } });
887+
}}
888+
/>
874889
</div>
875-
<Switch
876-
aria-describedby={copyOnSelectDescId}
877-
aria-labelledby={copyOnSelectLabelId}
878-
checked={terminalCopyOnSelect}
879-
className="settings-toggle"
880-
onCheckedChange={(nextValue) => {
881-
setTerminalCopyOnSelect(nextValue);
882-
void saveSettings({ appearance: { terminalCopyOnSelect: nextValue } });
883-
}}
884-
/>
885-
</div>
890+
)}
886891
</div>
887892

888893
<div className="settings-group">

0 commit comments

Comments
 (0)