From e220926d29bd01877045f391dfb72b4d295a27cb Mon Sep 17 00:00:00 2001 From: d3oxy Date: Wed, 30 Sep 2026 17:38:48 +0530 Subject: [PATCH] fix(web): split a checkout out of its project group from project settings again The per-checkout grouping override lost its project settings control in #10639, leaving the legacy sidebar as the only way to set or clear it. Project settings now show a Grouping row for single-checkout projects and a per-checkout picker under Checkouts. The legacy sidebar shares the labels and save helper. --- apps/web/src/components/LegacySidebar.tsx | 22 ++---- .../settings/ProjectGroupingSelect.tsx | 68 +++++++++++++++++++ .../settings/ProjectSettingsPanel.tsx | 27 +++++--- apps/web/src/logicalProject.ts | 1 + docs/user/project-settings.md | 11 +++ .../src/state/projectGrouping.test.ts | 19 ++++-- .../src/state/projectGrouping.ts | 14 ++++ 7 files changed, 132 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/settings/ProjectGroupingSelect.tsx diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 1991dc502283..144e6271edfb 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -206,7 +206,9 @@ import { deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings, + withProjectGroupingOverride, } from "../logicalProject"; +import { PROJECT_GROUPING_MODE_LABELS } from "./settings/ProjectGroupingSelect"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, @@ -229,11 +231,6 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { easing: "ease-out", } as const; const EMPTY_THREAD_JUMP_LABELS = new Map(); -const PROJECT_GROUPING_MODE_LABELS: Record = { - repository: "Group by repository", - repository_path: "Group by repository path", - separate: "Keep separate", -}; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; @@ -2213,17 +2210,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const overrideKey = deriveProjectGroupingOverrideKey(projectGroupingTarget); - const nextOverrides = { - ...projectGroupingSettings.sidebarProjectGroupingOverrides, - }; - if (projectGroupingSelection === "inherit") { - delete nextOverrides[overrideKey]; - } else { - nextOverrides[overrideKey] = projectGroupingSelection; - } updateSettings({ - sidebarProjectGroupingOverrides: nextOverrides, + sidebarProjectGroupingOverrides: withProjectGroupingOverride( + projectGroupingSettings.sidebarProjectGroupingOverrides, + projectGroupingTarget, + projectGroupingSelection, + ), }); closeProjectGroupingDialog(); }, [ diff --git a/apps/web/src/components/settings/ProjectGroupingSelect.tsx b/apps/web/src/components/settings/ProjectGroupingSelect.tsx new file mode 100644 index 000000000000..d1cff684ffbb --- /dev/null +++ b/apps/web/src/components/settings/ProjectGroupingSelect.tsx @@ -0,0 +1,68 @@ +import { SidebarProjectGroupingMode } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { + deriveProjectGroupingOverrideKey, + selectProjectGroupingSettings, + withProjectGroupingOverride, +} from "../../logicalProject"; +import type { SidebarProjectGroupMember } from "../../sidebarProjectGrouping"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +export const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; + +const isGroupingMode = Schema.is(SidebarProjectGroupingMode); + +/** + * Picks one checkout's grouping rule. Overrides are client settings keyed by + * environment and path, so they apply on this device only. + */ +export function ProjectGroupingSelect({ member }: { member: SidebarProjectGroupMember }) { + const settings = useClientSettings(selectProjectGroupingSettings); + const updateClientSettings = useUpdateClientSettings(); + const selection = + settings.sidebarProjectGroupingOverrides[deriveProjectGroupingOverrideKey(member)] ?? "inherit"; + + return ( + + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 8e5edec0f485..a55f27418b28 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -41,6 +41,7 @@ import { } from "./ProjectFaviconPickerDialog"; import { ProjectActionsSettings } from "./ProjectActionsSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; +import { ProjectGroupingSelect } from "./ProjectGroupingSelect"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; @@ -392,14 +393,17 @@ function ProjectDetail({ title={member.environmentLabel ?? "Environment"} description={member.workspaceRoot} control={ - +
+ + +
} /> ))} @@ -488,6 +492,13 @@ function ProjectDetail({ } /> + {hasMultipleCheckouts ? null : ( + } + /> + )} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index d75c4c2de902..c380ef18c02c 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -8,6 +8,7 @@ export { getProjectOrderKey, resolveProjectGroupingMode, selectProjectGroupingSettings, + withProjectGroupingOverride, type ProjectGroupingMode, type ProjectGroupingSettings, type ProjectGroup, diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index fe3d96ba472c..a7898aaf934e 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -100,6 +100,17 @@ When no image is found, web and desktop show a two-character monogram with a col from the icon palette, derived from the saved project name. For example, `Nebula` becomes `NA`, `Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`. +## Project grouping + +With **Settings → General → Project grouping** on, checkouts of the same repository share one +project, even across environments. The repository comes from the checkout's `upstream` remote, +then `origin`, so a fork with an `upstream` remote joins the original repository's project. + +To split one checkout out, open the project and change its rule under **Grouping**, or under +**Checkouts** when the project has several. **Keep separate** gives the checkout its own project, and +**Use default** puts it back. These rules are saved on the device you set them on, and mobile +only follows the global setting. + ## Keep the default branch current In Source Control, enable **Automatically pull** to keep the default-branch checkout up to date diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 4884c3b99bbc..d6d19e9d0d77 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -6,6 +6,7 @@ import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, derivePhysicalProjectKey, + withProjectGroupingOverride, type ProjectGroupingSettings, } from "./projectGrouping.ts"; @@ -171,23 +172,27 @@ describe("buildProjectGroups", () => { expect(groups.map((group) => group.label)).toEqual(["t3code", "t3code-2", "t3code-3"]); }); - it("applies a physical-project override without dropping its siblings", () => { + it("applies a physical-project override without dropping its siblings, and removes it", () => { const first = makeProject("t3code", "/work/t3code"); const second = makeProject("t3code-2", "/work/t3code-2"); const third = makeProject("t3code-3", "/work/t3code-3"); - const groups = buildProjectGroups({ - projects: [first, second, third], - settings: settings("repository", { - [derivePhysicalProjectKey(second)]: "separate", - }), - }); + const projects = [first, second, third]; + const overrides = withProjectGroupingOverride({}, second, "separate"); + const groups = buildProjectGroups({ projects, settings: settings("repository", overrides) }); + expect(overrides).toEqual({ [derivePhysicalProjectKey(second)]: "separate" }); expect(groups).toHaveLength(2); expect(groups.flatMap((group) => group.members.map((member) => member.project.id))).toEqual([ "t3code", "t3code-3", "t3code-2", ]); + + const restored = withProjectGroupingOverride(overrides, second, "inherit"); + expect(restored).toEqual({}); + expect( + buildProjectGroups({ projects, settings: settings("repository", restored) }), + ).toHaveLength(1); }); it("dedupes stale registrations at one physical path using the freshest project", () => { diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index ce5c984214fd..c6fb5e32c716 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -96,6 +96,20 @@ export function resolveProjectGroupingMode( ); } +/** + * Returns the overrides with one checkout's grouping rule set, or removed when + * `mode` is `"inherit"` so the checkout follows the global mode again. + */ +export function withProjectGroupingOverride( + overrides: ProjectGroupingSettings["sidebarProjectGroupingOverrides"], + project: Pick, + mode: SidebarProjectGroupingMode | "inherit", +): ProjectGroupingSettings["sidebarProjectGroupingOverrides"] { + const key = deriveProjectGroupingOverrideKey(project); + const { [key]: _previous, ...rest } = overrides; + return mode === "inherit" ? rest : { ...rest, [key]: mode }; +} + function deriveRepositoryScopedKey( project: Pick, groupingMode: SidebarProjectGroupingMode,