Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
useTheme,
} from "../../hooks/useTheme";
import {
useAllEnvironmentsSettings,
useScopedSettings,
useScopedSettingsMixed,
useUpdateScopedSettings,
Expand Down Expand Up @@ -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,
Expand Down
168 changes: 168 additions & 0 deletions apps/web/src/components/settings/SettingsRestore.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
}) => Promise<unknown>
>(),
confirm: vi.fn<() => Promise<boolean>>(),
}));

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<void>((resolve) => {
restored = resolve;
});
function RestoreHarness() {
const { changedSettingLabels, restoreDefaults } = useSettingsRestore(restored);
return (
<>
<p data-testid="labels">{changedSettingLabels.join("|")}</p>
<button type="button" onClick={() => void restoreDefaults()}>
Restore device defaults
</button>
</>
);
}
await using screen = await render(
<RegistryContext.Provider value={appAtomRegistry}>
<SettingsScopeProvider
search={row.search === "project" ? { project: projectKey } : { machine: remoteId }}
onChange={() => {}}
>
<RestoreHarness />
</SettingsScopeProvider>
</RegistryContext.Provider>,
);

await vi.waitFor(() =>
expect(screen.getByTestId("labels").element().textContent).toBe(
"Auto-settle inactive threads|Provider update checks",
),
);
Comment thread
only21mil marked this conversation as resolved.
Resolved
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],
]);
});
});
15 changes: 15 additions & 0 deletions apps/web/src/components/settings/scopedSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ClientSettingsSchema,
DEFAULT_SERVER_SETTINGS,
type ClientSettingsPatch,
type EnvironmentId,
globalIdleCompaction,
Expand Down Expand Up @@ -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<ScopedSettingsTarget, "settings">[],
): 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
Expand Down
30 changes: 29 additions & 1 deletion apps/web/src/components/settings/useScopedSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading