diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index 04312fd03142..99aed8c0291d 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -36,6 +36,11 @@ export const SETTINGS_DEVICE_ONLY_PATHS: ReadonlySet = new Set([ "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/settings/connections", ]); +/** Pages saved per environment that ignore the project, so only the environment is a target. */ +export const SETTINGS_ENVIRONMENT_ONLY_PATHS: ReadonlySet = new Set([ + "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/settings/providers", +]); + interface SettingsScopeMenuProps { readonly value: SettingsScopeSearch; readonly groups: readonly SidebarProjectSnapshot[]; @@ -62,6 +67,14 @@ export function SettingsScopeSentence() { environments, onChange: scope.selectScope, }; + if (SETTINGS_ENVIRONMENT_ONLY_PATHS.has(pathname)) { + return ( +

+ Applying settings on + +

+ ); + } return (

{/* Each connective stays with its picker so a wrap never strands "on". */} diff --git a/apps/web/src/components/settings/settingsScope.test.ts b/apps/web/src/components/settings/settingsScope.test.ts index 0199f6eb6cb9..4b09b36042c8 100644 --- a/apps/web/src/components/settings/settingsScope.test.ts +++ b/apps/web/src/components/settings/settingsScope.test.ts @@ -6,6 +6,7 @@ import type { SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; import { resolveSettingsScope, validateSettingsScopeSearch } from "./settingsScope"; +import { selectSingleEnvironmentScope } from "./settingsScopeAxis"; const laptopId = EnvironmentId.make("laptop"); const serverId = EnvironmentId.make("server"); @@ -203,3 +204,35 @@ describe("settings scope resolution", () => { }); }); }); + +describe("single-environment scope", () => { + const connected = environments.map((environment) => ({ + ...environment, + connection: { phase: "connected" as const }, + })); + const select = (search: Parameters[0]) => + selectSingleEnvironmentScope( + search, + resolveSettingsScope(search, groups, environments), + connected, + laptopId, + ); + + it("targets the checkout's environment for a link without a machine", () => { + expect(select({ project: "t3code", checkout: third.physicalProjectKey })).toEqual({ + machine: serverId, + }); + }); + + it("narrows to where the project is registered", () => { + expect(select({ project: "other" })).toEqual({ machine: serverId }); + }); + + it("falls back to the primary environment when the project is gone", () => { + expect(select({ project: "missing" })).toEqual({ machine: laptopId }); + }); + + it("keeps an explicit machine and drops the project", () => { + expect(select({ project: "other", machine: laptopId })).toEqual({ machine: laptopId }); + }); +}); diff --git a/apps/web/src/components/settings/settingsScopeAxis.ts b/apps/web/src/components/settings/settingsScopeAxis.ts index 0c0915c54c88..86e1682ef901 100644 --- a/apps/web/src/components/settings/settingsScopeAxis.ts +++ b/apps/web/src/components/settings/settingsScopeAxis.ts @@ -54,7 +54,11 @@ export function selectProjectAxis(search: SettingsScopeSearch, value: string): S return next; } -/** Provider configuration always belongs to one environment, including project scopes. */ +/** + * Provider configuration always belongs to one environment. A project or + * checkout only picks which one when no machine is chosen and never reaches + * the result, so a stale project cannot leave the page without a target. + */ export function selectSingleEnvironmentScope( search: SettingsScopeSearch, scope: ResolvedSettingsScope, @@ -64,14 +68,14 @@ export function selectSingleEnvironmentScope( }[], primaryEnvironmentId: string | null, ): SettingsScopeSearch { - if (search.machine || scope.kind === "unavailable") return search; - const selectedIds = new Set(scope.environmentIds); - const candidates = environments.filter((environment) => - selectedIds.has(environment.environmentId), - ); + if (search.machine) return { machine: search.machine }; + const selectedIds = scope.kind === "unavailable" ? null : new Set(scope.environmentIds); + const candidates = selectedIds + ? environments.filter((environment) => selectedIds.has(environment.environmentId)) + : environments; const selected = candidates.find((environment) => environment.environmentId === primaryEnvironmentId) ?? candidates.find((environment) => environment.connection.phase === "connected") ?? candidates[0]; - return selected ? { ...search, machine: selected.environmentId } : search; + return selected ? { machine: selected.environmentId } : {}; } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a1e02c12d929..242cb94489b3 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -16,7 +16,10 @@ import { } from "../components/settings/SettingsScopeContext"; import { useEnvironments } from "../state/environments"; import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice"; -import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence"; +import { + SETTINGS_DEVICE_ONLY_PATHS, + SETTINGS_ENVIRONMENT_ONLY_PATHS, +} from "../components/settings/SettingsScopeSentence"; import { SettingsPageContainer } from "../components/settings/settingsLayout"; import { retainSettingsScope, @@ -151,19 +154,22 @@ function SettingsRouteLayout() { const rawSearch = Route.useSearch(); const navigate = Route.useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); + const environmentOnly = SETTINGS_ENVIRONMENT_ONLY_PATHS.has(pathname); return ( { // Send every axis so the retain middleware sees an explicit target // even when the choice is "all", which is the absence of a key. + // Environment-only pages never see the project, so carry it over for + // the next page; a checkout belongs to the previous environment. void navigate({ to: pathname, search: () => ({ - project: next.project, + project: environmentOnly ? rawSearch.project : next.project, machine: next.machine, - checkout: next.checkout, + checkout: environmentOnly ? undefined : next.checkout, }), hash: "", resetScroll: false,