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
This commit is contained in:
l0ng-ai
2026-10-30 08:17:33 +08:00
committed by GitHub
parent 2ea47873cf
commit 3bb994062d
3 changed files with 303 additions and 9 deletions
+102
View File
@@ -0,0 +1,102 @@
/** Settings model ownership: these UI preferences belong to the client, including their descendants.
* Mixed sections deliberately list individual keys so project/tooling settings stay on the host.
* Keep this module dependency-free: the transport also consumes this metadata.
*/
export const CLIENT_SETTINGS_KEYS = [
"keymap",
"keymapOverrides",
"customKeymaps",
"appearance",
"notifications",
"general.reopenLastProject",
"general.confirmExit",
"general.openProjectIn",
"general.dateFormat",
"editor.fontFamily",
"editor.fontSize",
"editor.lineHeight",
"editor.ligatures",
"editor.fallbackFont",
"editor.fontWeight",
"editor.boldFontWeight",
"editor.colorScheme",
"editor.colorOverrides",
"editor.semanticHighlighting",
"editor.rainbowBrackets",
"editor.softWrap",
"editor.showWhitespace",
"editor.inlayHints",
"editor.stickyScroll",
"editor.appearance",
"editor.breadcrumbs",
"editor.folding",
"editor.tabs",
"editor.codeEditing",
"editor.inlay",
"editor.gutterIconsDisabled",
"editor.showGutterIcons",
"editor.general.wheelZoom",
"editor.general.dragAndDrop",
"editor.general.caretScroll",
"editor.general.softWrapFiles",
"editor.general.softWrapIndent",
"editor.general.scrollPastEnd",
"editor.general.richTextCopy",
"editor.codeStyle.visualGuides",
"terminal.tabName",
"terminal.audibleBell",
"terminal.closeOnExit",
"terminal.mouseReporting",
"terminal.copyOnSelection",
"terminal.pasteOnMiddleClick",
"terminal.overrideIdeShortcuts",
"terminal.highlightHyperlinks",
"terminal.optionAsMeta",
"terminal.escapeToEditor",
"terminal.cursorShape",
"terminal.cursorBlink",
"terminal.useEditorFont",
"terminal.fontFamily",
"terminal.fontSize",
"terminal.lineHeight",
"terminal.fallbackFontFamily",
"terminal.columnWidth",
"console.softWrap",
"console.foldPatterns",
"debugger.showOnBreakpoint",
"debugger.focusAppOnBreakpoint",
"debugger.hideOnTermination",
"debugger.runToCursorInlay",
"debugger.inlineValues",
"debugger.sortValues",
"debugger.valueTooltip",
"debugger.valueTooltipOnSelection",
"debugger.valueTooltipDelay",
"run.confirmRerun",
"run.confirmTerminate",
"run.showOnStart",
"vcs.showDirtyDirectories",
"vcs.git.showPushDialog",
"vcs.git.pushDialogOnlyProtected",
"vcs.git.combineStashAndShelf",
"vcs.commit.nonModal",
"vcs.commit.showRightMargin",
] as const;
export function isClientSetting(key: string): boolean {
return CLIENT_SETTINGS_KEYS.some((root) => key === root || key.startsWith(`${root}.`));
}
/** Overlay from local globals, never from the local window's project layer. Missing client keys
* also replace remote values, so older local settings fall back to client defaults.
*/
export function overlayClientSettings<T>(remote: T, local: unknown): T {
const object = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
const copy = (target: unknown, source: unknown, path: string[]): unknown => {
const [head, ...tail] = path;
const value = object(source)[head!];
return { ...object(target), [head!]: tail.length ? copy(object(target)[head!], value, tail) : value };
};
return CLIENT_SETTINGS_KEYS.reduce((settings, key) => copy(settings, local, key.split(".")) as T, remote);
}
+33 -9
View File
@@ -8,6 +8,8 @@ import { Channel, type InvokeArgs, type InvokeOptions } from "@tauri-apps/api/co
import { invokeLocal, setInvokeRoute, type InvokeRoute } from "./tauriCore";
import { create } from "zustand";
import { call, commands, isTauri, type ChannelFrame, type RemoteProgress, type RemoteSession } from "./ipc";
import { isClientSetting, overlayClientSettings } from "./clientSettings";
import type { Settings } from "./ipc";
import { useProject } from "./project";
interface RemoteState {
@@ -51,25 +53,46 @@ async function remoteInvoke(cmd: string, args?: InvokeArgs, options?: InvokeOpti
/**
* Where a command of a remote window runs. Local always: opening a project (a
* local folder; leaving the remote one) and closing it (that drops the
* connection, which ends the server's session). Both: a global setting, which
* connection, which ends the server's session). Client UI settings stay local.
* Both: a global tooling setting, which
* the server's language servers and the local windows read.
*/
export function remoteRouting(cmd: string, args: InvokeArgs | undefined, served: ReadonlySet<string>) {
if (cmd === "system_font_families") return "local";
if (cmd === "system_font_families" || cmd === "settings_get_global") return "local";
if (cmd === "settings_set" && isClientSetting((args as { key?: string } | undefined)?.key ?? "")) return "local";
if (!served.has(cmd) || cmd === "open_project" || cmd === "close_project") return "local";
if (cmd === "settings_set" && (args as { scope?: string } | undefined)?.scope === "global") return "both";
return "remote";
}
/** All effective settings responses use the same client overlay, including writes and project snapshots. */
async function clientSettings<T extends Settings>(settings: T): Promise<T> {
return overlayClientSettings(settings, await invokeLocal<Settings>("settings_get_global"));
}
const route: InvokeRoute = (cmd, args, options) => {
switch (remoteRouting(cmd, args, routed)) {
case "local":
return null;
case "remote":
return remoteInvoke(cmd, args, options);
case "both":
return invokeLocal(cmd, args, options).then(() => remoteInvoke(cmd, args, options));
const destination = remoteRouting(cmd, args, routed);
if (cmd === "settings_set") {
const setting = args as { scope: string; key: string; value: string };
return (async () => {
if (isClientSetting(setting.key)) {
await invokeLocal(cmd, { ...setting, scope: "global" }, options);
return clientSettings((await remoteInvoke("settings_get")) as Settings);
}
if (destination === "local") return invokeLocal(cmd, args, options);
if (destination === "both") await invokeLocal(cmd, args, options);
return clientSettings((await remoteInvoke(cmd, args, options)) as Settings);
})();
}
if (destination === "local") return null;
return remoteInvoke(cmd, args, options).then(async (result) => {
if (cmd === "settings_get") return clientSettings(result as Settings);
if (cmd === "project_current" && result) {
const project = result as { settings: Settings };
return { ...project, settings: await clientSettings(project.settings) };
}
return result;
});
};
/** Routes the commands the server answers (`session.commands`) to it from now on. */
@@ -115,6 +138,7 @@ export async function connectRemote(sshConfig: string, path: string) {
try {
const r = await call(commands.remoteConnect(sshConfig, path, onProgress));
enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands });
r.project.settings = await clientSettings(r.project.settings);
useProject.setState({ project: r.project, settings: r.project.settings });
rememberRemote({
sshConfig: r.sshConfig,
+168
View File
@@ -0,0 +1,168 @@
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 } });
});
});