Open History to see the last commit
1import { resolveEnvironmentMachineKind, type EnvironmentId } from "@t3tools/contracts";
2import { useLocation, useNavigate } from "@tanstack/react-router";
3import { ChevronRightIcon, LayersIcon } from "lucide-react";
4import type { ReactNode } from "react";
5
6import { cn } from "../../lib/utils";
7import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
8import { useEnvironments, type EnvironmentPresentation } from "../../state/environments";
9import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
10import { ProjectFavicon } from "../ProjectFavicon";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import { ITEM_ROW_CLASSNAME } from "./itemRows";
13import { SettingsGroup } from "./SettingsGroup";
14import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
15import { useSettingsScope } from "./SettingsScopeContext";
16import type { SettingsScopeSearch } from "./settingsScope";
17import {
18 projectScopeMenuGroups,
19 selectProjectAxis,
20 settingsScopeEnvironmentAddress,
21 settingsScopeEnvironmentLabels,
22 settingsScopeProjectMachines,
23} from "./settingsScopeAxis";
24
25interface ScopeChoice {
26 label: string;
27 secondary?: string;
28 icon: ReactNode;
29 search: SettingsScopeSearch;
30}
31
32export function SettingsScopeNotice({
33 children,
34 target,
35 targetId,
36 eligibleEnvironmentIds,
37 inline = false,
38}: {
39 children: string;
40 /** Renders inside a section instead of as its own page. */
41 inline?: boolean;
42 target: "environment" | "all" | "project" | "checkout";
43 targetId?: string;
44 eligibleEnvironmentIds?: readonly EnvironmentId[];
45}) {
46 const { selectScope, search, groups } = useSettingsScope();
47 const navigate = useNavigate({ from: "/settings" });
48 const pathname = useLocation({ select: (location) => location.pathname });
49 const { environments } = useEnvironments();
50 const environmentIcon = (environment: EnvironmentPresentation | undefined) =>
51 environment ? (
52 <EnvironmentMachineIcon
53 aria-hidden
54 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
55 className="size-4 shrink-0 text-muted-foreground"
56 />
57 ) : null;
58 const choiceGroups =
59 target === "checkout"
60 ? groups.filter((group) => !search.project || group.projectKey === search.project)
61 : [null];
62 const projectGroups = projectScopeMenuGroups(groups, search);
63 const renderChoices = (group: SidebarProjectSnapshot | null) => {
64 const choices: ScopeChoice[] = group
65 ? group.memberProjects.map((member) => {
66 const environment = environments.find(
67 (entry) => entry.environmentId === member.environmentId,
68 );
69 const { primary, secondary } = environment
70 ? settingsScopeEnvironmentLabels(environment, environments)
71 : {
72 primary: member.environmentLabel ?? "Environment",
73 secondary: member.environmentId,
74 };
75 return {
76 label: primary,
77 secondary: [secondary, member.workspaceRoot].filter(Boolean).join(" · "),
78 icon: environmentIcon(environment),
79 search: {
80 project: group.projectKey,
81 machine: member.environmentId,
82 checkout: member.physicalProjectKey,
83 },
84 };
85 })
86 : target === "project"
87 ? projectGroups.map((group) => ({
88 label: group.displayName,
89 secondary: [
90 ...(group.archived ? ["Archived"] : []),
91 ...(projectGroups.some(
92 (other) =>
93 other.projectKey !== group.projectKey && other.displayName === group.displayName,
94 )
95 ? settingsScopeProjectMachines(group, environments)
96 : []),
97 group.workspaceRoot,
98 ].join(" · "),
99 icon: <ProjectFavicon project={group} className="size-4 shrink-0" />,
100 search: selectProjectAxis(search, group.projectKey),
101 }))
102 : target === "environment"
103 ? environments
104 .filter(
105 (entry) =>
106 eligibleEnvironmentIds === undefined ||
107 eligibleEnvironmentIds.includes(entry.environmentId),
108 )
109 .map((entry) => {
110 const { primary, secondary } = settingsScopeEnvironmentLabels(entry, environments);
111 return {
112 label: primary,
113 secondary: secondary || settingsScopeEnvironmentAddress(entry),
114 icon: environmentIcon(entry),
115 search: { machine: entry.environmentId },
116 };
117 })
118 : [
119 {
120 label: "Open all environments",
121 icon: <LayersIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />,
122 search: {},
123 },
124 ];
125 return choices.map((choice) => (
126 <button
127 key={JSON.stringify(choice.search)}
128 type="button"
129 className={cn(
130 ITEM_ROW_CLASSNAME,
131 "flex w-full min-w-0 cursor-pointer items-center gap-3 text-left outline-none hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
132 )}
133 onClick={() => {
134 if (targetId)
135 void navigate({
136 to: pathname,
137 search: () => ({
138 project: undefined,
139 machine: undefined,
140 checkout: undefined,
141 ...choice.search,
142 }),
143 hash: targetId,
144 });
145 else selectScope(choice.search);
146 }}
147 >
148 {choice.icon}
149 <span className="min-w-0 flex-1">
150 <span className="block truncate text-sm font-medium">{choice.label}</span>
151 {choice.secondary ? (
152 <Tooltip>
153 <TooltipTrigger
154 render={<span className="block truncate text-xs text-muted-foreground" />}
155 >
156 {choice.secondary}
157 </TooltipTrigger>
158 <TooltipPopup>{choice.secondary}</TooltipPopup>
159 </Tooltip>
160 ) : null}
161 </span>
162 <ChevronRightIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
163 </button>
164 ));
165 };
166 const notice = (
167 <div className="space-y-3">
168 <p role="status" className="px-3 text-sm text-muted-foreground sm:px-4">
169 {children}
170 </p>
171 <div className="space-y-4">
172 {choiceGroups.map((group) =>
173 group?.displayName ? (
174 <SettingsSection key={group.projectKey} title={group.displayName}>
175 {renderChoices(group)}
176 </SettingsSection>
177 ) : (
178 <SettingsGroup key={group?.projectKey ?? target}>{renderChoices(group)}</SettingsGroup>
179 ),
180 )}
181 </div>
182 </div>
183 );
184 return inline ? notice : <SettingsPageContainer>{notice}</SettingsPageContainer>;
185}