Skip to content

Commit 5d8a23b

Browse files
committed
Merge branch 'feat/terminal-copy-on-select' into develop
2 parents e7199db + 3a1eafe commit 5d8a23b

11 files changed

Lines changed: 931 additions & 8 deletions

File tree

packages/server/src/commands/settings.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,31 @@ describe("settings commands", () => {
6767
).toEqual({ value: "600" });
6868
});
6969

70+
it("settings.update persists appearance.terminalCopyOnSelect into user_settings", async () => {
71+
const result = await dispatch(
72+
{
73+
kind: "command",
74+
id: "settings-update-terminal-copy-on-select",
75+
op: "settings.update",
76+
args: {
77+
settings: {
78+
appearance: {
79+
terminalCopyOnSelect: true,
80+
},
81+
},
82+
},
83+
},
84+
ctx
85+
);
86+
87+
expect(result.ok).toBe(true);
88+
expect(
89+
db
90+
.prepare("SELECT value FROM user_settings WHERE key = ?")
91+
.get("appearance.terminalCopyOnSelect")
92+
).toEqual({ value: "true" });
93+
});
94+
7095
it("settings.update rejects fractional supervisor timeout values", async () => {
7196
const result = await dispatch(
7297
{
@@ -270,6 +295,26 @@ describe("settings commands", () => {
270295
});
271296
});
272297

298+
it("settings.get reads appearance.terminalCopyOnSelect from user_settings", async () => {
299+
db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run(
300+
"appearance.terminalCopyOnSelect",
301+
"true"
302+
);
303+
304+
const result = await dispatch(
305+
{
306+
kind: "command",
307+
id: "settings-get-terminal-copy-on-select",
308+
op: "settings.get",
309+
args: {},
310+
},
311+
ctx
312+
);
313+
314+
expect(result.ok).toBe(true);
315+
expect(result.data?.["appearance.terminalCopyOnSelect"]).toBe(true);
316+
});
317+
273318
it("settings.get normalizes invalid persisted supervisor timeout values", async () => {
274319
db.prepare("INSERT INTO user_settings (key, value) VALUES (?, ?)").run(
275320
"supervisor.evaluationTimeoutSec",

packages/server/src/commands/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const SettingsSchema = z.object({
4848
.object({
4949
theme: z.enum(["dark"]).optional(),
5050
terminalRenderer: z.enum(["standard", "compatibility"]).optional(),
51+
terminalCopyOnSelect: z.boolean().optional(),
5152
locale: z.enum(["zh", "en"]).optional(),
5253
})
5354
.optional(),

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
workspacesAtom,
1111
workspacesLoadStateAtom,
1212
} from "../atoms/workspaces";
13+
import { terminalPreferencesAtom } from "../features/terminal-panel/preferences";
1314
import {
1415
fileTreeStaleAtomFamily,
1516
gitBranchListAtomFamily,
@@ -97,9 +98,11 @@ function seedWorkspaces(
9798
describe("AppProviders lifecycle recovery", () => {
9899
const originalFetch = globalThis.fetch;
99100
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
101+
const originalTerminalPreferences = localStorage.getItem("ui.terminalPreferences");
100102

101103
beforeEach(() => {
102104
resetAppProvidersSingletonsForTests();
105+
localStorage.removeItem("ui.terminalPreferences");
103106
globalThis.fetch = vi.fn().mockResolvedValue({
104107
json: async () => ({ authEnabled: false }),
105108
}) as unknown as typeof fetch;
@@ -133,6 +136,11 @@ describe("AppProviders lifecycle recovery", () => {
133136
resetAppProvidersSingletonsForTests();
134137
globalThis.fetch = originalFetch;
135138
vi.restoreAllMocks();
139+
if (originalTerminalPreferences === null) {
140+
localStorage.removeItem("ui.terminalPreferences");
141+
} else {
142+
localStorage.setItem("ui.terminalPreferences", originalTerminalPreferences);
143+
}
136144
if (originalVisibilityState) {
137145
Object.defineProperty(document, "visibilityState", originalVisibilityState);
138146
} else {
@@ -374,6 +382,131 @@ describe("AppProviders lifecycle recovery", () => {
374382
});
375383
});
376384

385+
it("hydrates terminal copy-on-select preferences from settings.get once connected", async () => {
386+
const store = createStore();
387+
setVisibilityState("visible");
388+
389+
const sendCommand = vi.fn().mockImplementation(async (op: string) => {
390+
if (op === "settings.get") {
391+
return {
392+
"appearance.terminalCopyOnSelect": true,
393+
};
394+
}
395+
396+
return undefined;
397+
});
398+
wsState.client!.sendCommand = sendCommand;
399+
400+
renderProviders(store);
401+
402+
await vi.waitFor(() => {
403+
expect(wsState.client?.connect).toHaveBeenCalled();
404+
});
405+
406+
act(() => {
407+
wsState.client?.statusHandler?.("connected");
408+
});
409+
410+
await vi.waitFor(() => {
411+
expect(store.get(terminalPreferencesAtom)).toEqual({ copyOnSelect: true });
412+
});
413+
414+
expect(sendCommand).toHaveBeenCalledWith("settings.get", {}, undefined);
415+
});
416+
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+
377510
it("marks the session authenticated when /auth/status confirms an existing server session", async () => {
378511
globalThis.fetch = vi.fn().mockResolvedValue({
379512
json: async () => ({ authEnabled: true, authenticated: true }),

packages/web/src/app/providers.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import { activeWorkspaceIdAtom } from "../atoms/workspaces";
3939
import { useSessionNotifications } from "../features/notifications";
4040
import { supervisorCyclesAtom, supervisorsAtom } from "../features/supervisor/atoms";
4141
import { terminalMetaAtomFamily } from "../features/terminal-panel/atoms";
42+
import {
43+
resolveTerminalCopyOnSelectSetting,
44+
terminalPreferencesAtom,
45+
} from "../features/terminal-panel/preferences";
4246
import {
4347
editorRefreshTokenAtomFamily,
4448
fileTreeStaleAtomFamily,
@@ -173,6 +177,7 @@ export function AppProviders({ children }: AppProvidersProps) {
173177
// Supervisor state atoms
174178
const setSupervisors = useSetAtom(supervisorsAtom);
175179
const setSupervisorCycles = useSetAtom(supervisorCyclesAtom);
180+
const setTerminalPreferences = useSetAtom(terminalPreferencesAtom);
176181

177182
// Get Jotai store for writing to atomFamily atoms
178183
const store = useStore();
@@ -197,6 +202,40 @@ export function AppProviders({ children }: AppProvidersProps) {
197202
dispatchRef.current = dispatch;
198203
}, [dispatch]);
199204

205+
useEffect(() => {
206+
if (connectionStatus !== "connected") {
207+
return;
208+
}
209+
210+
let cancelled = false;
211+
let localTerminalPreferencesUpdated = false;
212+
const unsubscribeTerminalPreferences = store.sub(terminalPreferencesAtom, () => {
213+
localTerminalPreferencesUpdated = true;
214+
});
215+
216+
const hydrateTerminalPreferences = async () => {
217+
const result = await dispatch<Record<string, unknown>>("settings.get", {});
218+
if (cancelled || !result.ok || !result.data) {
219+
return;
220+
}
221+
222+
if (localTerminalPreferencesUpdated) {
223+
return;
224+
}
225+
226+
setTerminalPreferences({
227+
copyOnSelect: resolveTerminalCopyOnSelectSetting(result.data),
228+
});
229+
};
230+
231+
void hydrateTerminalPreferences();
232+
233+
return () => {
234+
cancelled = true;
235+
unsubscribeTerminalPreferences();
236+
};
237+
}, [connectionStatus, dispatch, setTerminalPreferences, store]);
238+
200239
useEffect(() => {
201240
activeWorkspaceIdRef.current = activeWorkspaceId;
202241
}, [activeWorkspaceId]);

0 commit comments

Comments
 (0)