diff --git a/src/platform/clientSettings.ts b/src/platform/clientSettings.ts new file mode 100644 index 00000000..77f50437 --- /dev/null +++ b/src/platform/clientSettings.ts @@ -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(remote: T, local: unknown): T { + const object = (value: unknown): Record => + value && typeof value === "object" ? (value as Record) : {}; + 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); +} diff --git a/src/platform/remote.ts b/src/platform/remote.ts index 348d030d..7d62446f 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -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) { - 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(settings: T): Promise { + return overlayClientSettings(settings, await invokeLocal("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, diff --git a/src/platform/remoteSettings.test.ts b/src/platform/remoteSettings.test.ts new file mode 100644 index 00000000..1de70988 --- /dev/null +++ b/src/platform/remoteSettings.test.ts @@ -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 void>()); +vi.mock("./ipc", async (original) => ({ + ...(await original()), + 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; + +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 } }); + }); +});