Files
openidea/src/platform/remoteSettings.test.ts
T
l0ng-ai 3bb994062d fix(remote): keep keymap and UI preferences local in remote windows (#298)
In a remote-development window every settings read went to the server, so the keymap (preset, overrides, custom keymaps) and other UI preferences came from the server's global settings — defaults on a fresh host — and customised shortcuts were lost. Client-scoped settings are now declared in one place (src/platform/clientSettings.ts: keymap, appearance, notifications, editor typography and display, terminal typography and interaction, and UI-only general/console/debugger/run/VCS preferences). In remote windows they are overlaid from the local global settings on every settings response (settings_get, settings_set, project snapshots, connect), and writes to them go to the local backend only. Project and tooling settings still come from the server.

Not verified against a real SSH host (none available here); covered by routing and overlay tests.

Fixes #239
2026-10-30 08:17:33 +08:00

169 lines
6.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CLIENT_SETTINGS_KEYS } from "./clientSettings";
import { commands, call, type Settings } from "./ipc";
import { loadSettings, useProject } from "./project";
import { connectRemote, disableRemote, enableRemote, remoteRouting } from "./remote";
const handlers = vi.hoisted(() => new Map<string, () => void>());
vi.mock("./ipc", async (original) => ({
...(await original<typeof import("./ipc")>()),
onIdeEvent: (event: string, handler: () => void) => {
handlers.set(event, handler);
return () => handlers.delete(event);
},
}));
import { trackRuntimeSettings } from "@/features/settings/runtime";
const local = {
keymap: "my-map",
keymapOverrides: { "editor.copy": ["Meta+J"] },
customKeymaps: {
"my-map": { name: "Mine", parent: "vscode-macos", overrides: { "editor.paste": ["Meta+K"] } },
},
appearance: { theme: "dark" },
editor: { fontSize: 19, tabSize: 8 },
terminal: { fontSize: 18, shellPath: "/local" },
};
const remote = {
keymap: "intellij-linux",
keymapOverrides: {},
customKeymaps: {},
appearance: { theme: "light" },
editor: { fontSize: 13, tabSize: 2 },
terminal: { fontSize: 13, shellPath: "/remote" },
};
const served = new Set(["settings_get", "settings_set", "project_current"]);
let invoke: ReturnType<typeof vi.fn>;
beforeEach(() => {
useProject.setState({ opening: true });
invoke = vi.fn(async (cmd: string, _args?: unknown, _options?: unknown) =>
cmd === "remote_invoke" ? JSON.stringify(remote) : local,
);
Object.assign(window, { __TAURI_INTERNALS__: { invoke, transformCallback: () => 1 } });
enableRemote({ sshConfig: "test", root: "/remote", host: "test", commands: [...served] });
});
afterEach(() => {
disableRemote();
useProject.setState({ opening: false, settings: null });
});
describe("remote client settings", () => {
it.each([...CLIENT_SETTINGS_KEYS, "customKeymaps.mine", "keymapOverrides.editor.copy", "appearance.theme"])(
"keeps %s writes local in either scope",
(key) => {
for (const scope of ["global", "project"])
expect(remoteRouting("settings_set", { scope, key, value: "null" }, served)).toBe("local");
},
);
it.each([
"editor.tabSize",
"editor.formatOnSave",
"editor.codeStyle.languages",
"terminal.shellPath",
"terminal.env",
"tools.node",
"languages.python.interpreter",
"keymapExtra",
])("preserves routing for host setting %s", (key) => {
expect(remoteRouting("settings_set", { scope: "project", key }, served)).toBe("remote");
expect(remoteRouting("settings_set", { scope: "global", key }, served)).toBe("both");
});
it("merges settings before publishing a newly connected project", async () => {
disableRemote();
invoke.mockImplementation(async (cmd: string) =>
cmd === "remote_connect"
? {
sshConfig: "test",
root: "/remote",
host: "test",
commands: [...served],
project: { root: "/remote", name: "remote", settings: remote },
}
: local,
);
await connectRemote("test", "/remote");
expect(useProject.getState().settings).toMatchObject({ keymap: "my-map", editor: { tabSize: 2, fontSize: 19 } });
});
it("overlays the project snapshot used by the settings window", async () => {
invoke.mockImplementation(async (cmd: string) =>
cmd === "remote_invoke" ? JSON.stringify({ root: "/remote", settings: remote }) : local,
);
expect((await commands.projectCurrent())?.settings).toMatchObject({
keymap: "my-map",
editor: { tabSize: 2, fontSize: 19 },
});
});
it("keeps client preferences after a remote tooling write", async () => {
expect(await call(commands.settingsSet("project", "editor.tabSize", "2"))).toMatchObject({
keymap: "my-map",
editor: { fontSize: 19, tabSize: 2 },
});
expect(invoke.mock.calls.some(([cmd]) => cmd === "settings_set")).toBe(false);
});
it("keeps local overrides through a settingsChanged runtime reload", async () => {
vi.useFakeTimers();
await loadSettings();
const stop = trackRuntimeSettings();
try {
handlers.get("settingsChanged")!();
await vi.advanceTimersByTimeAsync(40);
expect(useProject.getState().settings?.keymapOverrides).toEqual(local.keymapOverrides);
expect(useProject.getState().settings?.keymap).toBe("my-map");
} finally {
stop();
vi.useRealTimers();
}
});
it("reloads local preferences instead of retaining stale or remote overrides", async () => {
await loadSettings();
invoke.mockImplementation(async (cmd: string) =>
cmd === "remote_invoke" ? JSON.stringify(remote) : { ...local, keymapOverrides: {} },
);
await loadSettings();
expect(useProject.getState().settings?.keymapOverrides).toEqual({});
});
it("does not inherit remote UI values when a local field is absent", async () => {
invoke.mockImplementation(async (cmd: string) => (cmd === "remote_invoke" ? JSON.stringify(remote) : {}));
await loadSettings();
expect(useProject.getState().settings?.keymap).toBeUndefined();
expect(useProject.getState().settings?.editor?.tabSize).toBe(2);
});
it("leaves local windows unchanged", async () => {
disableRemote();
await loadSettings();
expect(useProject.getState().settings).toEqual(local);
expect(invoke).toHaveBeenCalledTimes(1);
});
it("preserves client keymaps and UI settings when loading remote settings", async () => {
await loadSettings();
const settings = useProject.getState().settings as Settings;
expect(settings).toMatchObject({
...local,
editor: { fontSize: 19, tabSize: 2 },
terminal: { fontSize: 18, shellPath: "/remote" },
});
expect(invoke).toHaveBeenCalledWith("settings_get_global", {}, undefined);
});
it("shows client preferences in the global settings dialog snapshot", async () => {
expect(await commands.settingsGetGlobal()).toMatchObject({
keymap: "my-map",
editor: { fontSize: 19, tabSize: 8 },
});
});
it("writes a client preference only to local global settings and returns merged settings", async () => {
const settings = await call(commands.settingsSet("project", "editor.fontSize", "19"));
expect(invoke).toHaveBeenCalledWith(
"settings_set",
{ scope: "global", key: "editor.fontSize", value: "19" },
undefined,
);
expect(
invoke.mock.calls.filter(
([cmd, args]) => cmd === "remote_invoke" && (args as { cmd?: string })?.cmd === "settings_set",
),
).toHaveLength(0);
expect(settings).toMatchObject({ keymap: "my-map", editor: { fontSize: 19, tabSize: 2 } });
});
});