| author | |
| committer | |
| log | 8b2c0465def151f022e01dd04b1b6ba3859bd714 |
| tree | 6bff0a89416b65e00964fde63714a645b7c45ad6 |
| parent | 2c0e891740f86205784506a974096cf110b64da7 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 764 insertions(+), 201 deletions(-)
apps/web/src/components/settings/ProjectSettingsPanel.tsx+26-45| ... | @@ -68,7 +68,6 @@ import { | ... | @@ -68,7 +68,6 @@ import { |
| 68 | } from "../../providerInstances"; | 68 | } from "../../providerInstances"; |
| 69 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; | 69 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 70 | import { | 70 | import { |
| 71 | buildSidebarProjectSnapshots, | ||
| 72 | type SidebarProjectGroupMember, | 71 | type SidebarProjectGroupMember, |
| 73 | type SidebarProjectSnapshot, | 72 | type SidebarProjectSnapshot, |
| 74 | } from "../../sidebarProjectGrouping"; | 73 | } from "../../sidebarProjectGrouping"; |
| ... | @@ -115,6 +114,7 @@ import { | ... | @@ -115,6 +114,7 @@ import { |
| 115 | ProjectFaviconPickerDialog, | 114 | ProjectFaviconPickerDialog, |
| 116 | } from "./ProjectFaviconPickerDialog"; | 115 | } from "./ProjectFaviconPickerDialog"; |
| 117 | import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; | 116 | import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; |
| 117 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | ||
| 118 | 118 | ||
| 119 | const ProjectIconPickerDialog = lazy(() => | 119 | const ProjectIconPickerDialog = lazy(() => |
| 120 | import("./ProjectIconPickerDialog").then((module) => ({ | 120 | import("./ProjectIconPickerDialog").then((module) => ({ |
| ... | @@ -128,31 +128,6 @@ export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, st | ... | @@ -128,31 +128,6 @@ export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, st |
| 128 | separate: "Keep separate", | 128 | separate: "Keep separate", |
| 129 | }; | 129 | }; |
| 130 | 130 | ||
| 131 | /** Logical project groups for the settings page, sorted by display name. */ | ||
| 132 | export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { | ||
| 133 | const projects = useProjects(); | ||
| 134 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | ||
| 135 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 136 | const { environments } = useEnvironments(); | ||
| 137 | const environmentLabelById = useMemo( | ||
| 138 | () => | ||
| 139 | new Map( | ||
| 140 | environments.map((environment) => [environment.environmentId, environment.label] as const), | ||
| 141 | ), | ||
| 142 | [environments], | ||
| 143 | ); | ||
| 144 | return useMemo( | ||
| 145 | () => | ||
| 146 | buildSidebarProjectSnapshots({ | ||
| 147 | projects, | ||
| 148 | settings: projectGroupingSettings, | ||
| 149 | primaryEnvironmentId, | ||
| 150 | resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, | ||
| 151 | }).sort((a, b) => a.displayName.localeCompare(b.displayName)), | ||
| 152 | [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], | ||
| 153 | ); | ||
| 154 | } | ||
| 155 | |||
| 156 | function memberKey(member: { environmentId: string; id: string }): string { | 131 | function memberKey(member: { environmentId: string; id: string }): string { |
| 157 | return `${member.environmentId}:${member.id}`; | 132 | return `${member.environmentId}:${member.id}`; |
| 158 | } | 133 | } |
| ... | @@ -160,9 +135,11 @@ function memberKey(member: { environmentId: string; id: string }): string { | ... | @@ -160,9 +135,11 @@ function memberKey(member: { environmentId: string; id: string }): string { |
| 160 | export function ProjectSettingsPanel({ | 135 | export function ProjectSettingsPanel({ |
| 161 | projectKey, | 136 | projectKey, |
| 162 | environmentId = null, | 137 | environmentId = null, |
| 138 | checkoutKey = null, | ||
| 163 | }: { | 139 | }: { |
| 164 | projectKey: string; | 140 | projectKey: string; |
| 165 | environmentId?: EnvironmentId | null; | 141 | environmentId?: EnvironmentId | null; |
| 142 | checkoutKey?: string | null; | ||
| 166 | }) { | 143 | }) { |
| 167 | const groups = useSettingsProjectGroups(); | 144 | const groups = useSettingsProjectGroups(); |
| 168 | const navigate = useNavigate(); | 145 | const navigate = useNavigate(); |
| ... | @@ -171,9 +148,11 @@ export function ProjectSettingsPanel({ | ... | @@ -171,9 +148,11 @@ export function ProjectSettingsPanel({ |
| 171 | const members = useMemo( | 148 | const members = useMemo( |
| 172 | () => | 149 | () => |
| 173 | selected?.memberProjects.filter( | 150 | selected?.memberProjects.filter( |
| 174 | (member) => environmentId === null || member.environmentId === environmentId, | 151 | (member) => |
| 152 | (environmentId === null || member.environmentId === environmentId) && | ||
| 153 | (checkoutKey === null || member.physicalProjectKey === checkoutKey), | ||
| 175 | ) ?? [], | 154 | ) ?? [], |
| 176 | [selected, environmentId], | 155 | [selected, environmentId, checkoutKey], |
| 177 | ); | 156 | ); |
| 178 | 157 | ||
| 179 | // Remember the members of the last rendered group so a grouping-rule change | 158 | // Remember the members of the last rendered group so a grouping-rule change |
| ... | @@ -181,6 +160,7 @@ export function ProjectSettingsPanel({ | ... | @@ -181,6 +160,7 @@ export function ProjectSettingsPanel({ |
| 181 | const lastSelectionRef = useRef<{ | 160 | const lastSelectionRef = useRef<{ |
| 182 | key: string; | 161 | key: string; |
| 183 | environmentId: EnvironmentId | null; | 162 | environmentId: EnvironmentId | null; |
| 163 | checkoutKey: string | null; | ||
| 184 | memberKeys: string[]; | 164 | memberKeys: string[]; |
| 185 | } | null>(null); | 165 | } | null>(null); |
| 186 | useEffect(() => { | 166 | useEffect(() => { |
| ... | @@ -188,28 +168,38 @@ export function ProjectSettingsPanel({ | ... | @@ -188,28 +168,38 @@ export function ProjectSettingsPanel({ |
| 188 | lastSelectionRef.current = { | 168 | lastSelectionRef.current = { |
| 189 | key: selected.projectKey, | 169 | key: selected.projectKey, |
| 190 | environmentId, | 170 | environmentId, |
| 171 | checkoutKey, | ||
| 191 | memberKeys: members.map((member) => member.physicalProjectKey), | 172 | memberKeys: members.map((member) => member.physicalProjectKey), |
| 192 | }; | 173 | }; |
| 193 | }, [selected, members, environmentId]); | 174 | }, [selected, members, environmentId, checkoutKey]); |
| 194 | 175 | ||
| 195 | // A grouping-rule change replaces the group key mid-visit; follow the | 176 | // A grouping-rule change replaces the group key mid-visit; follow the |
| 196 | // project to its new key instead of parking on the not-found state. | 177 | // project to its new key instead of parking on the not-found state. |
| 197 | useEffect(() => { | 178 | useEffect(() => { |
| 198 | if (members.length > 0) return; | 179 | if (members.length > 0) return; |
| 199 | const last = lastSelectionRef.current; | 180 | const last = lastSelectionRef.current; |
| 200 | if (last?.key !== projectKey || last.environmentId !== environmentId) return; | 181 | if ( |
| 182 | last?.key !== projectKey || | ||
| 183 | last.environmentId !== environmentId || | ||
| 184 | last.checkoutKey !== checkoutKey | ||
| 185 | ) | ||
| 186 | return; | ||
| 201 | const successor = groups.find((group) => | 187 | const successor = groups.find((group) => |
| 202 | group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), | 188 | group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), |
| 203 | ); | 189 | ); |
| 204 | if (successor) { | 190 | if (successor) { |
| 205 | void navigate({ | 191 | void navigate({ |
| 206 | to: "/settings/projects", | 192 | to: "/settings/projects", |
| 207 | search: { project: successor.projectKey, machine: environmentId ?? undefined }, | 193 | search: { |
| 194 | project: successor.projectKey, | ||
| 195 | machine: environmentId ?? undefined, | ||
| 196 | checkout: checkoutKey ?? undefined, | ||
| 197 | }, | ||
| 208 | replace: true, | 198 | replace: true, |
| 209 | hashScrollIntoView: false, | 199 | hashScrollIntoView: false, |
| 210 | }); | 200 | }); |
| 211 | } | 201 | } |
| 212 | }, [groups, navigate, projectKey, members.length, environmentId]); | 202 | }, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]); |
| 213 | 203 | ||
| 214 | if (!selected) { | 204 | if (!selected) { |
| 215 | return ( | 205 | return ( |
| ... | @@ -223,7 +213,7 @@ export function ProjectSettingsPanel({ | ... | @@ -223,7 +213,7 @@ export function ProjectSettingsPanel({ |
| 223 | if (members.length === 0) | 213 | if (members.length === 0) |
| 224 | return ( | 214 | return ( |
| 225 | <p className="p-8 text-sm text-muted-foreground"> | 215 | <p className="p-8 text-sm text-muted-foreground"> |
| 226 | This project has no checkout on this machine. | 216 | This checkout is no longer available in the selected project and environment. |
| 227 | </p> | 217 | </p> |
| 228 | ); | 218 | ); |
| 229 | const scopedGroup = { | 219 | const scopedGroup = { |
| ... | @@ -234,7 +224,7 @@ export function ProjectSettingsPanel({ | ... | @@ -234,7 +224,7 @@ export function ProjectSettingsPanel({ |
| 234 | }; | 224 | }; |
| 235 | return ( | 225 | return ( |
| 236 | <ProjectDetail | 226 | <ProjectDetail |
| 237 | key={`${selected.projectKey}:${environmentId ?? "all"}`} | 227 | key={`${selected.projectKey}:${environmentId ?? "all"}:${checkoutKey ?? "all"}`} |
| 238 | group={scopedGroup} | 228 | group={scopedGroup} |
| 239 | hasOtherMembers={members.length < selected.memberProjects.length} | 229 | hasOtherMembers={members.length < selected.memberProjects.length} |
| 240 | /> | 230 | /> |
| ... | @@ -879,23 +869,14 @@ function ProjectDetail({ | ... | @@ -879,23 +869,14 @@ function ProjectDetail({ |
| 879 | draftStore.clearProjectDraftThreadId(projectRef); | 869 | draftStore.clearProjectDraftThreadId(projectRef); |
| 880 | } | 870 | } |
| 881 | 871 | ||
| 882 | if (isWholeGroup) { | 872 | if (isWholeGroup && !hasOtherMembers) { |
| 883 | if (hasOtherMembers) { | 873 | void navigate({ to: "/", replace: true }); |
| 884 | void navigate({ | ||
| 885 | to: "/settings/projects", | ||
| 886 | search: { project: group.projectKey, machine: undefined }, | ||
| 887 | replace: true, | ||
| 888 | }); | ||
| 889 | } else { | ||
| 890 | void navigate({ to: "/", replace: true }); | ||
| 891 | } | ||
| 892 | } | 874 | } |
| 893 | }, | 875 | }, |
| 894 | [ | 876 | [ |
| 895 | deleteProject, | 877 | deleteProject, |
| 896 | group.displayName, | 878 | group.displayName, |
| 897 | group.memberProjects.length, | 879 | group.memberProjects.length, |
| 898 | group.projectKey, | ||
| 899 | hasOtherMembers, | 880 | hasOtherMembers, |
| 900 | navigate, | 881 | navigate, |
| 901 | reportFailure, | 882 | reportFailure, |
apps/web/src/components/settings/ProjectsSettings.tsx+32-147| ... | @@ -1,168 +1,53 @@ | ... | @@ -1,168 +1,53 @@ |
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | ||
| 2 | import { ChevronDownIcon, FolderIcon } from "lucide-react"; | ||
| 3 | import { type ReactNode, useState } from "react"; | ||
| 4 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | ||
| 5 | import { ProjectFavicon } from "../ProjectFavicon"; | ||
| 6 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; | 1 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 7 | import { useEnvironments } from "../../state/environments"; | 2 | import { useEnvironments } from "../../state/environments"; |
| 8 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | 3 | import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; |
| 9 | import { | ||
| 10 | Combobox, | ||
| 11 | ComboboxEmpty, | ||
| 12 | ComboboxSearchInput, | ||
| 13 | ComboboxItem, | ||
| 14 | ComboboxList, | ||
| 15 | ComboboxPopup, | ||
| 16 | ComboboxTrigger, | ||
| 17 | } from "../ui/combobox"; | ||
| 18 | import { selectTriggerVariants } from "../ui/select"; | ||
| 19 | import { cn } from "../../lib/utils"; | ||
| 20 | import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel"; | ||
| 21 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; | 4 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; |
| 22 | 5 | import { SettingsScopeSelects } from "./SettingsScopeSelects"; | |
| 23 | function ScopePicker({ | 6 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; |
| 24 | label, | 7 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; |
| 25 | value, | ||
| 26 | options, | ||
| 27 | onChange, | ||
| 28 | }: { | ||
| 29 | label: "project" | "machine"; | ||
| 30 | value: string | null; | ||
| 31 | options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>; | ||
| 32 | onChange: (value: string | null) => void; | ||
| 33 | }) { | ||
| 34 | const [query, setQuery] = useState(""); | ||
| 35 | const selected = options.find((option) => option.value === value); | ||
| 36 | const allIcon = | ||
| 37 | label === "project" ? <FolderIcon aria-hidden className="size-3.5 shrink-0" /> : null; | ||
| 38 | const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options]; | ||
| 39 | return ( | ||
| 40 | <Combobox | ||
| 41 | items={items} | ||
| 42 | value={items.find((item) => item.value === (value ?? "all")) ?? null} | ||
| 43 | inputValue={query} | ||
| 44 | onInputValueChange={setQuery} | ||
| 45 | onOpenChange={() => setQuery("")} | ||
| 46 | onValueChange={(next) => { | ||
| 47 | if (next) onChange(next.value === "all" ? null : next.value); | ||
| 48 | }} | ||
| 49 | > | ||
| 50 | <ComboboxTrigger | ||
| 51 | aria-label={`${label === "project" ? "Project" : "Machine"} scope`} | ||
| 52 | className={cn(selectTriggerVariants({ size: "compact" }), "w-auto min-w-0 max-w-52")} | ||
| 53 | > | ||
| 54 | <span className="flex min-w-0 items-center gap-1.5"> | ||
| 55 | {value === null ? allIcon : selected?.icon} | ||
| 56 | <span className="truncate"> | ||
| 57 | {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} | ||
| 58 | </span> | ||
| 59 | </span> | ||
| 60 | <ChevronDownIcon aria-hidden className="-me-1 size-3 opacity-50" /> | ||
| 61 | </ComboboxTrigger> | ||
| 62 | <ComboboxPopup align="start" className="w-64"> | ||
| 63 | <ComboboxSearchInput aria-label={`Search ${label}s`} placeholder={`Search ${label}s...`} /> | ||
| 64 | <ComboboxEmpty>No matching {label}s.</ComboboxEmpty> | ||
| 65 | <ComboboxList> | ||
| 66 | {(item: (typeof items)[number]) => ( | ||
| 67 | <ComboboxItem | ||
| 68 | key={item.value} | ||
| 69 | value={item} | ||
| 70 | contentClassName="flex min-w-0 items-center gap-2" | ||
| 71 | > | ||
| 72 | {item.icon} | ||
| 73 | <span className="truncate">{item.label}</span> | ||
| 74 | </ComboboxItem> | ||
| 75 | )} | ||
| 76 | </ComboboxList> | ||
| 77 | </ComboboxPopup> | ||
| 78 | </Combobox> | ||
| 79 | ); | ||
| 80 | } | ||
| 81 | 8 | ||
| 82 | export function ProjectsSettings({ | 9 | export function ProjectsSettings({ |
| 83 | projectKey, | 10 | value, |
| 84 | machineId, | ||
| 85 | onScopeChange, | 11 | onScopeChange, |
| 86 | }: { | 12 | }: { |
| 87 | projectKey: string | null; | 13 | value: SettingsScopeSearch; |
| 88 | machineId: string | null; | 14 | onScopeChange: (scope: SettingsScopeSearch) => void; |
| 89 | onScopeChange: (project: string | null, machine: string | null) => void; | ||
| 90 | }) { | 15 | }) { |
| 91 | const groups = useSettingsProjectGroups(); | 16 | const groups = useSettingsProjectGroups(); |
| 92 | const { environments } = useEnvironments(); | 17 | const { environments } = useEnvironments(); |
| 93 | const machine = environments.find((environment) => environment.environmentId === machineId); | 18 | const scope = resolveSettingsScope(value, groups, environments); |
| 94 | const machineOptions = environments.map((environment) => ({ | 19 | // The panel follows remembered members when grouping replaces a project key. |
| 95 | value: environment.environmentId, | 20 | const projectScope = |
| 96 | label: environment.label, | 21 | scope.kind === "project" || |
| 97 | icon: ( | 22 | scope.kind === "checkout" || |
| 98 | <EnvironmentMachineIcon | 23 | (scope.kind === "unavailable" && |
| 99 | aria-hidden | 24 | (scope.reason === "project-missing" || scope.reason === "checkout-missing")); |
| 100 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | ||
| 101 | className="size-3.5 shrink-0" | ||
| 102 | /> | ||
| 103 | ), | ||
| 104 | })); | ||
| 105 | return ( | 25 | return ( |
| 106 | <div className="flex min-h-0 flex-1 flex-col"> | 26 | <div className="flex min-h-0 flex-1 flex-col"> |
| 107 | <div className="scrollbar-gutter-both shrink-0 overflow-y-auto"> | 27 | <div className="scrollbar-gutter-both shrink-0 overflow-y-auto"> |
| 108 | <WorkspacePageContainer className="pb-0"> | 28 | <WorkspacePageContainer className="items-end pb-0"> |
| 109 | <div | 29 | <SettingsScopeSelects |
| 110 | className="flex flex-wrap items-center gap-2" | 30 | value={value} |
| 111 | role="group" | 31 | groups={groups} |
| 112 | aria-label="Settings scope" | 32 | environments={environments} |
| 113 | > | 33 | onChange={onScopeChange} |
| 114 | {environments.length > 3 ? ( | 34 | /> |
| 115 | <ScopePicker | ||
| 116 | label="machine" | ||
| 117 | value={machineId} | ||
| 118 | options={machineOptions} | ||
| 119 | onChange={(value) => onScopeChange(projectKey, value)} | ||
| 120 | /> | ||
| 121 | ) : ( | ||
| 122 | <ToggleGroup | ||
| 123 | aria-label="Machine scope" | ||
| 124 | variant="segmented" | ||
| 125 | className="max-w-full flex-wrap" | ||
| 126 | value={[machineId ?? "all"]} | ||
| 127 | onValueChange={(next) => { | ||
| 128 | const value = next[0]; | ||
| 129 | if (value) onScopeChange(projectKey, value === "all" ? null : value); | ||
| 130 | }} | ||
| 131 | > | ||
| 132 | <Toggle value="all">All machines</Toggle> | ||
| 133 | {machineOptions.map((option) => ( | ||
| 134 | <Toggle key={option.value} value={option.value} title={option.label}> | ||
| 135 | {option.icon} | ||
| 136 | <span className="max-w-36 truncate">{option.label}</span> | ||
| 137 | </Toggle> | ||
| 138 | ))} | ||
| 139 | </ToggleGroup> | ||
| 140 | )} | ||
| 141 | <div className="ms-auto"> | ||
| 142 | <ScopePicker | ||
| 143 | label="project" | ||
| 144 | value={projectKey} | ||
| 145 | options={groups.map((group) => ({ | ||
| 146 | value: group.projectKey, | ||
| 147 | label: group.displayName, | ||
| 148 | icon: <ProjectFavicon project={group} className="size-3.5" />, | ||
| 149 | }))} | ||
| 150 | onChange={(value) => onScopeChange(value, machineId)} | ||
| 151 | /> | ||
| 152 | </div> | ||
| 153 | </div> | ||
| 154 | </WorkspacePageContainer> | 35 | </WorkspacePageContainer> |
| 155 | </div> | 36 | </div> |
| 156 | {machineId !== null && !machine ? ( | 37 | {value.project && projectScope ? ( |
| 157 | <p className="p-8 text-sm text-muted-foreground">This machine is no longer available.</p> | ||
| 158 | ) : projectKey === null ? ( | ||
| 159 | <ProjectDefaultsSettings environmentId={machine?.environmentId ?? null} /> | ||
| 160 | ) : ( | ||
| 161 | <ProjectSettingsPanel | 38 | <ProjectSettingsPanel |
| 162 | projectKey={projectKey} | 39 | projectKey={value.project} |
| 163 | environmentId={machine?.environmentId ?? null} | 40 | environmentId={value.machine ? EnvironmentId.make(value.machine) : null} |
| 41 | checkoutKey={value.checkout ?? null} | ||
| 42 | /> | ||
| 43 | ) : scope.kind === "unavailable" ? ( | ||
| 44 | <p className="p-8 text-sm text-muted-foreground">{scope.message}</p> | ||
| 45 | ) : ( | ||
| 46 | <ProjectDefaultsSettings | ||
| 47 | environmentId={scope.kind === "environment" ? scope.environmentId : null} | ||
| 164 | /> | 48 | /> |
| 165 | )} | 49 | )} |
| 166 | </div> | 50 | </div> |
| 167 | ); | 51 | ); |
| 168 | } | 52 | } |
| 53 | import { EnvironmentId } from "@t3tools/contracts"; |
apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | environmentAxisValue, | ||
| 6 | projectAxisValue, | ||
| 7 | selectEnvironmentAxis, | ||
| 8 | selectProjectAxis, | ||
| 9 | settingsScopeEnvironmentLabel, | ||
| 10 | } from "./SettingsScopeSelects.logic"; | ||
| 11 | |||
| 12 | const first = { | ||
| 13 | environmentId: EnvironmentId.make("first"), | ||
| 14 | label: "Development", | ||
| 15 | displayUrl: "https://first.example.com", | ||
| 16 | }; | ||
| 17 | const second = { | ||
| 18 | environmentId: EnvironmentId.make("second"), | ||
| 19 | label: "Development", | ||
| 20 | displayUrl: "https://second.example.com", | ||
| 21 | }; | ||
| 22 | |||
| 23 | describe("settings scope environment labels", () => { | ||
| 24 | it("distinguishes same-name environments by address", () => { | ||
| 25 | const environments = [first, second]; | ||
| 26 | expect( | ||
| 27 | environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), | ||
| 28 | ).toEqual([ | ||
| 29 | "Development · https://first.example.com", | ||
| 30 | "Development · https://second.example.com", | ||
| 31 | ]); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("falls back to environment IDs when duplicate names have no display URL", () => { | ||
| 35 | const environments = [first, second].map((environment) => ({ | ||
| 36 | ...environment, | ||
| 37 | displayUrl: null, | ||
| 38 | })); | ||
| 39 | expect( | ||
| 40 | environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), | ||
| 41 | ).toEqual(["Development · first", "Development · second"]); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("keeps unique names compact and removes disambiguation after a rename", () => { | ||
| 45 | expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development"); | ||
| 46 | expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe( | ||
| 47 | "Development", | ||
| 48 | ); | ||
| 49 | }); | ||
| 50 | }); | ||
| 51 | |||
| 52 | describe("settings scope axes", () => { | ||
| 53 | it("maps each axis to its search key and back", () => { | ||
| 54 | expect(projectAxisValue({})).toBe("all"); | ||
| 55 | expect(projectAxisValue({ project: "app" })).toBe("app"); | ||
| 56 | expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({ | ||
| 57 | project: "app", | ||
| 58 | machine: "second", | ||
| 59 | }); | ||
| 60 | expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({ | ||
| 61 | machine: "second", | ||
| 62 | }); | ||
| 63 | expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({ | ||
| 64 | project: "app", | ||
| 65 | machine: "first", | ||
| 66 | }); | ||
| 67 | expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({ | ||
| 68 | project: "app", | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("drops a checkout narrowing from older links when either axis changes", () => { | ||
| 73 | const checkout = { project: "app", checkout: "app@first", machine: "first" }; | ||
| 74 | expect(selectEnvironmentAxis(checkout, "second")).toEqual({ | ||
| 75 | project: "app", | ||
| 76 | machine: "second", | ||
| 77 | }); | ||
| 78 | expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" }); | ||
| 79 | }); | ||
| 80 | }); | ||
| 81 | |||
| 82 | describe("environmentAxisValue", () => { | ||
| 83 | it("shows the checkout's environment for a legacy checkout link", () => { | ||
| 84 | expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop"); | ||
| 85 | expect(environmentAxisValue({ project: "p" }, null)).toBe("all"); | ||
| 86 | expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); | ||
| 87 | }); | ||
| 88 | }); | ||
apps/web/src/components/settings/SettingsScopeSelects.logic.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 2 | import type { SettingsScopeSearch } from "./settingsScope"; | ||
| 3 | |||
| 4 | type ScopeEnvironment = Pick<EnvironmentPresentation, "environmentId" | "label" | "displayUrl">; | ||
| 5 | |||
| 6 | export function settingsScopeEnvironmentLabel( | ||
| 7 | environment: ScopeEnvironment, | ||
| 8 | environments: readonly ScopeEnvironment[], | ||
| 9 | ) { | ||
| 10 | const duplicate = environments.some( | ||
| 11 | (other) => | ||
| 12 | other.environmentId !== environment.environmentId && other.label === environment.label, | ||
| 13 | ); | ||
| 14 | return duplicate | ||
| 15 | ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}` | ||
| 16 | : environment.label; | ||
| 17 | } | ||
| 18 | |||
| 19 | export const ALL_ENVIRONMENTS_VALUE = "all"; | ||
| 20 | export const ALL_PROJECTS_VALUE = "all"; | ||
| 21 | |||
| 22 | /** | ||
| 23 | * The environment axis: `all` or an environment id. A legacy checkout link | ||
| 24 | * without `machine` still names one environment, which the resolver supplies. | ||
| 25 | */ | ||
| 26 | export function environmentAxisValue( | ||
| 27 | search: SettingsScopeSearch, | ||
| 28 | resolvedEnvironmentId?: string | null, | ||
| 29 | ): string { | ||
| 30 | return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE; | ||
| 31 | } | ||
| 32 | |||
| 33 | /** The project axis: `all` or a project key. */ | ||
| 34 | export function projectAxisValue(search: SettingsScopeSearch): string { | ||
| 35 | return search.project ?? ALL_PROJECTS_VALUE; | ||
| 36 | } | ||
| 37 | |||
| 38 | /** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */ | ||
| 39 | export function selectEnvironmentAxis( | ||
| 40 | search: SettingsScopeSearch, | ||
| 41 | value: string, | ||
| 42 | ): SettingsScopeSearch { | ||
| 43 | const next: SettingsScopeSearch = {}; | ||
| 44 | if (search.project) next.project = search.project; | ||
| 45 | if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value; | ||
| 46 | return next; | ||
| 47 | } | ||
| 48 | |||
| 49 | /** Choosing a project keeps the environment axis. */ | ||
| 50 | export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch { | ||
| 51 | const next: SettingsScopeSearch = {}; | ||
| 52 | if (value !== ALL_PROJECTS_VALUE) next.project = value; | ||
| 53 | if (search.machine) next.machine = search.machine; | ||
| 54 | return next; | ||
| 55 | } | ||
apps/web/src/components/settings/SettingsScopeSelects.tsx created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; | ||
| 2 | import { LayersIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | ||
| 5 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 6 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; | ||
| 7 | import { ProjectFavicon } from "../ProjectFavicon"; | ||
| 8 | import { | ||
| 9 | Select, | ||
| 10 | SelectGroup, | ||
| 11 | SelectGroupLabel, | ||
| 12 | SelectItem, | ||
| 13 | SelectPopup, | ||
| 14 | SelectTrigger, | ||
| 15 | SelectValue, | ||
| 16 | } from "../ui/select"; | ||
| 17 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | ||
| 18 | import { | ||
| 19 | ALL_ENVIRONMENTS_VALUE, | ||
| 20 | ALL_PROJECTS_VALUE, | ||
| 21 | environmentAxisValue, | ||
| 22 | projectAxisValue, | ||
| 23 | selectEnvironmentAxis, | ||
| 24 | selectProjectAxis, | ||
| 25 | settingsScopeEnvironmentLabel, | ||
| 26 | } from "./SettingsScopeSelects.logic"; | ||
| 27 | |||
| 28 | const TRIGGER_CLASSNAME = "w-auto min-w-0 max-w-56 justify-start"; | ||
| 29 | |||
| 30 | /** | ||
| 31 | * Two independent targets: which environments a change applies to, and | ||
| 32 | * which project it overrides. A project is the same project on every | ||
| 33 | * environment; the environment select alone decides where the override is | ||
| 34 | * written. Each axis defaults to "all". | ||
| 35 | */ | ||
| 36 | export function SettingsScopeSelects({ | ||
| 37 | value, | ||
| 38 | groups, | ||
| 39 | environments, | ||
| 40 | onChange, | ||
| 41 | }: { | ||
| 42 | value: SettingsScopeSearch; | ||
| 43 | groups: readonly SidebarProjectSnapshot[]; | ||
| 44 | environments: readonly EnvironmentPresentation[]; | ||
| 45 | onChange: (next: SettingsScopeSearch) => void; | ||
| 46 | }) { | ||
| 47 | const resolved = resolveSettingsScope(value, groups, environments); | ||
| 48 | const environmentValue = environmentAxisValue( | ||
| 49 | value, | ||
| 50 | resolved.kind === "checkout" ? resolved.environmentId : null, | ||
| 51 | ); | ||
| 52 | const projectValue = projectAxisValue(value); | ||
| 53 | const selectedEnvironment = environments.find( | ||
| 54 | (environment) => environment.environmentId === environmentValue, | ||
| 55 | ); | ||
| 56 | const selectedGroup = groups.find((group) => group.projectKey === value.project); | ||
| 57 | |||
| 58 | return ( | ||
| 59 | <div className="flex min-w-0 items-center gap-1.5" role="group" aria-label="Settings scope"> | ||
| 60 | <Select | ||
| 61 | value={environmentValue} | ||
| 62 | onValueChange={(next) => { | ||
| 63 | if (typeof next === "string") onChange(selectEnvironmentAxis(value, next)); | ||
| 64 | }} | ||
| 65 | > | ||
| 66 | <SelectTrigger size="compact" aria-label="Environment scope" className={TRIGGER_CLASSNAME}> | ||
| 67 | <SelectValue> | ||
| 68 | <span className="flex min-w-0 items-center gap-1.5"> | ||
| 69 | {selectedEnvironment ? ( | ||
| 70 | <EnvironmentMachineIcon | ||
| 71 | aria-hidden | ||
| 72 | kind={resolveEnvironmentMachineKind(selectedEnvironment.serverConfig)} | ||
| 73 | className="size-3.5" | ||
| 74 | /> | ||
| 75 | ) : ( | ||
| 76 | <LayersIcon aria-hidden className="size-3.5" /> | ||
| 77 | )} | ||
| 78 | <span className="truncate"> | ||
| 79 | {selectedEnvironment | ||
| 80 | ? settingsScopeEnvironmentLabel(selectedEnvironment, environments) | ||
| 81 | : environmentValue !== ALL_ENVIRONMENTS_VALUE | ||
| 82 | ? "Unavailable environment" | ||
| 83 | : "All environments"} | ||
| 84 | </span> | ||
| 85 | </span> | ||
| 86 | </SelectValue> | ||
| 87 | </SelectTrigger> | ||
| 88 | <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56"> | ||
| 89 | <SelectItem value={ALL_ENVIRONMENTS_VALUE}> | ||
| 90 | <span className="flex items-center gap-2"> | ||
| 91 | <LayersIcon aria-hidden className="size-3.5" /> | ||
| 92 | All environments | ||
| 93 | </span> | ||
| 94 | </SelectItem> | ||
| 95 | <SelectGroup> | ||
| 96 | <SelectGroupLabel>Environments</SelectGroupLabel> | ||
| 97 | {environments.map((environment) => ( | ||
| 98 | <SelectItem key={environment.environmentId} value={environment.environmentId}> | ||
| 99 | <span className="flex w-full min-w-0 items-center gap-2"> | ||
| 100 | <EnvironmentMachineIcon | ||
| 101 | aria-hidden | ||
| 102 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | ||
| 103 | className="size-3.5" | ||
| 104 | /> | ||
| 105 | <span className="min-w-0 flex-1 truncate"> | ||
| 106 | {settingsScopeEnvironmentLabel(environment, environments)} | ||
| 107 | </span> | ||
| 108 | {environment.connection.phase === "connected" ? null : ( | ||
| 109 | <span className="text-xs text-muted-foreground">Offline</span> | ||
| 110 | )} | ||
| 111 | </span> | ||
| 112 | </SelectItem> | ||
| 113 | ))} | ||
| 114 | </SelectGroup> | ||
| 115 | </SelectPopup> | ||
| 116 | </Select> | ||
| 117 | |||
| 118 | <Select | ||
| 119 | value={projectValue} | ||
| 120 | onValueChange={(next) => { | ||
| 121 | if (typeof next === "string") onChange(selectProjectAxis(value, next)); | ||
| 122 | }} | ||
| 123 | > | ||
| 124 | <SelectTrigger size="compact" aria-label="Project scope" className={TRIGGER_CLASSNAME}> | ||
| 125 | <SelectValue> | ||
| 126 | <span className="flex min-w-0 items-center gap-1.5"> | ||
| 127 | {selectedGroup ? ( | ||
| 128 | <ProjectFavicon project={selectedGroup} className="size-3.5" /> | ||
| 129 | ) : null} | ||
| 130 | <span className="truncate"> | ||
| 131 | {selectedGroup?.displayName ?? | ||
| 132 | (value.project ? "Unavailable project" : "All projects")} | ||
| 133 | </span> | ||
| 134 | </span> | ||
| 135 | </SelectValue> | ||
| 136 | </SelectTrigger> | ||
| 137 | <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-56"> | ||
| 138 | <SelectItem value={ALL_PROJECTS_VALUE}>All projects</SelectItem> | ||
| 139 | {groups.map((group) => ( | ||
| 140 | <SelectItem key={group.projectKey} value={group.projectKey}> | ||
| 141 | <span className="flex w-full min-w-0 items-center gap-2"> | ||
| 142 | <ProjectFavicon project={group} className="size-3.5" /> | ||
| 143 | <span className="min-w-0 flex-1 truncate">{group.displayName}</span> | ||
| 144 | </span> | ||
| 145 | </SelectItem> | ||
| 146 | ))} | ||
| 147 | </SelectPopup> | ||
| 148 | </Select> | ||
| 149 | {resolved.kind === "unavailable" ? ( | ||
| 150 | <span className="truncate text-xs text-muted-foreground">{resolved.message}</span> | ||
| 151 | ) : null} | ||
| 152 | </div> | ||
| 153 | ); | ||
| 154 | } | ||
apps/web/src/components/settings/settingsScope.test.ts created+216| ... | @@ -0,0 +1,216 @@ | ||
| 1 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import type { | ||
| 5 | SidebarProjectGroupMember, | ||
| 6 | SidebarProjectSnapshot, | ||
| 7 | } from "../../sidebarProjectGrouping"; | ||
| 8 | import { resolveSettingsScope, validateSettingsScopeSearch } from "./settingsScope"; | ||
| 9 | |||
| 10 | const laptopId = EnvironmentId.make("laptop"); | ||
| 11 | const serverId = EnvironmentId.make("server"); | ||
| 12 | const environments = [ | ||
| 13 | { environmentId: laptopId, label: "Laptop" }, | ||
| 14 | { environmentId: serverId, label: "Server" }, | ||
| 15 | ]; | ||
| 16 | |||
| 17 | function member(id: string, environmentId: EnvironmentId): SidebarProjectGroupMember { | ||
| 18 | return { | ||
| 19 | id: ProjectId.make(id), | ||
| 20 | environmentId, | ||
| 21 | title: "T3 Code", | ||
| 22 | workspaceRoot: `/repos/${id}`, | ||
| 23 | physicalProjectKey: `${environmentId}:/repos/${id}`, | ||
| 24 | environmentLabel: | ||
| 25 | environments.find((environment) => environment.environmentId === environmentId)?.label ?? | ||
| 26 | null, | ||
| 27 | defaultModelSelection: null, | ||
| 28 | scripts: [], | ||
| 29 | createdAt: "2026-09-07T00:00:00.000Z", | ||
| 30 | updatedAt: "2026-09-07T00:00:00.000Z", | ||
| 31 | }; | ||
| 32 | } | ||
| 33 | |||
| 34 | const first = member("first", laptopId); | ||
| 35 | const second = member("second", laptopId); | ||
| 36 | const third = member("third", serverId); | ||
| 37 | const other = member("other", serverId); | ||
| 38 | |||
| 39 | function group( | ||
| 40 | projectKey: string, | ||
| 41 | members: readonly SidebarProjectGroupMember[], | ||
| 42 | ): SidebarProjectSnapshot { | ||
| 43 | return { | ||
| 44 | ...members[0]!, | ||
| 45 | projectKey, | ||
| 46 | displayName: projectKey, | ||
| 47 | memberProjects: members, | ||
| 48 | memberProjectRefs: members.map((project) => ({ | ||
| 49 | environmentId: project.environmentId, | ||
| 50 | projectId: project.id, | ||
| 51 | })), | ||
| 52 | groupedProjectCount: members.length, | ||
| 53 | environmentPresence: "mixed", | ||
| 54 | allRemoteMembersAreDesktopLocal: false, | ||
| 55 | allRemoteMembersAreWsl: false, | ||
| 56 | remoteEnvironmentLabels: [], | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | const groups = [group("t3code", [first, second, third]), group("other", [other])]; | ||
| 61 | |||
| 62 | describe("settings scope search", () => { | ||
| 63 | it.each(["device", "all"] as const)( | ||
| 64 | "clears narrower targets for an explicit %s selection", | ||
| 65 | (scope) => { | ||
| 66 | expect( | ||
| 67 | validateSettingsScopeSearch({ | ||
| 68 | scope, | ||
| 69 | project: "t3code", | ||
| 70 | machine: laptopId, | ||
| 71 | checkout: first.physicalProjectKey, | ||
| 72 | }), | ||
| 73 | ).toEqual({ scope }); | ||
| 74 | }, | ||
| 75 | ); | ||
| 76 | |||
| 77 | it("retains legacy project and machine links without inventing an explicit broad scope", () => { | ||
| 78 | expect( | ||
| 79 | validateSettingsScopeSearch({ project: "t3code", machine: laptopId, unused: true }), | ||
| 80 | ).toEqual({ | ||
| 81 | project: "t3code", | ||
| 82 | machine: laptopId, | ||
| 83 | }); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("retains an orphan checkout so it cannot turn into all environments", () => { | ||
| 87 | const search = validateSettingsScopeSearch({ checkout: first.physicalProjectKey }); | ||
| 88 | expect(search).toEqual({ checkout: first.physicalProjectKey }); | ||
| 89 | expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ | ||
| 90 | kind: "unavailable", | ||
| 91 | reason: "project-required", | ||
| 92 | members: [], | ||
| 93 | environmentIds: [], | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | }); | ||
| 97 | |||
| 98 | describe("settings scope resolution", () => { | ||
| 99 | it("distinguishes this device from the default aggregate target", () => { | ||
| 100 | expect(resolveSettingsScope({}, groups, environments)).toMatchObject({ | ||
| 101 | kind: "all", | ||
| 102 | environmentIds: [laptopId, serverId], | ||
| 103 | }); | ||
| 104 | expect(resolveSettingsScope({ scope: "device" }, groups, environments)).toMatchObject({ | ||
| 105 | kind: "device", | ||
| 106 | members: [], | ||
| 107 | environmentIds: [], | ||
| 108 | }); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("resolves one environment without targeting its project overrides", () => { | ||
| 112 | expect(resolveSettingsScope({ machine: serverId }, groups, environments)).toMatchObject({ | ||
| 113 | kind: "environment", | ||
| 114 | environmentId: serverId, | ||
| 115 | environmentIds: [serverId], | ||
| 116 | members: [], | ||
| 117 | }); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("keeps all physical members in a project aggregate, including several on one environment", () => { | ||
| 121 | expect(resolveSettingsScope({ project: "t3code" }, groups, environments)).toMatchObject({ | ||
| 122 | kind: "project", | ||
| 123 | environmentId: null, | ||
| 124 | members: [first, second, third], | ||
| 125 | environmentIds: [laptopId, serverId], | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it("preserves legacy project plus machine aggregates with multiple checkouts", () => { | ||
| 130 | expect( | ||
| 131 | resolveSettingsScope({ project: "t3code", machine: laptopId }, groups, environments), | ||
| 132 | ).toMatchObject({ | ||
| 133 | kind: "project", | ||
| 134 | environmentId: laptopId, | ||
| 135 | label: "t3code / Laptop", | ||
| 136 | members: [first, second], | ||
| 137 | environmentIds: [laptopId], | ||
| 138 | }); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("narrows a checkout target to exactly one member, deriving its environment when omitted", () => { | ||
| 142 | expect( | ||
| 143 | resolveSettingsScope( | ||
| 144 | { project: "t3code", checkout: second.physicalProjectKey }, | ||
| 145 | groups, | ||
| 146 | environments, | ||
| 147 | ), | ||
| 148 | ).toMatchObject({ | ||
| 149 | kind: "checkout", | ||
| 150 | checkout: second, | ||
| 151 | environmentId: laptopId, | ||
| 152 | label: "t3code / Laptop · /repos/second", | ||
| 153 | members: [second], | ||
| 154 | environmentIds: [laptopId], | ||
| 155 | }); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it.each([ | ||
| 159 | { project: "missing" }, | ||
| 160 | { machine: "removed" }, | ||
| 161 | { project: "t3code", machine: "removed" }, | ||
| 162 | { project: "t3code", checkout: "deleted" }, | ||
| 163 | { project: "other", machine: laptopId }, | ||
| 164 | { project: "other", checkout: first.physicalProjectKey }, | ||
| 165 | { project: "t3code", machine: serverId, checkout: first.physicalProjectKey }, | ||
| 166 | ])("never widens an invalid or stale target: %j", (search) => { | ||
| 167 | expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ | ||
| 168 | kind: "unavailable", | ||
| 169 | members: [], | ||
| 170 | environmentIds: [], | ||
| 171 | }); | ||
| 172 | }); | ||
| 173 | |||
| 174 | it("leaves a removed checkout unavailable while sibling checkouts remain", () => { | ||
| 175 | const search = { | ||
| 176 | project: "t3code", | ||
| 177 | machine: laptopId, | ||
| 178 | checkout: first.physicalProjectKey, | ||
| 179 | }; | ||
| 180 | expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ | ||
| 181 | kind: "checkout", | ||
| 182 | members: [first], | ||
| 183 | }); | ||
| 184 | expect( | ||
| 185 | resolveSettingsScope(search, [group("t3code", [second, third])], environments), | ||
| 186 | ).toMatchObject({ kind: "unavailable", members: [], environmentIds: [] }); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("does not select another environment after removing a project's last local checkout", () => { | ||
| 190 | const search = { project: "t3code", machine: laptopId }; | ||
| 191 | expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ | ||
| 192 | kind: "project", | ||
| 193 | members: [first, second], | ||
| 194 | }); | ||
| 195 | expect(resolveSettingsScope(search, [group("t3code", [third])], environments)).toMatchObject({ | ||
| 196 | kind: "unavailable", | ||
| 197 | members: [], | ||
| 198 | environmentIds: [], | ||
| 199 | }); | ||
| 200 | }); | ||
| 201 | |||
| 202 | it("rejects a cached checkout whose environment was removed", () => { | ||
| 203 | expect( | ||
| 204 | resolveSettingsScope( | ||
| 205 | { project: "t3code", checkout: third.physicalProjectKey }, | ||
| 206 | groups, | ||
| 207 | environments.slice(0, 1), | ||
| 208 | ), | ||
| 209 | ).toMatchObject({ | ||
| 210 | kind: "unavailable", | ||
| 211 | reason: "environment-missing", | ||
| 212 | members: [], | ||
| 213 | environmentIds: [], | ||
| 214 | }); | ||
| 215 | }); | ||
| 216 | }); | ||
apps/web/src/components/settings/settingsScope.ts created+163| ... | @@ -0,0 +1,163 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import type { | ||
| 4 | SidebarProjectGroupMember, | ||
| 5 | SidebarProjectSnapshot, | ||
| 6 | } from "../../sidebarProjectGrouping"; | ||
| 7 | import type { EnvironmentPresentation } from "../../state/environments"; | ||
| 8 | |||
| 9 | export interface SettingsScopeSearch { | ||
| 10 | scope?: "device" | "all" | undefined; | ||
| 11 | project?: string | undefined; | ||
| 12 | machine?: string | undefined; | ||
| 13 | checkout?: string | undefined; | ||
| 14 | } | ||
| 15 | |||
| 16 | type ScopeTargets = { | ||
| 17 | label: string; | ||
| 18 | members: readonly SidebarProjectGroupMember[]; | ||
| 19 | environmentIds: readonly EnvironmentId[]; | ||
| 20 | }; | ||
| 21 | |||
| 22 | export type ResolvedSettingsScope = ScopeTargets & | ||
| 23 | ( | ||
| 24 | | { kind: "device" | "all" } | ||
| 25 | | { kind: "environment"; environmentId: EnvironmentId } | ||
| 26 | | { | ||
| 27 | kind: "project"; | ||
| 28 | group: SidebarProjectSnapshot; | ||
| 29 | environmentId: EnvironmentId | null; | ||
| 30 | } | ||
| 31 | | { | ||
| 32 | kind: "checkout"; | ||
| 33 | group: SidebarProjectSnapshot; | ||
| 34 | checkout: SidebarProjectGroupMember; | ||
| 35 | environmentId: EnvironmentId; | ||
| 36 | } | ||
| 37 | | { | ||
| 38 | kind: "unavailable"; | ||
| 39 | reason: "project-required" | "project-missing" | "environment-missing" | "checkout-missing"; | ||
| 40 | message: string; | ||
| 41 | } | ||
| 42 | ); | ||
| 43 | |||
| 44 | /** Explicit broad targets replace narrower selections; stale IDs remain visible to the resolver. */ | ||
| 45 | export function validateSettingsScopeSearch(raw: Record<string, unknown>): SettingsScopeSearch { | ||
| 46 | if (raw.scope === "device" || raw.scope === "all") return { scope: raw.scope }; | ||
| 47 | const stringValue = (value: unknown) => | ||
| 48 | typeof value === "string" && value.trim().length > 0 ? value : undefined; | ||
| 49 | const project = stringValue(raw.project); | ||
| 50 | const machine = stringValue(raw.machine); | ||
| 51 | const checkout = stringValue(raw.checkout); | ||
| 52 | return { | ||
| 53 | ...(project === undefined ? {} : { project }), | ||
| 54 | ...(machine === undefined ? {} : { machine }), | ||
| 55 | ...(checkout === undefined ? {} : { checkout }), | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 59 | /** Resolves only existing targets. An unavailable selection never broadens a subsequent write. */ | ||
| 60 | export function resolveSettingsScope( | ||
| 61 | search: SettingsScopeSearch, | ||
| 62 | groups: readonly SidebarProjectSnapshot[], | ||
| 63 | environments: readonly Pick<EnvironmentPresentation, "environmentId" | "label">[], | ||
| 64 | ): ResolvedSettingsScope { | ||
| 65 | const unavailable = ( | ||
| 66 | reason: Extract<ResolvedSettingsScope, { kind: "unavailable" }>["reason"], | ||
| 67 | message: string, | ||
| 68 | ): ResolvedSettingsScope => ({ | ||
| 69 | kind: "unavailable", | ||
| 70 | reason, | ||
| 71 | label: "Unavailable selection", | ||
| 72 | message, | ||
| 73 | members: [], | ||
| 74 | environmentIds: [], | ||
| 75 | }); | ||
| 76 | |||
| 77 | if (search.scope === "device") { | ||
| 78 | return { kind: "device", label: "This device", members: [], environmentIds: [] }; | ||
| 79 | } | ||
| 80 | if (search.scope === "all") { | ||
| 81 | return { | ||
| 82 | kind: "all", | ||
| 83 | label: "All environments", | ||
| 84 | members: [], | ||
| 85 | environmentIds: environments.map((environment) => environment.environmentId), | ||
| 86 | }; | ||
| 87 | } | ||
| 88 | if (search.checkout && !search.project) { | ||
| 89 | return unavailable("project-required", "Select a project to choose one of its checkouts."); | ||
| 90 | } | ||
| 91 | |||
| 92 | const environment = environments.find((candidate) => candidate.environmentId === search.machine); | ||
| 93 | if (search.machine && !environment) { | ||
| 94 | return unavailable("environment-missing", "This environment is no longer available."); | ||
| 95 | } | ||
| 96 | |||
| 97 | if (search.project) { | ||
| 98 | const group = groups.find((candidate) => candidate.projectKey === search.project); | ||
| 99 | if (!group) return unavailable("project-missing", "This project is no longer available."); | ||
| 100 | const members = group.memberProjects.filter( | ||
| 101 | (member) => | ||
| 102 | (search.machine === undefined || member.environmentId === search.machine) && | ||
| 103 | (search.checkout === undefined || member.physicalProjectKey === search.checkout), | ||
| 104 | ); | ||
| 105 | if (members.length === 0) { | ||
| 106 | return unavailable( | ||
| 107 | "checkout-missing", | ||
| 108 | search.checkout | ||
| 109 | ? "This checkout is no longer available in the selected project and environment." | ||
| 110 | : "This project has no checkout on this environment.", | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | if (search.checkout) { | ||
| 114 | const checkout = members[0]!; | ||
| 115 | const checkoutEnvironment = environments.find( | ||
| 116 | (candidate) => candidate.environmentId === checkout.environmentId, | ||
| 117 | ); | ||
| 118 | if (!checkoutEnvironment) { | ||
| 119 | return unavailable( | ||
| 120 | "environment-missing", | ||
| 121 | "This checkout's environment is no longer available.", | ||
| 122 | ); | ||
| 123 | } | ||
| 124 | const sharesEnvironment = group.memberProjects.some( | ||
| 125 | (member) => | ||
| 126 | member.environmentId === checkout.environmentId && | ||
| 127 | member.physicalProjectKey !== checkout.physicalProjectKey, | ||
| 128 | ); | ||
| 129 | return { | ||
| 130 | kind: "checkout", | ||
| 131 | group, | ||
| 132 | checkout, | ||
| 133 | environmentId: checkout.environmentId, | ||
| 134 | label: `${group.displayName} / ${checkoutEnvironment.label}${sharesEnvironment ? ` · ${checkout.workspaceRoot}` : ""}`, | ||
| 135 | members, | ||
| 136 | environmentIds: [checkout.environmentId], | ||
| 137 | }; | ||
| 138 | } | ||
| 139 | return { | ||
| 140 | kind: "project", | ||
| 141 | group, | ||
| 142 | environmentId: environment?.environmentId ?? null, | ||
| 143 | label: `${group.displayName} / ${environment?.label ?? "All checkouts"}`, | ||
| 144 | members, | ||
| 145 | environmentIds: [...new Set(members.map((member) => member.environmentId))], | ||
| 146 | }; | ||
| 147 | } | ||
| 148 | if (environment) { | ||
| 149 | return { | ||
| 150 | kind: "environment", | ||
| 151 | environmentId: environment.environmentId, | ||
| 152 | label: environment.label, | ||
| 153 | members: [], | ||
| 154 | environmentIds: [environment.environmentId], | ||
| 155 | }; | ||
| 156 | } | ||
| 157 | return { | ||
| 158 | kind: "all", | ||
| 159 | label: "All environments", | ||
| 160 | members: [], | ||
| 161 | environmentIds: environments.map((candidate) => candidate.environmentId), | ||
| 162 | }; | ||
| 163 | } | ||
apps/web/src/components/settings/useSettingsProjectGroups.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | |||
| 3 | import { useClientSettings } from "../../hooks/useSettings"; | ||
| 4 | import { selectProjectGroupingSettings } from "../../logicalProject"; | ||
| 5 | import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; | ||
| 6 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; | ||
| 7 | import { useProjects } from "../../state/entities"; | ||
| 8 | |||
| 9 | /** Settings uses the same logical projects as the sidebar, sorted by display name. */ | ||
| 10 | export function useSettingsProjectGroups() { | ||
| 11 | const projects = useProjects(); | ||
| 12 | const settings = useClientSettings(selectProjectGroupingSettings); | ||
| 13 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 14 | const { environments } = useEnvironments(); | ||
| 15 | return useMemo(() => { | ||
| 16 | const labels = new Map(environments.map((entry) => [entry.environmentId, entry.label])); | ||
| 17 | return buildSidebarProjectSnapshots({ | ||
| 18 | projects, | ||
| 19 | settings, | ||
| 20 | primaryEnvironmentId, | ||
| 21 | resolveEnvironmentLabel: (id) => labels.get(id) ?? null, | ||
| 22 | }).sort((a, b) => a.displayName.localeCompare(b.displayName)); | ||
| 23 | }, [environments, primaryEnvironmentId, projects, settings]); | ||
| 24 | } | ||
apps/web/src/routes/settings.projects.tsx+6-9| ... | @@ -1,24 +1,21 @@ | ... | @@ -1,24 +1,21 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { ProjectsSettings } from "../components/settings/ProjectsSettings"; | 2 | import { ProjectsSettings } from "../components/settings/ProjectsSettings"; |
| 3 | import { validateSettingsScopeSearch } from "../components/settings/settingsScope"; | ||
| 3 | 4 | ||
| 4 | export const Route = createFileRoute("/settings/projects")({ | 5 | export const Route = createFileRoute("/settings/projects")({ |
| 5 | validateSearch: (search: Record<string, unknown>) => ({ | 6 | validateSearch: validateSettingsScopeSearch, |
| 6 | project: typeof search.project === "string" ? search.project : undefined, | ||
| 7 | machine: typeof search.machine === "string" ? search.machine : undefined, | ||
| 8 | }), | ||
| 9 | component: ProjectsRoute, | 7 | component: ProjectsRoute, |
| 10 | }); | 8 | }); |
| 11 | 9 | ||
| 12 | function ProjectsRoute() { | 10 | function ProjectsRoute() { |
| 13 | const { project, machine } = Route.useSearch(); | 11 | const search = Route.useSearch(); |
| 14 | const navigate = Route.useNavigate(); | 12 | const navigate = Route.useNavigate(); |
| 15 | return ( | 13 | return ( |
| 16 | <ProjectsSettings | 14 | <ProjectsSettings |
| 17 | projectKey={project ?? null} | 15 | value={search} |
| 18 | machineId={machine ?? null} | 16 | onScopeChange={(scope) => { |
| 19 | onScopeChange={(project, machine) => { | ||
| 20 | void navigate({ | 17 | void navigate({ |
| 21 | search: { project: project ?? undefined, machine: machine ?? undefined }, | 18 | search: scope, |
| 22 | replace: true, | 19 | replace: true, |
| 23 | }); | 20 | }); |
| 24 | }} | 21 | }} |