1import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
2import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
3import { useAtomValue } from "@effect/atom-react";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/reactivity";
6import { createContext, type ReactNode, useContext, useMemo } from "react";
7
8import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
9import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFilesQueryState";
10import { useSettingsProjectGroups } from "./useSettingsProjectGroups";
11import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings";
12import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope";
13import { selectSingleEnvironmentScope } from "./settingsScopeAxis";
14
15/**
16 * Each member's decoded t3.json, so file-backed settings show the file as a
17 * layer in the inheritance chain. A member is only present once its read has
18 * settled; the query atom caches per (environment, cwd).
19 */
20function useMemberProjectFiles(scope: ReturnType<typeof resolveSettingsScope>) {
21 const members = scope.kind === "project" || scope.kind === "checkout" ? scope.members : [];
22 return useAtomValue(
23 useMemo(
24 () =>
25 Atom.make((get) => {
26 const files = new Map<string, T3ProjectFile | null>();
27 for (const member of members) {
28 const result = get(
29 getProjectFileQueryAtom(
30 member.environmentId,
31 member.workspaceRoot,
32 T3_PROJECT_FILE_NAME,
33 ),
34 );
35 if (result.waiting) continue;
36 // A pending in-app save overlays the query, like useProjectFileQuery.
37 const data =
38 get(
39 optimisticFileAtom(
40 member.environmentId,
41 member.workspaceRoot,
42 T3_PROJECT_FILE_NAME,
43 ),
44 )?.data ?? Option.getOrNull(AsyncResult.value(result));
45 files.set(
46 member.physicalProjectKey,
47 data === null || data.truncated ? null : parseT3ProjectFile(data.contents),
48 );
49 }
50 return files;
51 }),
52 [members],
53 ),
54 );
55}
56
57function useResolvedSettingsScope(rawSearch: SettingsScopeSearch, singleEnvironment: boolean) {
58 const groups = useSettingsProjectGroups();
59 const { environments: availableEnvironments } = useEnvironments();
60 const primaryEnvironmentId = usePrimaryEnvironmentId();
61 const search = useMemo(
62 () =>
63 singleEnvironment
64 ? selectSingleEnvironmentScope(
65 rawSearch,
66 resolveSettingsScope(rawSearch, groups, availableEnvironments),
67 availableEnvironments,
68 primaryEnvironmentId,
69 )
70 : rawSearch,
71 [availableEnvironments, groups, primaryEnvironmentId, rawSearch, singleEnvironment],
72 );
73 const scope = useMemo(
74 () => resolveSettingsScope(search, groups, availableEnvironments),
75 [availableEnvironments, groups, search],
76 );
77 const projectFiles = useMemberProjectFiles(scope);
78 return useMemo(() => {
79 const selected = selectScopedSettingsEnvironments(
80 scope,
81 availableEnvironments,
82 primaryEnvironmentId,
83 );
84 const targets = resolveScopedSettingsTargets(
85 scope,
86 selected.connectedEnvironments,
87 projectFiles,
88 );
89 // The representative target supplies display values; project scopes
90 // prefer the member on the primary environment, like environments do.
91 const target =
92 targets.find(
93 (candidate) => candidate.environmentId === selected.environment?.environmentId,
94 ) ??
95 targets[0] ??
96 null;
97 return { scope, groups, search, ...selected, targets, target };
98 }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope, search]);
99}
100
101const SettingsScopeContext = createContext<
102 | (ReturnType<typeof useResolvedSettingsScope> & {
103 singleEnvironment: boolean;
104 search: SettingsScopeSearch;
105 selectScope: (next: SettingsScopeSearch) => void;
106 })
107 | null
108>(null);
109
110export function SettingsScopeProvider({
111 search,
112 onChange,
113 children,
114 singleEnvironment = false,
115}: {
116 singleEnvironment?: boolean;
117 search: SettingsScopeSearch;
118 onChange: (next: SettingsScopeSearch) => void;
119 children: ReactNode;
120}) {
121 const resolved = useResolvedSettingsScope(search, singleEnvironment);
122 const value = useMemo(
123 () => ({ ...resolved, singleEnvironment, selectScope: onChange }),
124 [onChange, resolved, singleEnvironment],
125 );
126 return <SettingsScopeContext value={value}>{children}</SettingsScopeContext>;
127}
128
129export function useOptionalSettingsScope() {
130 return useContext(SettingsScopeContext);
131}
132
133export function useSettingsScope() {
134 const scope = useOptionalSettingsScope();
135 if (scope === null) throw new Error("Settings scope must be read inside SettingsScopeProvider.");
136 return scope;
137}