| 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 | 68 | } from "../../providerInstances"; |
| 69 | 69 | import { getCustomModelOptionsByInstance } from "../../modelSelection"; |
| 70 | 70 | import { |
| 71 | buildSidebarProjectSnapshots, | |
| 72 | 71 | type SidebarProjectGroupMember, |
| 73 | 72 | type SidebarProjectSnapshot, |
| 74 | 73 | } from "../../sidebarProjectGrouping"; |
| ... | ... | @@ -115,6 +114,7 @@ import { |
| 115 | 114 | ProjectFaviconPickerDialog, |
| 116 | 115 | } from "./ProjectFaviconPickerDialog"; |
| 117 | 116 | import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; |
| 117 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | |
| 118 | 118 | |
| 119 | 119 | const ProjectIconPickerDialog = lazy(() => |
| 120 | 120 | import("./ProjectIconPickerDialog").then((module) => ({ |
| ... | ... | @@ -128,31 +128,6 @@ export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, st |
| 128 | 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 | 131 | function memberKey(member: { environmentId: string; id: string }): string { |
| 157 | 132 | return `${member.environmentId}:${member.id}`; |
| 158 | 133 | } |
| ... | ... | @@ -160,9 +135,11 @@ function memberKey(member: { environmentId: string; id: string }): string { |
| 160 | 135 | export function ProjectSettingsPanel({ |
| 161 | 136 | projectKey, |
| 162 | 137 | environmentId = null, |
| 138 | checkoutKey = null, | |
| 163 | 139 | }: { |
| 164 | 140 | projectKey: string; |
| 165 | 141 | environmentId?: EnvironmentId | null; |
| 142 | checkoutKey?: string | null; | |
| 166 | 143 | }) { |
| 167 | 144 | const groups = useSettingsProjectGroups(); |
| 168 | 145 | const navigate = useNavigate(); |
| ... | ... | @@ -171,9 +148,11 @@ export function ProjectSettingsPanel({ |
| 171 | 148 | const members = useMemo( |
| 172 | 149 | () => |
| 173 | 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 | 158 | // Remember the members of the last rendered group so a grouping-rule change |
| ... | ... | @@ -181,6 +160,7 @@ export function ProjectSettingsPanel({ |
| 181 | 160 | const lastSelectionRef = useRef<{ |
| 182 | 161 | key: string; |
| 183 | 162 | environmentId: EnvironmentId | null; |
| 163 | checkoutKey: string | null; | |
| 184 | 164 | memberKeys: string[]; |
| 185 | 165 | } | null>(null); |
| 186 | 166 | useEffect(() => { |
| ... | ... | @@ -188,28 +168,38 @@ export function ProjectSettingsPanel({ |
| 188 | 168 | lastSelectionRef.current = { |
| 189 | 169 | key: selected.projectKey, |
| 190 | 170 | environmentId, |
| 171 | checkoutKey, | |
| 191 | 172 | memberKeys: members.map((member) => member.physicalProjectKey), |
| 192 | 173 | }; |
| 193 | }, [selected, members, environmentId]); | |
| 174 | }, [selected, members, environmentId, checkoutKey]); | |
| 194 | 175 | |
| 195 | 176 | // A grouping-rule change replaces the group key mid-visit; follow the |
| 196 | 177 | // project to its new key instead of parking on the not-found state. |
| 197 | 178 | useEffect(() => { |
| 198 | 179 | if (members.length > 0) return; |
| 199 | 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 | 187 | const successor = groups.find((group) => |
| 202 | 188 | group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), |
| 203 | 189 | ); |
| 204 | 190 | if (successor) { |
| 205 | 191 | void navigate({ |
| 206 | 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 | 198 | replace: true, |
| 209 | 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 | 204 | if (!selected) { |
| 215 | 205 | return ( |
| ... | ... | @@ -223,7 +213,7 @@ export function ProjectSettingsPanel({ |
| 223 | 213 | if (members.length === 0) |
| 224 | 214 | return ( |
| 225 | 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 | 217 | </p> |
| 228 | 218 | ); |
| 229 | 219 | const scopedGroup = { |
| ... | ... | @@ -234,7 +224,7 @@ export function ProjectSettingsPanel({ |
| 234 | 224 | }; |
| 235 | 225 | return ( |
| 236 | 226 | <ProjectDetail |
| 237 | key={`${selected.projectKey}:${environmentId ?? "all"}`} | |
| 227 | key={`${selected.projectKey}:${environmentId ?? "all"}:${checkoutKey ?? "all"}`} | |
| 238 | 228 | group={scopedGroup} |
| 239 | 229 | hasOtherMembers={members.length < selected.memberProjects.length} |
| 240 | 230 | /> |
| ... | ... | @@ -879,23 +869,14 @@ function ProjectDetail({ |
| 879 | 869 | draftStore.clearProjectDraftThreadId(projectRef); |
| 880 | 870 | } |
| 881 | 871 | |
| 882 | if (isWholeGroup) { | |
| 883 | if (hasOtherMembers) { | |
| 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 | } | |
| 872 | if (isWholeGroup && !hasOtherMembers) { | |
| 873 | void navigate({ to: "/", replace: true }); | |
| 892 | 874 | } |
| 893 | 875 | }, |
| 894 | 876 | [ |
| 895 | 877 | deleteProject, |
| 896 | 878 | group.displayName, |
| 897 | 879 | group.memberProjects.length, |
| 898 | group.projectKey, | |
| 899 | 880 | hasOtherMembers, |
| 900 | 881 | navigate, |
| 901 | 882 | reportFailure, |
apps/web/src/components/settings/ProjectsSettings.tsx+32-147| ... | ... | @@ -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 | 1 | import { WorkspacePageContainer } from "../WorkspacePageContainer"; |
| 7 | 2 | import { useEnvironments } from "../../state/environments"; |
| 8 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | |
| 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"; | |
| 3 | import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; | |
| 21 | 4 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; |
| 22 | ||
| 23 | function ScopePicker({ | |
| 24 | label, | |
| 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 | } | |
| 5 | import { SettingsScopeSelects } from "./SettingsScopeSelects"; | |
| 6 | import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; | |
| 7 | import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; | |
| 81 | 8 | |
| 82 | 9 | export function ProjectsSettings({ |
| 83 | projectKey, | |
| 84 | machineId, | |
| 10 | value, | |
| 85 | 11 | onScopeChange, |
| 86 | 12 | }: { |
| 87 | projectKey: string | null; | |
| 88 | machineId: string | null; | |
| 89 | onScopeChange: (project: string | null, machine: string | null) => void; | |
| 13 | value: SettingsScopeSearch; | |
| 14 | onScopeChange: (scope: SettingsScopeSearch) => void; | |
| 90 | 15 | }) { |
| 91 | 16 | const groups = useSettingsProjectGroups(); |
| 92 | 17 | const { environments } = useEnvironments(); |
| 93 | const machine = environments.find((environment) => environment.environmentId === machineId); | |
| 94 | const machineOptions = environments.map((environment) => ({ | |
| 95 | value: environment.environmentId, | |
| 96 | label: environment.label, | |
| 97 | icon: ( | |
| 98 | <EnvironmentMachineIcon | |
| 99 | aria-hidden | |
| 100 | kind={resolveEnvironmentMachineKind(environment.serverConfig)} | |
| 101 | className="size-3.5 shrink-0" | |
| 102 | /> | |
| 103 | ), | |
| 104 | })); | |
| 18 | const scope = resolveSettingsScope(value, groups, environments); | |
| 19 | // The panel follows remembered members when grouping replaces a project key. | |
| 20 | const projectScope = | |
| 21 | scope.kind === "project" || | |
| 22 | scope.kind === "checkout" || | |
| 23 | (scope.kind === "unavailable" && | |
| 24 | (scope.reason === "project-missing" || scope.reason === "checkout-missing")); | |
| 105 | 25 | return ( |
| 106 | 26 | <div className="flex min-h-0 flex-1 flex-col"> |
| 107 | 27 | <div className="scrollbar-gutter-both shrink-0 overflow-y-auto"> |
| 108 | <WorkspacePageContainer className="pb-0"> | |
| 109 | <div | |
| 110 | className="flex flex-wrap items-center gap-2" | |
| 111 | role="group" | |
| 112 | aria-label="Settings scope" | |
| 113 | > | |
| 114 | {environments.length > 3 ? ( | |
| 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> | |
| 28 | <WorkspacePageContainer className="items-end pb-0"> | |
| 29 | <SettingsScopeSelects | |
| 30 | value={value} | |
| 31 | groups={groups} | |
| 32 | environments={environments} | |
| 33 | onChange={onScopeChange} | |
| 34 | /> | |
| 154 | 35 | </WorkspacePageContainer> |
| 155 | 36 | </div> |
| 156 | {machineId !== null && !machine ? ( | |
| 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 | ) : ( | |
| 37 | {value.project && projectScope ? ( | |
| 161 | 38 | <ProjectSettingsPanel |
| 162 | projectKey={projectKey} | |
| 163 | environmentId={machine?.environmentId ?? null} | |
| 39 | projectKey={value.project} | |
| 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 | 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 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { ProjectsSettings } from "../components/settings/ProjectsSettings"; |
| 3 | import { validateSettingsScopeSearch } from "../components/settings/settingsScope"; | |
| 3 | 4 | |
| 4 | 5 | export const Route = createFileRoute("/settings/projects")({ |
| 5 | validateSearch: (search: Record<string, unknown>) => ({ | |
| 6 | project: typeof search.project === "string" ? search.project : undefined, | |
| 7 | machine: typeof search.machine === "string" ? search.machine : undefined, | |
| 8 | }), | |
| 6 | validateSearch: validateSettingsScopeSearch, | |
| 9 | 7 | component: ProjectsRoute, |
| 10 | 8 | }); |
| 11 | 9 | |
| 12 | 10 | function ProjectsRoute() { |
| 13 | const { project, machine } = Route.useSearch(); | |
| 11 | const search = Route.useSearch(); | |
| 14 | 12 | const navigate = Route.useNavigate(); |
| 15 | 13 | return ( |
| 16 | 14 | <ProjectsSettings |
| 17 | projectKey={project ?? null} | |
| 18 | machineId={machine ?? null} | |
| 19 | onScopeChange={(project, machine) => { | |
| 15 | value={search} | |
| 16 | onScopeChange={(scope) => { | |
| 20 | 17 | void navigate({ |
| 21 | search: { project: project ?? undefined, machine: machine ?? undefined }, | |
| 18 | search: scope, | |
| 22 | 19 | replace: true, |
| 23 | 20 | }); |
| 24 | 21 | }} |