mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user