diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 633e18a567b2f..0688ea9b19ab8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -54,6 +54,7 @@ import { useTheme, } from "../../hooks/useTheme"; import { + useAllEnvironmentsSettings, useScopedSettings, useScopedSettingsMixed, useUpdateScopedSettings, @@ -443,8 +444,7 @@ export function useSettingsRestore(onRestored?: () => void) { clearThemeHalves, themeHalves, } = useTheme(); - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); + const { changedSettings: settings, updateSettings } = useAllEnvironmentsSettings(); const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, diff --git a/apps/web/src/components/settings/SettingsRestore.browser.test.tsx b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx new file mode 100644 index 0000000000000..bf5d829d29037 --- /dev/null +++ b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx @@ -0,0 +1,168 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + type ServerSettings, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { AsyncResult } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import type { Project } from "../../types"; + +const state = vi.hoisted(() => ({ + projects: [] as Project[], + environments: [] as unknown[], + updateSettings: + vi.fn< + (request: { + environmentId: unknown; + input: { patch: Record }; + }) => Promise + >(), + confirm: vi.fn<() => Promise>(), +})); + +vi.mock(import("~/state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ environments: state.environments })) as never, + usePrimaryEnvironmentId: () => primaryId, +})); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useProjects: (() => state.projects) as never, +})); +vi.mock(import("~/state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => state.updateSettings) as never, +})); +vi.mock(import("~/state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, +})); +vi.mock(import("~/localApi"), async (original) => ({ + ...(await original()), + readLocalApi: (() => ({ dialogs: { confirm: state.confirm } })) as never, +})); + +import { writeBrowserClientSettings } from "../../clientPersistenceStorage"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import { useSettingsRestore } from "./SettingsPanels"; +import { SettingsScopeProvider } from "./SettingsScopeContext"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); +const customized = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 30, + enableProviderUpdateChecks: !DEFAULT_SERVER_SETTINGS.enableProviderUpdateChecks, +}; + +function setup(primarySettings: ServerSettings) { + vi.clearAllMocks(); + // Browser test files share localStorage, and an earlier file may leave client settings changed. + writeBrowserClientSettings(DEFAULT_CLIENT_SETTINGS); + state.environments = ( + [ + [primaryId, primarySettings], + [remoteId, customized], + ] as const + ).map(([environmentId, settings]) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + settings, + environment: { platform: { machine: "server" }, capabilities: { projectOrganization: true } }, + }, + })); + state.projects = [primaryId, remoteId].map((environmentId) => ({ + id: ProjectId.make(`project-${environmentId}`), + environmentId, + title: "Shared repo", + workspaceRoot: `/work/${environmentId}`, + repositoryIdentity: { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + })); + state.updateSettings.mockResolvedValue(AsyncResult.success(customized)); + state.confirm.mockResolvedValue(true); + return buildSidebarProjectSnapshots({ + projects: state.projects, + settings: DEFAULT_CLIENT_SETTINGS, + primaryEnvironmentId: primaryId, + resolveEnvironmentLabel: (id) => id, + })[0]!.projectKey; +} + +describe("useSettingsRestore", () => { + it.each([ + { name: "a project scope", primarySettings: customized, search: "project" }, + { + name: "a machine scope, changed only on a remote environment", + primarySettings: DEFAULT_SERVER_SETTINGS, + search: "machine", + }, + ] as const)("restores every environment's defaults under $name", async (row) => { + const projectKey = setup(row.primarySettings); + let restored!: () => void; + const done = new Promise((resolve) => { + restored = resolve; + }); + function RestoreHarness() { + const { changedSettingLabels, restoreDefaults } = useSettingsRestore(restored); + return ( + <> +

{changedSettingLabels.join("|")}

+ + + ); + } + await using screen = await render( + + {}} + > + + + , + ); + + await vi.waitFor(() => + expect(screen.getByTestId("labels").element().textContent).toBe( + "Auto-settle inactive threads|Provider update checks", + ), + ); + await userEvent.click(screen.getByRole("button", { name: "Restore device defaults" })); + await done; + + expect( + state.updateSettings.mock.calls.map(([request]) => [ + request.environmentId, + request.input.patch.sidebarAutoSettleAfterDays, + request.input.patch.projectSettingsOverrides, + ]), + ).toEqual([ + [primaryId, DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays, undefined], + [remoteId, DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays, undefined], + ]); + }); +}); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index d0654f8902d8c..be5e060db8c17 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -1,5 +1,6 @@ import { ClientSettingsSchema, + DEFAULT_SERVER_SETTINGS, type ClientSettingsPatch, type EnvironmentId, globalIdleCompaction, @@ -203,6 +204,20 @@ export function scopedSettingsAreMixed( ); } +/** Per key, the first target value that differs from the default, so a change on any target shows. */ +export function changedScopedSettings( + targets: readonly Pick[], +): ServerSettings { + return Object.fromEntries( + Object.entries(DEFAULT_SERVER_SETTINGS).map(([key, value]) => { + const changed = targets.find( + (target) => !Equal.equals(target.settings[key as keyof ServerSettings], value), + ); + return [key, changed ? changed.settings[key as keyof ServerSettings] : value]; + }), + ) as ServerSettings; +} + /** * Servers store the idle compaction mode differently by version, so selected * environments are compared by the mode each resolves to, and "Never" is offered diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index d67e2d3124282..a63545670f15b 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -5,6 +5,7 @@ import { EnvironmentAuthorizationError, } from "@t3tools/contracts"; import { readEnvironmentScope } from "../../state/session"; +import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { @@ -17,23 +18,28 @@ import { import { persistClientSettingsPatch, useMergedSettings } from "../../hooks/useSettings"; import { appAtomRegistry } from "../../rpc/atomRegistry"; -import { readEnvironment } from "../../state/environments"; +import { readEnvironment, useEnvironments } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; import { + applyPendingScopedWrites, + changedScopedSettings, createScopedSettingsWriter, pendingScopedWritesAtom, planProjectOverridesClear, planScopedSettingsClear, planScopedSettingsPatch, + resolveScopedSettingsTargets, scopedSettingsAreMixed, scopedSettingsSource, + selectScopedSettingsEnvironments, settlePendingScopedWrites, type ProjectOverrideEntry, type ScopedSettingsPatch, } from "./scopedSettings"; +import { resolveSettingsScope } from "./settingsScope"; const readSettings = (environmentId: EnvironmentId) => readEnvironment(environmentId)?.serverConfig?.settings ?? null; @@ -143,6 +149,28 @@ export function useUpdateScopedSettings() { ); } +/** Ignores the settings URL scope: writes every connected environment and reads a change on any of them. */ +export function useAllEnvironmentsSettings() { + const { environments } = useEnvironments(); + const scope = resolveSettingsScope({}, [], environments); + const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null); + const pendingWrites = useAtomValue(pendingScopedWritesAtom); + const changedSettings = useMergedSettings( + changedScopedSettings( + resolveScopedSettingsTargets( + scope, + applyPendingScopedWrites(connectedEnvironments, pendingWrites), + ), + ), + ); + const run = useRunScopedPlan(); + return { + changedSettings, + updateSettings: (patch: ScopedSettingsPatch) => + run(environments, (current) => planScopedSettingsPatch(scope, current, patch)), + }; +} + export function useClearScopedSettings() { const context = useOptionalSettingsScope(); const run = useRunScopedPlan();