| author | |
| committer | |
| log | 8de61d4a97635382ee08d098b2a588e88532cc0a |
| tree | 7428fa78ab07516e252c5972c08961ece093850a |
| parent | 8863990fcaecfb59dbd6c338e49aba603ecccdfd |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>14 files changed, 704 insertions(+), 28 deletions(-)
apps/mobile/src/features/home/HomeRouteScreen.tsx+11-1| ... | @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; | ... | @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; |
| 7 | import { Platform, useWindowDimensions } from "react-native"; | 7 | import { Platform, useWindowDimensions } from "react-native"; |
| 8 | 8 | ||
| 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 10 | import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; | ||
| 10 | 11 | ||
| 11 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | 12 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 12 | import { useProjects } from "../../state/entities"; | 13 | import { useProjects } from "../../state/entities"; |
| ... | @@ -119,7 +120,16 @@ export function HomeRouteScreen() { | ... | @@ -119,7 +120,16 @@ export function HomeRouteScreen() { |
| 119 | setSelectedProjectKey, | 120 | setSelectedProjectKey, |
| 120 | } = useHomeListOptions(availableEnvironmentIds); | 121 | } = useHomeListOptions(availableEnvironmentIds); |
| 121 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; | 122 | const selectedEnvironmentId = listOptions.selectedEnvironmentId; |
| 122 | const selectedProjectKey = listOptions.selectedProjectKey; | 123 | const projectScopeKey = listOptions.selectedProjectKey; |
| 124 | const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { | ||
| 125 | sidebarProjectGroupingMode: listOptions.projectGroupingMode, | ||
| 126 | sidebarProjectGroupingOverrides: {}, | ||
| 127 | }); | ||
| 128 | useEffect(() => { | ||
| 129 | if (selectedProjectKey !== projectScopeKey) { | ||
| 130 | setSelectedProjectKey(selectedProjectKey); | ||
| 131 | } | ||
| 132 | }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]); | ||
| 123 | const projectFilterOptions = useMemo( | 133 | const projectFilterOptions = useMemo( |
| 124 | () => | 134 | () => |
| 125 | buildHomeProjectScopes({ | 135 | buildHomeProjectScopes({ |
apps/mobile/src/features/home/homeThreadList.test.ts+74| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | sortHomeProjectScopes, | 11 | sortHomeProjectScopes, |
| 12 | } from "./homeThreadList"; | 12 | } from "./homeThreadList"; |
| 13 | import { makeThreadShellFixture } from "../../test-fixtures"; | 13 | import { makeThreadShellFixture } from "../../test-fixtures"; |
| 14 | import { buildThreadListV2Items } from "../threads/threadListV2"; | ||
| 14 | 15 | ||
| 15 | function makeProject( | 16 | function makeProject( |
| 16 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, | 17 | input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">, |
| ... | @@ -48,6 +49,79 @@ function makeThread( | ... | @@ -48,6 +49,79 @@ function makeThread( |
| 48 | } | 49 | } |
| 49 | 50 | ||
| 50 | describe("home project scopes", () => { | 51 | describe("home project scopes", () => { |
| 52 | it("filters threads across both scratch hosts while retaining the selected environment boundary", () => { | ||
| 53 | const localEnvironmentId = EnvironmentId.make("local"); | ||
| 54 | const remoteEnvironmentId = EnvironmentId.make("remote"); | ||
| 55 | const projects = [ | ||
| 56 | makeProject({ | ||
| 57 | environmentId: localEnvironmentId, | ||
| 58 | id: ProjectId.make("scratch-local"), | ||
| 59 | title: "No project", | ||
| 60 | workspaceRoot: "/local/scratch", | ||
| 61 | isScratch: true, | ||
| 62 | }), | ||
| 63 | makeProject({ | ||
| 64 | environmentId: remoteEnvironmentId, | ||
| 65 | id: ProjectId.make("scratch-remote"), | ||
| 66 | title: "No project", | ||
| 67 | workspaceRoot: "/remote/scratch", | ||
| 68 | isScratch: true, | ||
| 69 | }), | ||
| 70 | makeProject({ | ||
| 71 | environmentId: remoteEnvironmentId, | ||
| 72 | id: ProjectId.make("ordinary"), | ||
| 73 | title: "No project", | ||
| 74 | }), | ||
| 75 | ]; | ||
| 76 | const threads = projects.map((project) => | ||
| 77 | makeThread({ | ||
| 78 | environmentId: project.environmentId, | ||
| 79 | projectId: project.id, | ||
| 80 | id: ThreadId.make(`thread-${project.id}`), | ||
| 81 | title: project.id, | ||
| 82 | }), | ||
| 83 | ); | ||
| 84 | const scopes = buildHomeProjectScopes({ | ||
| 85 | projects, | ||
| 86 | environmentId: null, | ||
| 87 | projectGroupingMode: "separate", | ||
| 88 | }); | ||
| 89 | expect(scopes).toHaveLength(2); | ||
| 90 | const scratchScope = scopes.find((scope) => scope.representative.isScratch)!; | ||
| 91 | const filtered = buildThreadListV2Items({ | ||
| 92 | threads, | ||
| 93 | environmentId: null, | ||
| 94 | projectRefs: scratchScope.projectRefs, | ||
| 95 | searchQuery: "", | ||
| 96 | now: "2026-06-01T00:00:00.000Z", | ||
| 97 | }); | ||
| 98 | expect(filtered.items.map((item) => item.thread.id).sort()).toEqual( | ||
| 99 | threads | ||
| 100 | .slice(0, 2) | ||
| 101 | .map((thread) => thread.id) | ||
| 102 | .sort(), | ||
| 103 | ); | ||
| 104 | const remoteScopes = buildHomeProjectScopes({ | ||
| 105 | projects, | ||
| 106 | environmentId: remoteEnvironmentId, | ||
| 107 | projectGroupingMode: "separate", | ||
| 108 | }); | ||
| 109 | const remoteScratch = remoteScopes.find((scope) => scope.representative.isScratch)!; | ||
| 110 | expect(remoteScratch.key).toBe(scratchScope.key); | ||
| 111 | expect(remoteScratch.projectRefs).toEqual([ | ||
| 112 | { environmentId: remoteEnvironmentId, projectId: projects[1]!.id }, | ||
| 113 | ]); | ||
| 114 | expect( | ||
| 115 | buildThreadListV2Items({ | ||
| 116 | threads, | ||
| 117 | environmentId: remoteEnvironmentId, | ||
| 118 | projectRefs: remoteScratch.projectRefs, | ||
| 119 | searchQuery: "", | ||
| 120 | now: "2026-06-01T00:00:00.000Z", | ||
| 121 | }).items.map((item) => item.thread.id), | ||
| 122 | ).toEqual([threads[1]!.id]); | ||
| 123 | }); | ||
| 124 | |||
| 51 | it("builds one v2 scope for the same repository across environments", () => { | 125 | it("builds one v2 scope for the same repository across environments", () => { |
| 52 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 126 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 53 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); | 127 | const remoteEnvironmentId = EnvironmentId.make("environment-remote"); |
apps/mobile/src/features/home/homeThreadList.ts+1| ... | @@ -50,6 +50,7 @@ export function buildHomeProjectScopes(input: { | ... | @@ -50,6 +50,7 @@ export function buildHomeProjectScopes(input: { |
| 50 | ); | 50 | ); |
| 51 | return buildProjectGroups({ | 51 | return buildProjectGroups({ |
| 52 | projects, | 52 | projects, |
| 53 | groupScratchProjects: true, | ||
| 53 | settings: { | 54 | settings: { |
| 54 | sidebarProjectGroupingMode: input.projectGroupingMode, | 55 | sidebarProjectGroupingMode: input.projectGroupingMode, |
| 55 | sidebarProjectGroupingOverrides: {}, | 56 | sidebarProjectGroupingOverrides: {}, |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+11-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; | 1 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; |
| 2 | import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; | ||
| 2 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 3 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 3 | import { computeThreadMoveAvailability } from "./threadOrder"; | 4 | import { computeThreadMoveAvailability } from "./threadOrder"; |
| 4 | import type { | 5 | import type { |
| ... | @@ -206,7 +207,16 @@ function ThreadNavigationSidebarPane( | ... | @@ -206,7 +207,16 @@ function ThreadNavigationSidebarPane( |
| 206 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), | 207 | () => new Set(threadSearch.matches.map(threadSearchMatchKey)), |
| 207 | [threadSearch.matches], | 208 | [threadSearch.matches], |
| 208 | ); | 209 | ); |
| 209 | const selectedProjectKey = options.selectedProjectKey; | 210 | const projectScopeKey = options.selectedProjectKey; |
| 211 | const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, { | ||
| 212 | sidebarProjectGroupingMode: options.projectGroupingMode, | ||
| 213 | sidebarProjectGroupingOverrides: {}, | ||
| 214 | }); | ||
| 215 | useEffect(() => { | ||
| 216 | if (selectedProjectKey !== projectScopeKey) { | ||
| 217 | setSelectedProjectKey(selectedProjectKey); | ||
| 218 | } | ||
| 219 | }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]); | ||
| 210 | const projectScopes = useMemo( | 220 | const projectScopes = useMemo( |
| 211 | () => | 221 | () => |
| 212 | buildHomeProjectScopes({ | 222 | buildHomeProjectScopes({ |
apps/mobile/src/state/projects.ts+2| ... | @@ -4,10 +4,12 @@ import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/pro | ... | @@ -4,10 +4,12 @@ import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/pro |
| 4 | import { environmentCatalog } from "../connection/catalog"; | 4 | import { environmentCatalog } from "../connection/catalog"; |
| 5 | import { connectionAtomRuntime } from "../connection/runtime"; | 5 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 6 | import { environmentSnapshotAtom } from "./shell"; | 6 | import { environmentSnapshotAtom } from "./shell"; |
| 7 | import { serverEnvironment } from "./server"; | ||
| 7 | 8 | ||
| 8 | export const environmentProjects = createEnvironmentProjectAtoms({ | 9 | export const environmentProjects = createEnvironmentProjectAtoms({ |
| 9 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 10 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 10 | snapshotAtom: environmentSnapshotAtom, | 11 | snapshotAtom: environmentSnapshotAtom, |
| 12 | serverConfigValueAtom: serverEnvironment.configValueAtom, | ||
| 11 | }); | 13 | }); |
| 12 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { | 14 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { |
| 13 | projectAtom: environmentProjects.projectAtom, | 15 | projectAtom: environmentProjects.projectAtom, |
apps/web/src/components/Sidebar.tsx+54-13| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type EnvironmentId } from "@t3tools/contracts"; | 1 | import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; |
| 2 | import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; | 2 | import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; |
| 3 | import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; | 3 | import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; |
| 4 | import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators"; | 4 | import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators"; |
| ... | @@ -129,6 +129,8 @@ import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalPro | ... | @@ -129,6 +129,8 @@ import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalPro |
| 129 | import { | 129 | import { |
| 130 | buildSidebarProjectSnapshots, | 130 | buildSidebarProjectSnapshots, |
| 131 | projectGroupsSpanEnvironments, | 131 | projectGroupsSpanEnvironments, |
| 132 | resolveSidebarProjectScopeKey, | ||
| 133 | getSidebarProjectSettingsKey, | ||
| 132 | type SidebarProjectSnapshot, | 134 | type SidebarProjectSnapshot, |
| 133 | } from "../sidebarProjectGrouping"; | 135 | } from "../sidebarProjectGrouping"; |
| 134 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 136 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| ... | @@ -158,6 +160,7 @@ import { | ... | @@ -158,6 +160,7 @@ import { |
| 158 | useThreadShells, | 160 | useThreadShells, |
| 159 | } from "../state/entities"; | 161 | } from "../state/entities"; |
| 160 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; | 162 | import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; |
| 163 | import { environmentProjects } from "../state/projects"; | ||
| 161 | import { vcsEnvironment } from "../state/vcs"; | 164 | import { vcsEnvironment } from "../state/vcs"; |
| 162 | import { threadEnvironment } from "../state/threads"; | 165 | import { threadEnvironment } from "../state/threads"; |
| 163 | import { useEnvironmentQuery } from "../state/query"; | 166 | import { useEnvironmentQuery } from "../state/query"; |
| ... | @@ -2562,6 +2565,7 @@ export default function Sidebar() { | ... | @@ -2562,6 +2565,7 @@ export default function Sidebar() { |
| 2562 | () => | 2565 | () => |
| 2563 | buildSidebarProjectSnapshots({ | 2566 | buildSidebarProjectSnapshots({ |
| 2564 | projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, | 2567 | projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, |
| 2568 | groupScratchProjects: true, | ||
| 2565 | settings: projectGroupingSettings, | 2569 | settings: projectGroupingSettings, |
| 2566 | primaryEnvironmentId, | 2570 | primaryEnvironmentId, |
| 2567 | resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, | 2571 | resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, |
| ... | @@ -2625,8 +2629,17 @@ export default function Sidebar() { | ... | @@ -2625,8 +2629,17 @@ export default function Sidebar() { |
| 2625 | // The selection lives in the persisted UI store next to the other sidebar | 2629 | // The selection lives in the persisted UI store next to the other sidebar |
| 2626 | // project preferences, so routes that unmount the sidebar (Settings) and | 2630 | // project preferences, so routes that unmount the sidebar (Settings) and |
| 2627 | // app restarts keep it. | 2631 | // app restarts keep it. |
| 2628 | const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); | 2632 | const persistedProjectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); |
| 2629 | const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); | 2633 | const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); |
| 2634 | const projectScopeKey = useMemo( | ||
| 2635 | () => | ||
| 2636 | resolveSidebarProjectScopeKey({ | ||
| 2637 | groups: projectGroups, | ||
| 2638 | key: persistedProjectScopeKey, | ||
| 2639 | settings: projectGroupingSettings, | ||
| 2640 | }), | ||
| 2641 | [persistedProjectScopeKey, projectGroups, projectGroupingSettings], | ||
| 2642 | ); | ||
| 2630 | // {value, label} items let Base UI drive the combobox selection contract | 2643 | // {value, label} items let Base UI drive the combobox selection contract |
| 2631 | // while the popup search filters the same collection. | 2644 | // while the popup search filters the same collection. |
| 2632 | const projectScopeItems = useMemo( | 2645 | const projectScopeItems = useMemo( |
| ... | @@ -2709,15 +2722,28 @@ export default function Sidebar() { | ... | @@ -2709,15 +2722,28 @@ export default function Sidebar() { |
| 2709 | ), | 2722 | ), |
| 2710 | [scopedProjectGroup], | 2723 | [scopedProjectGroup], |
| 2711 | ); | 2724 | ); |
| 2712 | // A persisted scope whose project is gone falls back to all projects, but | 2725 | // Configs identify scratch projects, so both they and live project snapshots |
| 2713 | // only after every catalog environment has a live project snapshot. Cached | 2726 | // must arrive before a persisted scope can be considered missing. |
| 2714 | // or disconnected environments cannot establish that the project is gone. | ||
| 2715 | const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); | 2727 | const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); |
| 2728 | const allProjectConfigsReady = useAtomValue(environmentProjects.projectConfigsReadyAtom); | ||
| 2716 | useEffect(() => { | 2729 | useEffect(() => { |
| 2717 | if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) { | 2730 | const nextKey = resolveSidebarProjectScopeKey({ |
| 2718 | setProjectScopeKey(null); | 2731 | groups: projectGroups, |
| 2732 | key: persistedProjectScopeKey, | ||
| 2733 | settings: projectGroupingSettings, | ||
| 2734 | canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, | ||
| 2735 | }); | ||
| 2736 | if (nextKey !== persistedProjectScopeKey) { | ||
| 2737 | setProjectScopeKey(nextKey); | ||
| 2719 | } | 2738 | } |
| 2720 | }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); | 2739 | }, [ |
| 2740 | allProjectConfigsReady, | ||
| 2741 | allProjectSnapshotsReady, | ||
| 2742 | persistedProjectScopeKey, | ||
| 2743 | projectGroups, | ||
| 2744 | projectGroupingSettings, | ||
| 2745 | setProjectScopeKey, | ||
| 2746 | ]); | ||
| 2721 | // Count-only subscription: the parent needs "are there draft rows" for the | 2747 | // Count-only subscription: the parent needs "are there draft rows" for the |
| 2722 | // empty state, while SidebarDraftBlock owns the per-keystroke content | 2748 | // empty state, while SidebarDraftBlock owns the per-keystroke content |
| 2723 | // subscription. Selecting a number keeps typing in a draft composer from | 2749 | // subscription. Selecting a number keeps typing in a draft composer from |
| ... | @@ -2751,16 +2777,23 @@ export default function Sidebar() { | ... | @@ -2751,16 +2777,23 @@ export default function Sidebar() { |
| 2751 | }, [clearSelection, projectScopeKey]); | 2777 | }, [clearSelection, projectScopeKey]); |
| 2752 | 2778 | ||
| 2753 | const openProjectSettings = useCallback( | 2779 | const openProjectSettings = useCallback( |
| 2754 | (projectGroup: SidebarProjectSnapshot) => { | 2780 | (projectGroup: SidebarProjectSnapshot, projectRef?: ScopedProjectRef) => { |
| 2755 | if (isMobile) { | 2781 | if (isMobile) { |
| 2756 | setOpenMobile(false); | 2782 | setOpenMobile(false); |
| 2757 | } | 2783 | } |
| 2758 | void router.navigate({ | 2784 | void router.navigate({ |
| 2759 | to: "/projects/$projectKey", | 2785 | to: "/projects/$projectKey", |
| 2760 | params: { projectKey: projectGroup.projectKey }, | 2786 | params: { |
| 2787 | projectKey: getSidebarProjectSettingsKey( | ||
| 2788 | projectGroup, | ||
| 2789 | projectGroupingSettings, | ||
| 2790 | projects, | ||
| 2791 | projectRef, | ||
| 2792 | ), | ||
| 2793 | }, | ||
| 2761 | }); | 2794 | }); |
| 2762 | }, | 2795 | }, |
| 2763 | [isMobile, router, setOpenMobile], | 2796 | [isMobile, projectGroupingSettings, projects, router, setOpenMobile], |
| 2764 | ); | 2797 | ); |
| 2765 | // Anchor for the scope popup: the header search field, not its icon trigger. | 2798 | // Anchor for the scope popup: the header search field, not its icon trigger. |
| 2766 | const headerSearchRef = useRef<HTMLDivElement | null>(null); | 2799 | const headerSearchRef = useRef<HTMLDivElement | null>(null); |
| ... | @@ -4579,7 +4612,11 @@ export default function Sidebar() { | ... | @@ -4579,7 +4612,11 @@ export default function Sidebar() { |
| 4579 | if (clicked._tag === "Failure") return; | 4612 | if (clicked._tag === "Failure") return; |
| 4580 | switch (clicked.value) { | 4613 | switch (clicked.value) { |
| 4581 | case "project-settings": | 4614 | case "project-settings": |
| 4582 | if (projectGroup) openProjectSettings(projectGroup); | 4615 | if (projectGroup) |
| 4616 | openProjectSettings(projectGroup, { | ||
| 4617 | environmentId: session.environmentId, | ||
| 4618 | projectId: session.projectId, | ||
| 4619 | }); | ||
| 4583 | return; | 4620 | return; |
| 4584 | case "copy-path": | 4621 | case "copy-path": |
| 4585 | if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath }); | 4622 | if (workspacePath) copyPathToClipboard(workspacePath, { path: workspacePath }); |
| ... | @@ -4703,7 +4740,11 @@ export default function Sidebar() { | ... | @@ -4703,7 +4740,11 @@ export default function Sidebar() { |
| 4703 | } | 4740 | } |
| 4704 | return; | 4741 | return; |
| 4705 | case "project-settings": | 4742 | case "project-settings": |
| 4706 | if (threadProjectGroup) openProjectSettings(threadProjectGroup); | 4743 | if (threadProjectGroup) |
| 4744 | openProjectSettings(threadProjectGroup, { | ||
| 4745 | environmentId: threadRef.environmentId, | ||
| 4746 | projectId: thread.projectId, | ||
| 4747 | }); | ||
| 4707 | return; | 4748 | return; |
| 4708 | case "new-thread-on-branch": { | 4749 | case "new-thread-on-branch": { |
| 4709 | // Explicit branch carry-over: reuse the thread's worktree when it | 4750 | // Explicit branch carry-over: reuse the thread's worktree when it |
apps/web/src/environmentGrouping.test.ts+141| ... | @@ -13,6 +13,8 @@ import { | ... | @@ -13,6 +13,8 @@ import { |
| 13 | buildSidebarProjectPickerEntries, | 13 | buildSidebarProjectPickerEntries, |
| 14 | buildSidebarProjectSnapshots, | 14 | buildSidebarProjectSnapshots, |
| 15 | projectGroupsSpanEnvironments, | 15 | projectGroupsSpanEnvironments, |
| 16 | resolveSidebarProjectScopeKey, | ||
| 17 | getSidebarProjectSettingsKey, | ||
| 16 | } from "./sidebarProjectGrouping"; | 18 | } from "./sidebarProjectGrouping"; |
| 17 | import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; | 19 | import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; |
| 18 | import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; | 20 | import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; |
| ... | @@ -52,6 +54,145 @@ function makeProject(overrides: Partial<Project> = {}): Project { | ... | @@ -52,6 +54,145 @@ function makeProject(overrides: Partial<Project> = {}): Project { |
| 52 | } | 54 | } |
| 53 | 55 | ||
| 54 | describe("environment grouping", () => { | 56 | describe("environment grouping", () => { |
| 57 | it("opens physical scratch settings from the merged filter, including a remote thread target", () => { | ||
| 58 | const local = makeProject({ | ||
| 59 | title: "No project", | ||
| 60 | workspaceRoot: "/local/scratch", | ||
| 61 | isScratch: true, | ||
| 62 | }); | ||
| 63 | const remote = makeProject({ | ||
| 64 | title: "No project", | ||
| 65 | id: ProjectId.make("remote-scratch"), | ||
| 66 | environmentId: remoteEnvironmentId, | ||
| 67 | workspaceRoot: "/remote/scratch", | ||
| 68 | isScratch: true, | ||
| 69 | }); | ||
| 70 | const input = { | ||
| 71 | projects: [local, remote], | ||
| 72 | settings: defaultGroupingSettings, | ||
| 73 | primaryEnvironmentId, | ||
| 74 | resolveEnvironmentLabel: () => null, | ||
| 75 | }; | ||
| 76 | const filters = buildSidebarProjectSnapshots({ ...input, groupScratchProjects: true }); | ||
| 77 | const settingsGroups = buildSidebarProjectSnapshots(input); | ||
| 78 | expect(filters).toHaveLength(1); | ||
| 79 | expect(settingsGroups).toHaveLength(2); | ||
| 80 | expect(settingsGroups.map((group) => group.memberProjectRefs)).toEqual([ | ||
| 81 | [{ environmentId: local.environmentId, projectId: local.id }], | ||
| 82 | [{ environmentId: remote.environmentId, projectId: remote.id }], | ||
| 83 | ]); | ||
| 84 | expect(getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects)).toBe( | ||
| 85 | settingsGroups[0]?.projectKey, | ||
| 86 | ); | ||
| 87 | expect( | ||
| 88 | getSidebarProjectSettingsKey(filters[0]!, defaultGroupingSettings, input.projects, { | ||
| 89 | environmentId: remote.environmentId, | ||
| 90 | projectId: remote.id, | ||
| 91 | }), | ||
| 92 | ).toBe(settingsGroups[1]?.projectKey); | ||
| 93 | }); | ||
| 94 | |||
| 95 | it.each(["repository", "repository_path"] as const)( | ||
| 96 | "migrates a scratch scope selected before config in %s mode", | ||
| 97 | (mode) => { | ||
| 98 | const project = makeProject({ | ||
| 99 | workspaceRoot: "/repo/scratch", | ||
| 100 | repositoryIdentity: { ...repositoryIdentity, rootPath: "/repo" }, | ||
| 101 | }); | ||
| 102 | const settings = { ...defaultGroupingSettings, sidebarProjectGroupingMode: mode }; | ||
| 103 | const key = deriveLogicalProjectKeyFromSettings(project, settings); | ||
| 104 | const groups = buildSidebarProjectSnapshots({ | ||
| 105 | projects: [{ ...project, isScratch: true }], | ||
| 106 | groupScratchProjects: true, | ||
| 107 | settings, | ||
| 108 | primaryEnvironmentId, | ||
| 109 | resolveEnvironmentLabel: () => null, | ||
| 110 | }); | ||
| 111 | expect( | ||
| 112 | resolveSidebarProjectScopeKey({ groups, key, settings, canClearMissingScope: true }), | ||
| 113 | ).toBe(groups[0]?.projectKey); | ||
| 114 | }, | ||
| 115 | ); | ||
| 116 | |||
| 117 | it("prefers an existing ordinary repository scope over a scratch alias", () => { | ||
| 118 | const scratch = makeProject({ isScratch: true, repositoryIdentity }); | ||
| 119 | const ordinary = makeProject({ | ||
| 120 | id: ProjectId.make("ordinary"), | ||
| 121 | workspaceRoot: "/ordinary", | ||
| 122 | repositoryIdentity, | ||
| 123 | }); | ||
| 124 | const groups = buildSidebarProjectSnapshots({ | ||
| 125 | projects: [scratch, ordinary], | ||
| 126 | groupScratchProjects: true, | ||
| 127 | settings: defaultGroupingSettings, | ||
| 128 | primaryEnvironmentId, | ||
| 129 | resolveEnvironmentLabel: () => null, | ||
| 130 | }); | ||
| 131 | const key = deriveLogicalProjectKeyFromSettings(ordinary, defaultGroupingSettings); | ||
| 132 | expect(resolveSidebarProjectScopeKey({ groups, key, canClearMissingScope: true })).toBe(key); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("keeps both scratch hosts in one filter and migrates a physical scope after config loads", () => { | ||
| 136 | const projects = [ | ||
| 137 | makeProject({ title: "No project", workspaceRoot: "/local/scratch" }), | ||
| 138 | makeProject({ | ||
| 139 | id: ProjectId.make("scratch-remote"), | ||
| 140 | environmentId: remoteEnvironmentId, | ||
| 141 | title: "No project", | ||
| 142 | workspaceRoot: "/remote/scratch", | ||
| 143 | }), | ||
| 144 | ]; | ||
| 145 | const build = (scratch: boolean) => | ||
| 146 | buildSidebarProjectSnapshots({ | ||
| 147 | projects: projects.map((project) => ({ | ||
| 148 | ...project, | ||
| 149 | ...(scratch ? { isScratch: true as const } : {}), | ||
| 150 | })), | ||
| 151 | groupScratchProjects: true, | ||
| 152 | settings: defaultGroupingSettings, | ||
| 153 | primaryEnvironmentId, | ||
| 154 | resolveEnvironmentLabel: (id) => id, | ||
| 155 | }); | ||
| 156 | const oldKey = derivePhysicalProjectKey(projects[1]!); | ||
| 157 | const unloaded = build(false); | ||
| 158 | const groups = build(true); | ||
| 159 | const scratchKey = groups[0]!.projectKey; | ||
| 160 | expect(groups).toHaveLength(1); | ||
| 161 | expect(groups[0]?.displayName).toBe("No project"); | ||
| 162 | expect(groups[0]?.memberProjects).toHaveLength(2); | ||
| 163 | expect(groups[0]?.memberProjectRefs).toEqual( | ||
| 164 | projects.map((project) => ({ environmentId: project.environmentId, projectId: project.id })), | ||
| 165 | ); | ||
| 166 | expect(resolveSidebarProjectScopeKey({ groups: unloaded, key: oldKey })).toBe(oldKey); | ||
| 167 | expect(resolveSidebarProjectScopeKey({ groups, key: oldKey, canClearMissingScope: true })).toBe( | ||
| 168 | scratchKey, | ||
| 169 | ); | ||
| 170 | expect(resolveSidebarProjectScopeKey({ groups: unloaded, key: scratchKey })).toBe(scratchKey); | ||
| 171 | expect( | ||
| 172 | resolveSidebarProjectScopeKey({ groups, key: scratchKey, canClearMissingScope: true }), | ||
| 173 | ).toBe(scratchKey); | ||
| 174 | expect( | ||
| 175 | resolveSidebarProjectScopeKey({ groups, key: "removed", canClearMissingScope: true }), | ||
| 176 | ).toBeNull(); | ||
| 177 | }); | ||
| 178 | |||
| 179 | it("does not migrate an ordinary physical filter into a repository group", () => { | ||
| 180 | const project = makeProject({ repositoryIdentity, title: "No project" }); | ||
| 181 | const groups = buildSidebarProjectSnapshots({ | ||
| 182 | projects: [project], | ||
| 183 | settings: defaultGroupingSettings, | ||
| 184 | primaryEnvironmentId, | ||
| 185 | resolveEnvironmentLabel: () => null, | ||
| 186 | }); | ||
| 187 | expect( | ||
| 188 | resolveSidebarProjectScopeKey({ | ||
| 189 | groups, | ||
| 190 | key: derivePhysicalProjectKey(project), | ||
| 191 | canClearMissingScope: true, | ||
| 192 | }), | ||
| 193 | ).toBeNull(); | ||
| 194 | }); | ||
| 195 | |||
| 55 | it("groups matching repository identities across environments", () => { | 196 | it("groups matching repository identities across environments", () => { |
| 56 | const primary = makeProject({ repositoryIdentity }); | 197 | const primary = makeProject({ repositoryIdentity }); |
| 57 | const remote = makeProject({ | 198 | const remote = makeProject({ |
apps/web/src/sidebarProjectGrouping.ts+49-1| ... | @@ -1,5 +1,10 @@ | ... | @@ -1,5 +1,10 @@ |
| 1 | import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; |
| 2 | import { buildProjectGroups, type ProjectGroupingSettings } from "./logicalProject"; | 2 | import { |
| 3 | buildProjectGroups, | ||
| 4 | derivePhysicalProjectKey, | ||
| 5 | type ProjectGroupingSettings, | ||
| 6 | } from "./logicalProject"; | ||
| 7 | import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; | ||
| 3 | import type { Project } from "./types"; | 8 | import type { Project } from "./types"; |
| 4 | 9 | ||
| 5 | export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; | 10 | export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; |
| ... | @@ -45,6 +50,47 @@ export interface SidebarProjectPickerEntry { | ... | @@ -45,6 +50,47 @@ export interface SidebarProjectPickerEntry { |
| 45 | isPreferred: boolean; | 50 | isPreferred: boolean; |
| 46 | } | 51 | } |
| 47 | 52 | ||
| 53 | /** A merged filter still opens settings for the chosen physical project. */ | ||
| 54 | export function getSidebarProjectSettingsKey( | ||
| 55 | group: SidebarProjectSnapshot, | ||
| 56 | settings: ProjectGroupingSettings, | ||
| 57 | projects: ReadonlyArray<Project>, | ||
| 58 | projectRef?: ScopedProjectRef, | ||
| 59 | ): string { | ||
| 60 | if (!group.isScratch) return group.projectKey; | ||
| 61 | const project = | ||
| 62 | group.memberProjects.find( | ||
| 63 | (member) => | ||
| 64 | member.environmentId === projectRef?.environmentId && member.id === projectRef.projectId, | ||
| 65 | ) ?? | ||
| 66 | group.memberProjects.find((member) => member.environmentId === projectRef?.environmentId) ?? | ||
| 67 | group; | ||
| 68 | return ( | ||
| 69 | buildPhysicalToLogicalProjectKeyMap({ projects, settings, primaryEnvironmentId: null }).get( | ||
| 70 | derivePhysicalProjectKey(project), | ||
| 71 | ) ?? group.projectKey | ||
| 72 | ); | ||
| 73 | } | ||
| 74 | |||
| 75 | /** Retains a saved physical scratch scope when its environment config coalesces the group. */ | ||
| 76 | export function resolveSidebarProjectScopeKey(input: { | ||
| 77 | readonly groups: ReadonlyArray<SidebarProjectSnapshot>; | ||
| 78 | readonly key: string | null; | ||
| 79 | readonly settings?: ProjectGroupingSettings; | ||
| 80 | readonly canClearMissingScope?: boolean; | ||
| 81 | }): string | null { | ||
| 82 | if (input.key === null) return null; | ||
| 83 | const group = | ||
| 84 | input.groups.find((candidate) => candidate.projectKey === input.key) ?? | ||
| 85 | input.groups.find( | ||
| 86 | (candidate) => | ||
| 87 | candidate.isScratch && | ||
| 88 | resolveScratchProjectScopeKey(input.key, candidate.memberProjects, input.settings) === | ||
| 89 | candidate.projectKey, | ||
| 90 | ); | ||
| 91 | return group?.projectKey ?? (input.canClearMissingScope ? null : input.key); | ||
| 92 | } | ||
| 93 | |||
| 48 | export function buildPhysicalToLogicalProjectKeyMap(input: { | 94 | export function buildPhysicalToLogicalProjectKeyMap(input: { |
| 49 | projects: ReadonlyArray<Project>; | 95 | projects: ReadonlyArray<Project>; |
| 50 | settings: ProjectGroupingSettings; | 96 | settings: ProjectGroupingSettings; |
| ... | @@ -67,6 +113,7 @@ export function buildPhysicalToLogicalProjectKeyMap(input: { | ... | @@ -67,6 +113,7 @@ export function buildPhysicalToLogicalProjectKeyMap(input: { |
| 67 | export function buildSidebarProjectSnapshots(input: { | 113 | export function buildSidebarProjectSnapshots(input: { |
| 68 | projects: ReadonlyArray<Project>; | 114 | projects: ReadonlyArray<Project>; |
| 69 | settings: ProjectGroupingSettings; | 115 | settings: ProjectGroupingSettings; |
| 116 | groupScratchProjects?: boolean; | ||
| 70 | primaryEnvironmentId: EnvironmentId | null; | 117 | primaryEnvironmentId: EnvironmentId | null; |
| 71 | resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; | 118 | resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; |
| 72 | // Returns true when an env id maps to a desktop-local saved-env | 119 | // Returns true when an env id maps to a desktop-local saved-env |
| ... | @@ -78,6 +125,7 @@ export function buildSidebarProjectSnapshots(input: { | ... | @@ -78,6 +125,7 @@ export function buildSidebarProjectSnapshots(input: { |
| 78 | }): SidebarProjectSnapshot[] { | 125 | }): SidebarProjectSnapshot[] { |
| 79 | return buildProjectGroups({ | 126 | return buildProjectGroups({ |
| 80 | projects: input.projects, | 127 | projects: input.projects, |
| 128 | groupScratchProjects: input.groupScratchProjects ?? false, | ||
| 81 | settings: input.settings, | 129 | settings: input.settings, |
| 82 | preferredEnvironmentId: input.primaryEnvironmentId, | 130 | preferredEnvironmentId: input.primaryEnvironmentId, |
| 83 | }).map((group): SidebarProjectSnapshot => { | 131 | }).map((group): SidebarProjectSnapshot => { |
apps/web/src/state/projects.ts+2| ... | @@ -6,10 +6,12 @@ import { WS_METHODS } from "@t3tools/contracts"; | ... | @@ -6,10 +6,12 @@ import { WS_METHODS } from "@t3tools/contracts"; |
| 6 | import { environmentCatalog } from "../connection/catalog"; | 6 | import { environmentCatalog } from "../connection/catalog"; |
| 7 | import { connectionAtomRuntime } from "../connection/runtime"; | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | import { environmentSnapshotAtom } from "./shell"; | 8 | import { environmentSnapshotAtom } from "./shell"; |
| 9 | import { serverEnvironment } from "./server"; | ||
| 9 | 10 | ||
| 10 | export const environmentProjects = createEnvironmentProjectAtoms({ | 11 | export const environmentProjects = createEnvironmentProjectAtoms({ |
| 11 | catalogValueAtom: environmentCatalog.catalogValueAtom, | 12 | catalogValueAtom: environmentCatalog.catalogValueAtom, |
| 12 | snapshotAtom: environmentSnapshotAtom, | 13 | snapshotAtom: environmentSnapshotAtom, |
| 14 | serverConfigValueAtom: serverEnvironment.configValueAtom, | ||
| 13 | }); | 15 | }); |
| 14 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { | 16 | export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { |
| 15 | projectAtom: environmentProjects.projectAtom, | 17 | projectAtom: environmentProjects.projectAtom, |
packages/client-runtime/src/state/models.ts+2| ... | @@ -22,6 +22,8 @@ import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; | ... | @@ -22,6 +22,8 @@ import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; |
| 22 | 22 | ||
| 23 | export interface EnvironmentProject extends OrchestrationProjectShell { | 23 | export interface EnvironmentProject extends OrchestrationProjectShell { |
| 24 | readonly environmentId: EnvironmentId; | 24 | readonly environmentId: EnvironmentId; |
| 25 | /** Derived from the environment's configured scratch root, never persisted. */ | ||
| 26 | readonly isScratch?: true; | ||
| 25 | } | 27 | } |
| 26 | 28 | ||
| 27 | /** | 29 | /** |
packages/client-runtime/src/state/projectEntities.test.ts created+135| ... | @@ -0,0 +1,135 @@ | ||
| 1 | import { EnvironmentId, ProjectId, type ServerConfig } from "@t3tools/contracts"; | ||
| 2 | import { Atom, AtomRegistry } from "effect/reactivity"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { PrimaryConnectionTarget } from "../connection/model.ts"; | ||
| 7 | import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; | ||
| 8 | import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; | ||
| 9 | |||
| 10 | const environmentId = EnvironmentId.make("local"); | ||
| 11 | const remoteEnvironmentId = EnvironmentId.make("remote"); | ||
| 12 | |||
| 13 | function makeHarness(withConfig = true) { | ||
| 14 | const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => | ||
| 15 | Atom.make({ | ||
| 16 | ...v2ShellSnapshot, | ||
| 17 | projects: [ | ||
| 18 | { ...v2Project, title: "No project" }, | ||
| 19 | { | ||
| 20 | ...v2Project, | ||
| 21 | title: "No project", | ||
| 22 | id: ProjectId.make("ordinary"), | ||
| 23 | workspaceRoot: "/repo", | ||
| 24 | }, | ||
| 25 | ], | ||
| 26 | }), | ||
| 27 | ); | ||
| 28 | const configAtom = Atom.family((_environmentId: EnvironmentId) => | ||
| 29 | Atom.make<(Pick<ServerConfig, "scratchWorkspaceRoot"> & { revision?: number }) | null>(null), | ||
| 30 | ); | ||
| 31 | const catalogValueAtom = Atom.make({ | ||
| 32 | isReady: true, | ||
| 33 | entries: new Map( | ||
| 34 | [environmentId, remoteEnvironmentId].map((id) => [ | ||
| 35 | id, | ||
| 36 | { | ||
| 37 | target: new PrimaryConnectionTarget({ | ||
| 38 | environmentId: id, | ||
| 39 | label: id, | ||
| 40 | httpBaseUrl: `https://${id}.example.test`, | ||
| 41 | wsBaseUrl: `wss://${id}.example.test`, | ||
| 42 | }), | ||
| 43 | profile: Option.none(), | ||
| 44 | enabled: true, | ||
| 45 | }, | ||
| 46 | ]), | ||
| 47 | ), | ||
| 48 | }); | ||
| 49 | const projects = createEnvironmentProjectAtoms({ | ||
| 50 | catalogValueAtom, | ||
| 51 | snapshotAtom, | ||
| 52 | ...(withConfig ? { serverConfigValueAtom: configAtom } : {}), | ||
| 53 | }); | ||
| 54 | return { registry: AtomRegistry.make(), projects, configAtom, snapshotAtom, catalogValueAtom }; | ||
| 55 | } | ||
| 56 | |||
| 57 | describe("project scratch identity", () => { | ||
| 58 | it("waits for enabled project configs and ignores a disabled environment with no config", () => { | ||
| 59 | const { registry, projects, configAtom, catalogValueAtom } = makeHarness(); | ||
| 60 | try { | ||
| 61 | expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); | ||
| 62 | registry.set(configAtom(environmentId), { scratchWorkspaceRoot: v2Project.workspaceRoot }); | ||
| 63 | expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); | ||
| 64 | const catalog = registry.get(catalogValueAtom); | ||
| 65 | registry.set(catalogValueAtom, { | ||
| 66 | ...catalog, | ||
| 67 | entries: new Map( | ||
| 68 | [...catalog.entries].map(([id, entry]) => [ | ||
| 69 | id, | ||
| 70 | { ...entry, enabled: id === environmentId }, | ||
| 71 | ]), | ||
| 72 | ), | ||
| 73 | }); | ||
| 74 | expect(registry.get(projects.projectConfigsReadyAtom)).toBe(true); | ||
| 75 | registry.set(catalogValueAtom, catalog); | ||
| 76 | expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); | ||
| 77 | registry.set(configAtom(remoteEnvironmentId), {}); | ||
| 78 | expect(registry.get(projects.projectConfigsReadyAtom)).toBe(true); | ||
| 79 | registry.set(catalogValueAtom, { ...catalog, isReady: false }); | ||
| 80 | expect(registry.get(projects.projectConfigsReadyAtom)).toBe(false); | ||
| 81 | } finally { | ||
| 82 | registry.dispose(); | ||
| 83 | } | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("updates a loaded project when config arrives, preserving unrelated projects and snapshots", () => { | ||
| 87 | const { registry, projects, configAtom, snapshotAtom } = makeHarness(); | ||
| 88 | try { | ||
| 89 | const initial = registry.get(projects.projectsAtom); | ||
| 90 | const source = registry.get(snapshotAtom(environmentId)); | ||
| 91 | registry.set(configAtom(environmentId), { | ||
| 92 | scratchWorkspaceRoot: `${v2Project.workspaceRoot}/`, | ||
| 93 | }); | ||
| 94 | const next = registry.get(projects.projectsAtom); | ||
| 95 | |||
| 96 | expect(next[0]?.isScratch).toBe(true); | ||
| 97 | expect(next[0]).not.toBe(initial[0]); | ||
| 98 | expect(next.slice(1)).toEqual(initial.slice(1)); | ||
| 99 | for (let index = 1; index < initial.length; index++) { | ||
| 100 | expect(next[index]).toBe(initial[index]); | ||
| 101 | } | ||
| 102 | expect(registry.get(snapshotAtom(environmentId))).toBe(source); | ||
| 103 | expect(v2Project).not.toHaveProperty("isScratch"); | ||
| 104 | |||
| 105 | registry.set(configAtom(environmentId), { | ||
| 106 | scratchWorkspaceRoot: v2Project.workspaceRoot, | ||
| 107 | revision: 1, | ||
| 108 | }); | ||
| 109 | expect(registry.get(projects.projectsAtom)).toBe(next); | ||
| 110 | |||
| 111 | registry.set(configAtom(environmentId), { scratchWorkspaceRoot: "/repo" }); | ||
| 112 | const changedRoot = registry.get(projects.projectsAtom); | ||
| 113 | expect(changedRoot[0]).not.toHaveProperty("isScratch"); | ||
| 114 | expect(changedRoot[1]?.isScratch).toBe(true); | ||
| 115 | expect(changedRoot[2]).toBe(initial[2]); | ||
| 116 | |||
| 117 | registry.set(configAtom(environmentId), null); | ||
| 118 | expect(registry.get(projects.projectsAtom).every((project) => !project.isScratch)).toBe(true); | ||
| 119 | } finally { | ||
| 120 | registry.dispose(); | ||
| 121 | } | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("keeps callers without server configs working without marking a same-title project", () => { | ||
| 125 | const { registry, projects, configAtom } = makeHarness(false); | ||
| 126 | try { | ||
| 127 | const initial = registry.get(projects.projectsAtom); | ||
| 128 | registry.set(configAtom(environmentId), { scratchWorkspaceRoot: v2Project.workspaceRoot }); | ||
| 129 | expect(registry.get(projects.projectsAtom)).toBe(initial); | ||
| 130 | expect(initial.every((project) => !project.isScratch)).toBe(true); | ||
| 131 | } finally { | ||
| 132 | registry.dispose(); | ||
| 133 | } | ||
| 134 | }); | ||
| 135 | }); | ||
packages/client-runtime/src/state/projectEntities.ts+35-2| ... | @@ -4,11 +4,13 @@ import type { | ... | @@ -4,11 +4,13 @@ import type { |
| 4 | OrchestrationV2ShellSnapshot, | 4 | OrchestrationV2ShellSnapshot, |
| 5 | ProjectId, | 5 | ProjectId, |
| 6 | ScopedProjectRef, | 6 | ScopedProjectRef, |
| 7 | ServerConfig, | ||
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { Atom } from "effect/reactivity"; | 9 | import { Atom } from "effect/reactivity"; |
| 9 | 10 | ||
| 10 | import type { EnvironmentProject } from "./models.ts"; | 11 | import type { EnvironmentProject } from "./models.ts"; |
| 11 | import { scopeProject } from "./models.ts"; | 12 | import { scopeProject } from "./models.ts"; |
| 13 | import { isScratchProject } from "./projects.ts"; | ||
| 12 | import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; | 14 | import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts"; |
| 13 | import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; | 15 | import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; |
| 14 | 16 | ||
| ... | @@ -20,6 +22,9 @@ export function createEnvironmentProjectAtoms(input: { | ... | @@ -20,6 +22,9 @@ export function createEnvironmentProjectAtoms(input: { |
| 20 | readonly snapshotAtom: ( | 22 | readonly snapshotAtom: ( |
| 21 | environmentId: EnvironmentId, | 23 | environmentId: EnvironmentId, |
| 22 | ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>; | 24 | ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>; |
| 25 | readonly serverConfigValueAtom?: ( | ||
| 26 | environmentId: EnvironmentId, | ||
| 27 | ) => Atom.Atom<Pick<ServerConfig, "scratchWorkspaceRoot"> | null>; | ||
| 23 | }) { | 28 | }) { |
| 24 | const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => | 29 | const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => |
| 25 | Atom.make( | 30 | Atom.make( |
| ... | @@ -56,14 +61,30 @@ export function createEnvironmentProjectAtoms(input: { | ... | @@ -56,14 +61,30 @@ export function createEnvironmentProjectAtoms(input: { |
| 56 | const projectAtomFamily = Atom.family((key: string) => { | 61 | const projectAtomFamily = Atom.family((key: string) => { |
| 57 | const ref = parseProjectKey(key); | 62 | const ref = parseProjectKey(key); |
| 58 | let previousSource: OrchestrationProjectShell | null = null; | 63 | let previousSource: OrchestrationProjectShell | null = null; |
| 64 | let previousIsScratch = false; | ||
| 59 | let previousValue: EnvironmentProject | null = null; | 65 | let previousValue: EnvironmentProject | null = null; |
| 60 | return Atom.make((get) => { | 66 | return Atom.make((get) => { |
| 61 | const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null; | 67 | const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null; |
| 62 | if (source === previousSource) { | 68 | const scratch = |
| 69 | source !== null && | ||
| 70 | isScratchProject( | ||
| 71 | source, | ||
| 72 | input.serverConfigValueAtom | ||
| 73 | ? get(input.serverConfigValueAtom(ref.environmentId))?.scratchWorkspaceRoot | ||
| 74 | : null, | ||
| 75 | ); | ||
| 76 | if (source === previousSource && scratch === previousIsScratch) { | ||
| 63 | return previousValue; | 77 | return previousValue; |
| 64 | } | 78 | } |
| 65 | previousSource = source; | 79 | previousSource = source; |
| 66 | previousValue = source === null ? null : scopeProject(ref.environmentId, source); | 80 | previousIsScratch = scratch; |
| 81 | previousValue = | ||
| 82 | source === null | ||
| 83 | ? null | ||
| 84 | : { | ||
| 85 | ...scopeProject(ref.environmentId, source), | ||
| 86 | ...(scratch ? { isScratch: true as const } : {}), | ||
| 87 | }; | ||
| 67 | return previousValue; | 88 | return previousValue; |
| 68 | }).pipe(Atom.withLabel(`environment-project:${key}`)); | 89 | }).pipe(Atom.withLabel(`environment-project:${key}`)); |
| 69 | }); | 90 | }); |
| ... | @@ -94,12 +115,24 @@ export function createEnvironmentProjectAtoms(input: { | ... | @@ -94,12 +115,24 @@ export function createEnvironmentProjectAtoms(input: { |
| 94 | return previousProjects; | 115 | return previousProjects; |
| 95 | }).pipe(Atom.withLabel("environment-project-list")); | 116 | }).pipe(Atom.withLabel("environment-project-list")); |
| 96 | 117 | ||
| 118 | const projectConfigsReadyAtom = Atom.make((get) => { | ||
| 119 | const catalog = get(input.catalogValueAtom); | ||
| 120 | if (!catalog.isReady) return false; | ||
| 121 | for (const environmentId of enabledEnvironmentIds(catalog)) { | ||
| 122 | if (input.serverConfigValueAtom && get(input.serverConfigValueAtom(environmentId)) === null) { | ||
| 123 | return false; | ||
| 124 | } | ||
| 125 | } | ||
| 126 | return true; | ||
| 127 | }).pipe(Atom.withLabel("environment-project-configs-ready")); | ||
| 128 | |||
| 97 | return { | 129 | return { |
| 98 | environmentProjectsAtom, | 130 | environmentProjectsAtom, |
| 99 | environmentProjectIndexAtom, | 131 | environmentProjectIndexAtom, |
| 100 | environmentProjectRefsAtom, | 132 | environmentProjectRefsAtom, |
| 101 | projectRefsAtom, | 133 | projectRefsAtom, |
| 102 | projectsAtom, | 134 | projectsAtom, |
| 135 | projectConfigsReadyAtom, | ||
| 103 | projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), | 136 | projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), |
| 104 | }; | 137 | }; |
| 105 | } | 138 | } |
packages/client-runtime/src/state/projectGrouping.test.ts+127| ... | @@ -5,7 +5,9 @@ import type { EnvironmentProject } from "./models.ts"; | ... | @@ -5,7 +5,9 @@ import type { EnvironmentProject } from "./models.ts"; |
| 5 | import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; | 5 | import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; |
| 6 | import { | 6 | import { |
| 7 | buildProjectGroups, | 7 | buildProjectGroups, |
| 8 | deriveLogicalProjectKeyFromSettings, | ||
| 8 | derivePhysicalProjectKey, | 9 | derivePhysicalProjectKey, |
| 10 | resolveScratchProjectScopeKey, | ||
| 9 | type ProjectGroupingSettings, | 11 | type ProjectGroupingSettings, |
| 10 | } from "./projectGrouping.ts"; | 12 | } from "./projectGrouping.ts"; |
| 11 | 13 | ||
| ... | @@ -117,6 +119,131 @@ function settings( | ... | @@ -117,6 +119,131 @@ function settings( |
| 117 | } | 119 | } |
| 118 | 120 | ||
| 119 | describe("buildProjectGroups", () => { | 121 | describe("buildProjectGroups", () => { |
| 122 | it.each(["repository", "repository_path", "separate"] as const)( | ||
| 123 | "keeps scratch project ownership separate by default in %s mode", | ||
| 124 | (mode) => { | ||
| 125 | const projects = [ | ||
| 126 | makeProject("scratch-local", "/local/scratch", { | ||
| 127 | isScratch: true, | ||
| 128 | repositoryIdentity: null, | ||
| 129 | }), | ||
| 130 | makeProject("scratch-remote", "/remote/scratch", { | ||
| 131 | environmentId: EnvironmentId.make("remote"), | ||
| 132 | isScratch: true, | ||
| 133 | repositoryIdentity: null, | ||
| 134 | }), | ||
| 135 | ]; | ||
| 136 | const groups = buildProjectGroups({ projects, settings: settings(mode) }); | ||
| 137 | expect(groups.map((group) => group.key)).toEqual(projects.map(derivePhysicalProjectKey)); | ||
| 138 | expect(groups.map((group) => group.memberProjectRefs)).toEqual( | ||
| 139 | projects.map((project) => [ | ||
| 140 | { environmentId: project.environmentId, projectId: project.id }, | ||
| 141 | ]), | ||
| 142 | ); | ||
| 143 | expect( | ||
| 144 | buildProjectGroups({ projects, settings: settings(mode), groupScratchProjects: true }), | ||
| 145 | ).toHaveLength(1); | ||
| 146 | }, | ||
| 147 | ); | ||
| 148 | |||
| 149 | it.each(["repository", "repository_path", "separate"] as const)( | ||
| 150 | "migrates a pre-config %s scratch selection and retains it after the original host is removed", | ||
| 151 | (mode) => { | ||
| 152 | const local = makeProject("scratch-local", "/local/repo/scratch", { | ||
| 153 | repositoryIdentity: { ...repositoryIdentity, rootPath: "/local/repo" }, | ||
| 154 | }); | ||
| 155 | const remote = makeProject("scratch-remote", "/remote/scratch", { | ||
| 156 | environmentId: EnvironmentId.make("remote"), | ||
| 157 | repositoryIdentity: null, | ||
| 158 | isScratch: true, | ||
| 159 | }); | ||
| 160 | const groupingSettings = settings(mode); | ||
| 161 | const selectedBeforeConfig = deriveLogicalProjectKeyFromSettings(local, groupingSettings); | ||
| 162 | const loaded = [{ ...local, isScratch: true as const }, remote]; | ||
| 163 | const migrated = resolveScratchProjectScopeKey( | ||
| 164 | selectedBeforeConfig, | ||
| 165 | loaded, | ||
| 166 | groupingSettings, | ||
| 167 | ); | ||
| 168 | const scratchKey = buildProjectGroups({ | ||
| 169 | projects: loaded, | ||
| 170 | settings: groupingSettings, | ||
| 171 | groupScratchProjects: true, | ||
| 172 | })[0]!.key; | ||
| 173 | expect(migrated).toBe(scratchKey); | ||
| 174 | expect(resolveScratchProjectScopeKey(migrated, [remote], groupingSettings)).toBe(scratchKey); | ||
| 175 | expect( | ||
| 176 | buildProjectGroups({ | ||
| 177 | projects: [remote], | ||
| 178 | settings: groupingSettings, | ||
| 179 | groupScratchProjects: true, | ||
| 180 | })[0]?.memberProjectRefs, | ||
| 181 | ).toEqual([{ environmentId: remote.environmentId, projectId: remote.id }]); | ||
| 182 | }, | ||
| 183 | ); | ||
| 184 | |||
| 185 | it("retains a repository filter that still belongs to an ordinary project", () => { | ||
| 186 | const scratch = makeProject("scratch", "/scratch", { isScratch: true }); | ||
| 187 | const ordinary = makeProject("ordinary", "/ordinary"); | ||
| 188 | const key = deriveLogicalProjectKeyFromSettings(ordinary, settings("repository")); | ||
| 189 | expect(resolveScratchProjectScopeKey(key, [scratch, ordinary])).toBe(key); | ||
| 190 | }); | ||
| 191 | |||
| 192 | it.each(["repository", "repository_path", "separate"] as const)( | ||
| 193 | "coalesces scratch homes in %s mode without merging ordinary same-title projects", | ||
| 194 | (mode) => { | ||
| 195 | const remoteEnvironmentId = EnvironmentId.make("remote"); | ||
| 196 | const projects = [ | ||
| 197 | makeProject("scratch-local", "/local/scratch", { | ||
| 198 | title: "No project", | ||
| 199 | isScratch: true, | ||
| 200 | repositoryIdentity: null, | ||
| 201 | }), | ||
| 202 | makeProject("scratch-remote", "/remote/scratch", { | ||
| 203 | title: "No project", | ||
| 204 | environmentId: remoteEnvironmentId, | ||
| 205 | isScratch: true, | ||
| 206 | }), | ||
| 207 | makeProject("scratch-duplicate", "/remote/scratch/", { | ||
| 208 | title: "No project", | ||
| 209 | environmentId: remoteEnvironmentId, | ||
| 210 | isScratch: true, | ||
| 211 | repositoryIdentity: null, | ||
| 212 | updatedAt: "2026-07-02T00:00:00.000Z", | ||
| 213 | }), | ||
| 214 | makeProject("ordinary", "/ordinary", { title: "No project" }), | ||
| 215 | makeProject("reserved-collision", "/other", { | ||
| 216 | title: "No project", | ||
| 217 | repositoryIdentity: { ...repositoryIdentity, canonicalKey: "\0no-project" }, | ||
| 218 | }), | ||
| 219 | ]; | ||
| 220 | const groups = buildProjectGroups({ | ||
| 221 | projects, | ||
| 222 | groupScratchProjects: true, | ||
| 223 | settings: settings(mode, { [derivePhysicalProjectKey(projects[0]!)]: "separate" }), | ||
| 224 | preferredEnvironmentId: remoteEnvironmentId, | ||
| 225 | }); | ||
| 226 | expect(groups).toHaveLength(3); | ||
| 227 | const scratch = groups.find((group) => group.representative.isScratch)!; | ||
| 228 | expect(scratch.label).toBe("No project"); | ||
| 229 | expect(scratch.representative.id).toBe(projects[2]?.id); | ||
| 230 | expect(scratch.members).toHaveLength(2); | ||
| 231 | expect(scratch.memberProjectRefs).toEqual( | ||
| 232 | projects.slice(0, 3).map((project) => ({ | ||
| 233 | environmentId: project.environmentId, | ||
| 234 | projectId: project.id, | ||
| 235 | })), | ||
| 236 | ); | ||
| 237 | expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[1]!), projects)).toBe( | ||
| 238 | scratch.key, | ||
| 239 | ); | ||
| 240 | expect(resolveScratchProjectScopeKey(derivePhysicalProjectKey(projects[3]!), projects)).toBe( | ||
| 241 | derivePhysicalProjectKey(projects[3]!), | ||
| 242 | ); | ||
| 243 | expect(groups.filter((group) => !group.representative.isScratch)).toHaveLength(2); | ||
| 244 | }, | ||
| 245 | ); | ||
| 246 | |||
| 120 | it("preserves every physical clone as a selectable member in repository modes", () => { | 247 | it("preserves every physical clone as a selectable member in repository modes", () => { |
| 121 | const projects = [ | 248 | const projects = [ |
| 122 | makeProject("t3code", "/work/t3code"), | 249 | makeProject("t3code", "/work/t3code"), |
packages/client-runtime/src/state/projectGrouping.ts+60-10| ... | @@ -11,6 +11,8 @@ import type { ClientSettings } from "@t3tools/contracts/settings"; | ... | @@ -11,6 +11,8 @@ import type { ClientSettings } from "@t3tools/contracts/settings"; |
| 11 | import type { EnvironmentProject } from "./models.ts"; | 11 | import type { EnvironmentProject } from "./models.ts"; |
| 12 | import { normalizeProjectPathForComparison } from "./projects.ts"; | 12 | import { normalizeProjectPathForComparison } from "./projects.ts"; |
| 13 | 13 | ||
| 14 | const SCRATCH_PROJECT_SCOPE_KEY = "\0no-project"; | ||
| 15 | |||
| 14 | export interface ProjectGroupingSettings { | 16 | export interface ProjectGroupingSettings { |
| 15 | readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode; | 17 | readonly sidebarProjectGroupingMode: SidebarProjectGroupingMode; |
| 16 | readonly sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>; | 18 | readonly sidebarProjectGroupingOverrides: Record<string, SidebarProjectGroupingMode>; |
| ... | @@ -105,7 +107,8 @@ function deriveRepositoryScopedKey( | ... | @@ -105,7 +107,8 @@ function deriveRepositoryScopedKey( |
| 105 | const canonicalKey = project.repositoryIdentity | 107 | const canonicalKey = project.repositoryIdentity |
| 106 | ? repositoryGroupingKeyOf(project.repositoryIdentity) | 108 | ? repositoryGroupingKeyOf(project.repositoryIdentity) |
| 107 | : null; | 109 | : null; |
| 108 | if (!canonicalKey) { | 110 | // A NUL reserves the scratch key namespace outside valid repository identities. |
| 111 | if (!canonicalKey || canonicalKey.includes("\0")) { | ||
| 109 | return null; | 112 | return null; |
| 110 | } | 113 | } |
| 111 | 114 | ||
| ... | @@ -126,12 +129,14 @@ function deriveRepositoryScopedKey( | ... | @@ -126,12 +129,14 @@ function deriveRepositoryScopedKey( |
| 126 | export function deriveLogicalProjectKey( | 129 | export function deriveLogicalProjectKey( |
| 127 | project: Pick< | 130 | project: Pick< |
| 128 | EnvironmentProject, | 131 | EnvironmentProject, |
| 129 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | 132 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" |
| 130 | >, | 133 | >, |
| 131 | options?: { | 134 | options?: { |
| 132 | readonly groupingMode?: SidebarProjectGroupingMode; | 135 | readonly groupingMode?: SidebarProjectGroupingMode; |
| 136 | readonly groupScratchProjects?: boolean; | ||
| 133 | }, | 137 | }, |
| 134 | ): string { | 138 | ): string { |
| 139 | if (options?.groupScratchProjects && project.isScratch) return SCRATCH_PROJECT_SCOPE_KEY; | ||
| 135 | const groupingMode = options?.groupingMode ?? "repository"; | 140 | const groupingMode = options?.groupingMode ?? "repository"; |
| 136 | if (groupingMode === "separate") { | 141 | if (groupingMode === "separate") { |
| 137 | return derivePhysicalProjectKey(project); | 142 | return derivePhysicalProjectKey(project); |
| ... | @@ -147,15 +152,53 @@ export function deriveLogicalProjectKey( | ... | @@ -147,15 +152,53 @@ export function deriveLogicalProjectKey( |
| 147 | export function deriveLogicalProjectKeyFromSettings( | 152 | export function deriveLogicalProjectKeyFromSettings( |
| 148 | project: Pick< | 153 | project: Pick< |
| 149 | EnvironmentProject, | 154 | EnvironmentProject, |
| 150 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | 155 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" |
| 151 | >, | 156 | >, |
| 152 | settings: ProjectGroupingSettings, | 157 | settings: ProjectGroupingSettings, |
| 158 | options?: { readonly groupScratchProjects?: boolean }, | ||
| 153 | ): string { | 159 | ): string { |
| 154 | return deriveLogicalProjectKey(project, { | 160 | return deriveLogicalProjectKey(project, { |
| 155 | groupingMode: resolveProjectGroupingMode(project, settings), | 161 | groupingMode: resolveProjectGroupingMode(project, settings), |
| 162 | groupScratchProjects: options?.groupScratchProjects ?? false, | ||
| 156 | }); | 163 | }); |
| 157 | } | 164 | } |
| 158 | 165 | ||
| 166 | /** Resolves a scratch filter selected before its environment config identified the project. */ | ||
| 167 | export function resolveScratchProjectScopeKey( | ||
| 168 | key: string | null, | ||
| 169 | projects: ReadonlyArray< | ||
| 170 | Pick< | ||
| 171 | EnvironmentProject, | ||
| 172 | "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" | ||
| 173 | > | ||
| 174 | >, | ||
| 175 | settings: ProjectGroupingSettings = { | ||
| 176 | sidebarProjectGroupingMode: "repository", | ||
| 177 | sidebarProjectGroupingOverrides: {}, | ||
| 178 | }, | ||
| 179 | ): string | null { | ||
| 180 | if (key === null || key === SCRATCH_PROJECT_SCOPE_KEY) return key; | ||
| 181 | if ( | ||
| 182 | projects.some( | ||
| 183 | (project) => | ||
| 184 | deriveLogicalProjectKeyFromSettings(project, settings, { groupScratchProjects: true }) === | ||
| 185 | key, | ||
| 186 | ) | ||
| 187 | ) { | ||
| 188 | return key; | ||
| 189 | } | ||
| 190 | return projects.some((project) => { | ||
| 191 | if (!project.isScratch) return false; | ||
| 192 | const mode = resolveProjectGroupingMode(project, settings); | ||
| 193 | return ( | ||
| 194 | derivePhysicalProjectKey(project) === key || | ||
| 195 | (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key) | ||
| 196 | ); | ||
| 197 | }) | ||
| 198 | ? SCRATCH_PROJECT_SCOPE_KEY | ||
| 199 | : key; | ||
| 200 | } | ||
| 201 | |||
| 159 | export function deriveProjectGroupLabel(input: { | 202 | export function deriveProjectGroupLabel(input: { |
| 160 | readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">; | 203 | readonly representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">; |
| 161 | readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>; | 204 | readonly members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>; |
| ... | @@ -254,6 +297,8 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { | ... | @@ -254,6 +297,8 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { |
| 254 | readonly projects: ReadonlyArray<TProject>; | 297 | readonly projects: ReadonlyArray<TProject>; |
| 255 | readonly settings: ProjectGroupingSettings; | 298 | readonly settings: ProjectGroupingSettings; |
| 256 | readonly preferredEnvironmentId?: EnvironmentId | null; | 299 | readonly preferredEnvironmentId?: EnvironmentId | null; |
| 300 | /** Filter equivalence does not change the default ownership of mutable project settings. */ | ||
| 301 | readonly groupScratchProjects?: boolean; | ||
| 257 | }): ReadonlyArray<ProjectGroup<TProject>> { | 302 | }): ReadonlyArray<ProjectGroup<TProject>> { |
| 258 | const projectsByPhysicalKey = new Map<string, TProject[]>(); | 303 | const projectsByPhysicalKey = new Map<string, TProject[]>(); |
| 259 | for (const project of input.projects) { | 304 | for (const project of input.projects) { |
| ... | @@ -275,6 +320,7 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { | ... | @@ -275,6 +320,7 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { |
| 275 | const identitySource = selectProjectIdentitySource(physicalProjects, winner); | 320 | const identitySource = selectProjectIdentitySource(physicalProjects, winner); |
| 276 | const logicalKey = deriveLogicalProjectKey(identitySource, { | 321 | const logicalKey = deriveLogicalProjectKey(identitySource, { |
| 277 | groupingMode: resolveProjectGroupingMode(winner, input.settings), | 322 | groupingMode: resolveProjectGroupingMode(winner, input.settings), |
| 323 | groupScratchProjects: input.groupScratchProjects ?? false, | ||
| 278 | }); | 324 | }); |
| 279 | logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey); | 325 | logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey); |
| 280 | const member = { physicalProjectKey, project: winner }; | 326 | const member = { physicalProjectKey, project: winner }; |
| ... | @@ -292,7 +338,9 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { | ... | @@ -292,7 +338,9 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { |
| 292 | const physicalProjectKey = derivePhysicalProjectKey(project); | 338 | const physicalProjectKey = derivePhysicalProjectKey(project); |
| 293 | const logicalKey = | 339 | const logicalKey = |
| 294 | logicalKeyByPhysicalKey.get(physicalProjectKey) ?? | 340 | logicalKeyByPhysicalKey.get(physicalProjectKey) ?? |
| 295 | deriveLogicalProjectKeyFromSettings(project, input.settings); | 341 | deriveLogicalProjectKeyFromSettings(project, input.settings, { |
| 342 | groupScratchProjects: input.groupScratchProjects ?? false, | ||
| 343 | }); | ||
| 296 | const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); | 344 | const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); |
| 297 | if (seenProjectRefs.has(projectRefKey)) continue; | 345 | if (seenProjectRefs.has(projectRefKey)) continue; |
| 298 | seenProjectRefs.add(projectRefKey); | 346 | seenProjectRefs.add(projectRefKey); |
| ... | @@ -314,12 +362,14 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { | ... | @@ -314,12 +362,14 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { |
| 314 | return { | 362 | return { |
| 315 | key, | 363 | key, |
| 316 | label: | 364 | label: |
| 317 | members.length > 1 | 365 | input.groupScratchProjects && representative.isScratch |
| 318 | ? deriveProjectGroupLabel({ | 366 | ? "No project" |
| 319 | representative, | 367 | : members.length > 1 |
| 320 | members: members.map((member) => member.project), | 368 | ? deriveProjectGroupLabel({ |
| 321 | }) | 369 | representative, |
| 322 | : representative.title, | 370 | members: members.map((member) => member.project), |
| 371 | }) | ||
| 372 | : representative.title, | ||
| 323 | representative, | 373 | representative, |
| 324 | members, | 374 | members, |
| 325 | memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [], | 375 | memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [], |