| author | |
| committer | |
| log | 464fad9a433c246b4a5b73c8640787c174c2c195 |
| tree | a512c96ffeb9567158fb5e15409bd02bd6259409 |
| parent | cb395ee13b5b5ddd4352e7c3d4216181501cc2e5 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 162 insertions(+), 45 deletions(-)
apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx+33-3| ... | @@ -1,6 +1,11 @@ | ... | @@ -1,6 +1,11 @@ |
| 1 | import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; | ||
| 2 | import { useMemo } from "react"; | ||
| 1 | import { ScrollView } from "react-native"; | 3 | import { ScrollView } from "react-native"; |
| 2 | import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; | 4 | import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; |
| 3 | import { ListFilterMenu } from "../workspace/ListFilterMenu"; | 5 | import { ListFilterMenu } from "../workspace/ListFilterMenu"; |
| 6 | import { useProjects, useThreadShells } from "../../state/entities"; | ||
| 7 | import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; | ||
| 8 | import { buildHomeProjectScopes, sortHomeProjectScopes } from "../home/homeThreadList"; | ||
| 4 | import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; | 9 | import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; |
| 5 | 10 | ||
| 6 | const STATES = { open: "Open", closed: "Closed", merged: "Merged", all: "All states" } as const; | 11 | const STATES = { open: "Open", closed: "Closed", merged: "Merged", all: "All states" } as const; |
| ... | @@ -28,6 +33,30 @@ function choices(field: string, options: Readonly<Record<string, string>>, selec | ... | @@ -28,6 +33,30 @@ function choices(field: string, options: Readonly<Record<string, string>>, selec |
| 28 | })); | 33 | })); |
| 29 | } | 34 | } |
| 30 | 35 | ||
| 36 | /** The project list's own order, which is the order its sidebar and new-task picker show. */ | ||
| 37 | function useProjectsInSidebarOrder<Project extends { id: string; environmentId: string }>( | ||
| 38 | projects: readonly Project[], | ||
| 39 | ) { | ||
| 40 | const allProjects = useProjects(); | ||
| 41 | const threads = useThreadShells(); | ||
| 42 | const { sidebarProjectGroupingMode } = useMobileProjectGroupingSettings(); | ||
| 43 | const scopes = useMemo( | ||
| 44 | () => | ||
| 45 | sortHomeProjectScopes({ | ||
| 46 | scopes: buildHomeProjectScopes({ | ||
| 47 | projects: allProjects, | ||
| 48 | environmentId: null, | ||
| 49 | projectGroupingMode: sidebarProjectGroupingMode, | ||
| 50 | }), | ||
| 51 | threads, | ||
| 52 | pendingTasks: [], | ||
| 53 | projectSortOrder: "updated_at", | ||
| 54 | }).map((scope) => ({ memberProjectRefs: scope.projectRefs })), | ||
| 55 | [allProjects, sidebarProjectGroupingMode, threads], | ||
| 56 | ); | ||
| 57 | return useMemo(() => sortProjectsByGroupOrder(projects, scopes), [projects, scopes]); | ||
| 58 | } | ||
| 59 | |||
| 31 | export function PullRequestListFilters(props: { | 60 | export function PullRequestListFilters(props: { |
| 32 | readonly environments: readonly { id: string; label: string }[]; | 61 | readonly environments: readonly { id: string; label: string }[]; |
| 33 | readonly environmentId: string | null; | 62 | readonly environmentId: string | null; |
| ... | @@ -39,6 +68,7 @@ export function PullRequestListFilters(props: { | ... | @@ -39,6 +68,7 @@ export function PullRequestListFilters(props: { |
| 39 | readonly projects: readonly { id: string; title: string; environmentId: string }[]; | 68 | readonly projects: readonly { id: string; title: string; environmentId: string }[]; |
| 40 | }) { | 69 | }) { |
| 41 | const { value } = props; | 70 | const { value } = props; |
| 71 | const projects = useProjectsInSidebarOrder(props.projects); | ||
| 42 | const selectedLabels = value.filters.labels?.flat() ?? []; | 72 | const selectedLabels = value.filters.labels?.flat() ?? []; |
| 43 | const selectedLabelSet = new Set(selectedLabels); | 73 | const selectedLabelSet = new Set(selectedLabels); |
| 44 | const author = value.filters.author; | 74 | const author = value.filters.author; |
| ... | @@ -76,7 +106,7 @@ export function PullRequestListFilters(props: { | ... | @@ -76,7 +106,7 @@ export function PullRequestListFilters(props: { |
| 76 | props.onChange({ ...value, sort: next as PullRequestListControls["sort"] }); | 106 | props.onChange({ ...value, sort: next as PullRequestListControls["sort"] }); |
| 77 | break; | 107 | break; |
| 78 | case "project": { | 108 | case "project": { |
| 79 | const project = props.projects.find( | 109 | const project = projects.find( |
| 80 | (project) => JSON.stringify([project.environmentId, project.id]) === next, | 110 | (project) => JSON.stringify([project.environmentId, project.id]) === next, |
| 81 | ); | 111 | ); |
| 82 | props.onChange({ | 112 | props.onChange({ |
| ... | @@ -165,7 +195,7 @@ export function PullRequestListFilters(props: { | ... | @@ -165,7 +195,7 @@ export function PullRequestListFilters(props: { |
| 165 | { | 195 | { |
| 166 | id: "projects", | 196 | id: "projects", |
| 167 | title: | 197 | title: |
| 168 | props.projects.find( | 198 | projects.find( |
| 169 | (project) => | 199 | (project) => |
| 170 | project.id === value.projectId && project.environmentId === value.projectEnvironmentId, | 200 | project.id === value.projectId && project.environmentId === value.projectEnvironmentId, |
| 171 | )?.title ?? "Project", | 201 | )?.title ?? "Project", |
| ... | @@ -173,7 +203,7 @@ export function PullRequestListFilters(props: { | ... | @@ -173,7 +203,7 @@ export function PullRequestListFilters(props: { |
| 173 | "project", | 203 | "project", |
| 174 | Object.fromEntries([ | 204 | Object.fromEntries([ |
| 175 | ["", "All projects"], | 205 | ["", "All projects"], |
| 176 | ...props.projects.map((project) => [ | 206 | ...projects.map((project) => [ |
| 177 | JSON.stringify([project.environmentId, project.id]), | 207 | JSON.stringify([project.environmentId, project.id]), |
| 178 | project.title, | 208 | project.title, |
| 179 | ]), | 209 | ]), |
apps/web/src/components/Sidebar.tsx+4-41| ... | @@ -128,21 +128,21 @@ import { isMacPlatform } from "~/lib/utils"; | ... | @@ -128,21 +128,21 @@ import { isMacPlatform } from "~/lib/utils"; |
| 128 | import { useOpenPrLink } from "../lib/openPullRequestLink"; | 128 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 129 | import { readLocalApi } from "../localApi"; | 129 | import { readLocalApi } from "../localApi"; |
| 130 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; | 130 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; |
| 131 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; | 131 | import { selectProjectGroupingSettings } from "../logicalProject"; |
| 132 | import { | 132 | import { |
| 133 | buildSidebarProjectSnapshots, | ||
| 134 | projectGroupsSpanEnvironments, | 133 | projectGroupsSpanEnvironments, |
| 135 | resolveSidebarProjectScopeKey, | 134 | resolveSidebarProjectScopeKey, |
| 136 | getSidebarProjectSettingsKey, | 135 | getSidebarProjectSettingsKey, |
| 137 | type SidebarProjectSnapshot, | 136 | type SidebarProjectSnapshot, |
| 138 | } from "../sidebarProjectGrouping"; | 137 | } from "../sidebarProjectGrouping"; |
| 139 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | 138 | import { useUiStateStore } from "../uiStateStore"; |
| 140 | import { | 139 | import { |
| 141 | getThreadKeysToDeselectAfterDelete, | 140 | getThreadKeysToDeselectAfterDelete, |
| 142 | useThreadSelectionStore, | 141 | useThreadSelectionStore, |
| 143 | } from "../threadSelectionStore"; | 142 | } from "../threadSelectionStore"; |
| 144 | import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; | 143 | import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; |
| 145 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 144 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 145 | import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; | ||
| 146 | import { useTerminalFocus } from "../hooks/useTerminalFocus"; | 146 | import { useTerminalFocus } from "../hooks/useTerminalFocus"; |
| 147 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; | 147 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 148 | import { registerSettleAndAdvance } from "../settleAndAdvance"; | 148 | import { registerSettleAndAdvance } from "../settleAndAdvance"; |
| ... | @@ -224,7 +224,6 @@ import { | ... | @@ -224,7 +224,6 @@ import { |
| 224 | sidebarThreadKeyAtY, | 224 | sidebarThreadKeyAtY, |
| 225 | sortInboxThreadsByReturn, | 225 | sortInboxThreadsByReturn, |
| 226 | sortPinnedThreadsForSidebar, | 226 | sortPinnedThreadsForSidebar, |
| 227 | sortSidebarV2ProjectGroups, | ||
| 228 | sortThreadsForSidebar, | 227 | sortThreadsForSidebar, |
| 229 | sortWorkingThreadsBySend, | 228 | sortWorkingThreadsBySend, |
| 230 | useThreadJumpHintVisibility, | 229 | useThreadJumpHintVisibility, |
| ... | @@ -2420,14 +2419,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -2420,14 +2419,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2420 | 2419 | ||
| 2421 | export default function Sidebar() { | 2420 | export default function Sidebar() { |
| 2422 | const projects = useProjects(); | 2421 | const projects = useProjects(); |
| 2423 | const projectOrder = useUiStateStore((store) => store.projectOrder); | ||
| 2424 | const threads = useThreadShells(); | 2422 | const threads = useThreadShells(); |
| 2425 | const router = useRouter(); | 2423 | const router = useRouter(); |
| 2426 | const { isMobile, setOpenMobile } = useSidebar(); | 2424 | const { isMobile, setOpenMobile } = useSidebar(); |
| 2427 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 2425 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 2428 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | 2426 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 2429 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); | 2427 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 2430 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | ||
| 2431 | const timestampFormat = useClientSettings((s) => s.timestampFormat); | 2428 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| 2432 | const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); | 2429 | const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); |
| 2433 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); | 2430 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| ... | @@ -2545,41 +2542,7 @@ export default function Sidebar() { | ... | @@ -2545,41 +2542,7 @@ export default function Sidebar() { |
| 2545 | [environments], | 2542 | [environments], |
| 2546 | ); | 2543 | ); |
| 2547 | const environmentMachineById = useEnvironmentMachines(); | 2544 | const environmentMachineById = useEnvironmentMachines(); |
| 2548 | const orderedProjects = useMemo( | 2545 | const projectGroups = useSidebarProjectGroups(); |
| 2549 | () => | ||
| 2550 | orderItemsByPreferredIds({ | ||
| 2551 | items: projects, | ||
| 2552 | preferredIds: projectOrder, | ||
| 2553 | getId: getProjectOrderKey, | ||
| 2554 | getPreferenceIds: (project) => [ | ||
| 2555 | getProjectOrderKey(project), | ||
| 2556 | legacyProjectCwdPreferenceKey(project.workspaceRoot), | ||
| 2557 | ], | ||
| 2558 | }), | ||
| 2559 | [projectOrder, projects], | ||
| 2560 | ); | ||
| 2561 | const unsortedProjectGroups = useMemo( | ||
| 2562 | () => | ||
| 2563 | buildSidebarProjectSnapshots({ | ||
| 2564 | projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, | ||
| 2565 | groupScratchProjects: true, | ||
| 2566 | settings: projectGroupingSettings, | ||
| 2567 | primaryEnvironmentId, | ||
| 2568 | resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, | ||
| 2569 | }), | ||
| 2570 | [ | ||
| 2571 | environmentLabelById, | ||
| 2572 | orderedProjects, | ||
| 2573 | primaryEnvironmentId, | ||
| 2574 | projectGroupingSettings, | ||
| 2575 | projects, | ||
| 2576 | sidebarProjectSortOrder, | ||
| 2577 | ], | ||
| 2578 | ); | ||
| 2579 | const projectGroups = useMemo( | ||
| 2580 | () => sortSidebarV2ProjectGroups(unsortedProjectGroups, threads, sidebarProjectSortOrder), | ||
| 2581 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], | ||
| 2582 | ); | ||
| 2583 | const projectGroupsRef = useRef(projectGroups); | 2546 | const projectGroupsRef = useRef(projectGroups); |
| 2584 | projectGroupsRef.current = projectGroups; | 2547 | projectGroupsRef.current = projectGroups; |
| 2585 | // Threads on non-primary environments (T3 Connect, hosted) resolve their | 2548 | // Threads on non-primary environments (T3 Connect, hosted) resolve their |
apps/web/src/hooks/useSidebarProjectGroups.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import { useMemo } from "react"; | ||
| 2 | |||
| 3 | import { orderItemsByPreferredIds, sortSidebarV2ProjectGroups } from "../components/Sidebar.logic"; | ||
| 4 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; | ||
| 5 | import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; | ||
| 6 | import { useEnvironmentIdentities, usePrimaryEnvironmentId } from "../state/environments"; | ||
| 7 | import { useProjects, useThreadShells } from "../state/entities"; | ||
| 8 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | ||
| 9 | import { useClientSettings } from "./useSettings"; | ||
| 10 | |||
| 11 | /** The sidebar's project groups, in the order the sidebar shows them. */ | ||
| 12 | export function useSidebarProjectGroups() { | ||
| 13 | const projects = useProjects(); | ||
| 14 | const threads = useThreadShells(); | ||
| 15 | const projectOrder = useUiStateStore((store) => store.projectOrder); | ||
| 16 | const sortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | ||
| 17 | const groupingSettings = useClientSettings(selectProjectGroupingSettings); | ||
| 18 | const environments = useEnvironmentIdentities(); | ||
| 19 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 20 | const orderedProjects = useMemo( | ||
| 21 | () => | ||
| 22 | orderItemsByPreferredIds({ | ||
| 23 | items: projects, | ||
| 24 | preferredIds: projectOrder, | ||
| 25 | getId: getProjectOrderKey, | ||
| 26 | getPreferenceIds: (project) => [ | ||
| 27 | getProjectOrderKey(project), | ||
| 28 | legacyProjectCwdPreferenceKey(project.workspaceRoot), | ||
| 29 | ], | ||
| 30 | }), | ||
| 31 | [projectOrder, projects], | ||
| 32 | ); | ||
| 33 | const unsortedGroups = useMemo(() => { | ||
| 34 | const labels = new Map( | ||
| 35 | environments.map((environment) => [environment.environmentId, environment.label] as const), | ||
| 36 | ); | ||
| 37 | return buildSidebarProjectSnapshots({ | ||
| 38 | projects: sortOrder === "manual" ? orderedProjects : projects, | ||
| 39 | groupScratchProjects: true, | ||
| 40 | settings: groupingSettings, | ||
| 41 | primaryEnvironmentId, | ||
| 42 | resolveEnvironmentLabel: (environmentId) => labels.get(environmentId) ?? null, | ||
| 43 | }); | ||
| 44 | }, [environments, groupingSettings, orderedProjects, primaryEnvironmentId, projects, sortOrder]); | ||
| 45 | return useMemo( | ||
| 46 | () => sortSidebarV2ProjectGroups(unsortedGroups, threads, sortOrder), | ||
| 47 | [sortOrder, threads, unsortedGroups], | ||
| 48 | ); | ||
| 49 | } | ||
apps/web/src/routes/_chat.pull-requests.tsx+16-1| ... | @@ -5,6 +5,7 @@ import type { PullRequestSpeedActionResult } from "~/components/pullRequest/Pull | ... | @@ -5,6 +5,7 @@ import type { PullRequestSpeedActionResult } from "~/components/pullRequest/Pull |
| 5 | import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions"; | 5 | import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions"; |
| 6 | import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; | 6 | import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; |
| 7 | import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; | 7 | import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; |
| 8 | import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; | ||
| 8 | import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 9 | import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 9 | import type { | 10 | import type { |
| 10 | EnvironmentId, | 11 | EnvironmentId, |
| ... | @@ -45,6 +46,7 @@ import { | ... | @@ -45,6 +46,7 @@ import { |
| 45 | useMemo, | 46 | useMemo, |
| 46 | useRef, | 47 | useRef, |
| 47 | useState, | 48 | useState, |
| 49 | type ComponentProps, | ||
| 48 | type ReactNode, | 50 | type ReactNode, |
| 49 | type RefObject, | 51 | type RefObject, |
| 50 | } from "react"; | 52 | } from "react"; |
| ... | @@ -154,6 +156,7 @@ import { | ... | @@ -154,6 +156,7 @@ import { |
| 154 | } from "../rightPanelStore"; | 156 | } from "../rightPanelStore"; |
| 155 | import { useDebouncedValue } from "../state/queries"; | 157 | import { useDebouncedValue } from "../state/queries"; |
| 156 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; | 158 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; |
| 159 | import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; | ||
| 157 | import { useEnvironments } from "../state/environments"; | 160 | import { useEnvironments } from "../state/environments"; |
| 158 | import { | 161 | import { |
| 159 | pullRequestEnvironment, | 162 | pullRequestEnvironment, |
| ... | @@ -2061,7 +2064,7 @@ function PullRequestsRouteView() { | ... | @@ -2061,7 +2064,7 @@ function PullRequestsRouteView() { |
| 2061 | /> | 2064 | /> |
| 2062 | ); | 2065 | ); |
| 2063 | const filtersMenu = ( | 2066 | const filtersMenu = ( |
| 2064 | <PullRequestFiltersMenu | 2067 | <PullRequestFiltersMenuInSidebarOrder |
| 2065 | onOpenChange={setFiltersOpen} | 2068 | onOpenChange={setFiltersOpen} |
| 2066 | state={search.state} | 2069 | state={search.state} |
| 2067 | stateOptions={STATE_TABS} | 2070 | stateOptions={STATE_TABS} |
| ... | @@ -2389,6 +2392,18 @@ function PullRequestsRouteView() { | ... | @@ -2389,6 +2392,18 @@ function PullRequestsRouteView() { |
| 2389 | } | 2392 | } |
| 2390 | 2393 | ||
| 2391 | /** A compact stand-in for one pill group when the header is narrow. */ | 2394 | /** A compact stand-in for one pill group when the header is narrow. */ |
| 2395 | /** Lists the projects in the sidebar's order; it re-renders with thread activity, so the page does not. */ | ||
| 2396 | function PullRequestFiltersMenuInSidebarOrder( | ||
| 2397 | props: ComponentProps<typeof PullRequestFiltersMenu>, | ||
| 2398 | ) { | ||
| 2399 | const sidebarProjectGroups = useSidebarProjectGroups(); | ||
| 2400 | const projects = useMemo( | ||
| 2401 | () => sortProjectsByGroupOrder(props.projects, sidebarProjectGroups), | ||
| 2402 | [props.projects, sidebarProjectGroups], | ||
| 2403 | ); | ||
| 2404 | return <PullRequestFiltersMenu {...props} projects={projects} />; | ||
| 2405 | } | ||
| 2406 | |||
| 2392 | function CompactFilterMenu<Value extends string>({ | 2407 | function CompactFilterMenu<Value extends string>({ |
| 2393 | label, | 2408 | label, |
| 2394 | triggerIcon, | 2409 | triggerIcon, |
packages/client-runtime/src/state/projectGrouping.test.ts+38| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | deriveLogicalProjectKeyFromSettings, | 8 | deriveLogicalProjectKeyFromSettings, |
| 9 | derivePhysicalProjectKey, | 9 | derivePhysicalProjectKey, |
| 10 | resolveScratchProjectScopeKey, | 10 | resolveScratchProjectScopeKey, |
| 11 | sortProjectsByGroupOrder, | ||
| 11 | type ProjectGroupingSettings, | 12 | type ProjectGroupingSettings, |
| 12 | } from "./projectGrouping.ts"; | 13 | } from "./projectGrouping.ts"; |
| 13 | 14 | ||
| ... | @@ -445,3 +446,40 @@ describe("buildProjectGroups", () => { | ... | @@ -445,3 +446,40 @@ describe("buildProjectGroups", () => { |
| 445 | expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]); | 446 | expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]); |
| 446 | }); | 447 | }); |
| 447 | }); | 448 | }); |
| 449 | |||
| 450 | describe("sortProjectsByGroupOrder", () => { | ||
| 451 | const remote = EnvironmentId.make("remote"); | ||
| 452 | const ref = (projectId: string, env = environmentId) => ({ | ||
| 453 | environmentId: env, | ||
| 454 | projectId: ProjectId.make(projectId), | ||
| 455 | }); | ||
| 456 | const project = (id: string, env: string = environmentId) => ({ id, environmentId: env }); | ||
| 457 | |||
| 458 | it("follows the group order, keeping ungrouped projects last in their own order", () => { | ||
| 459 | const sorted = sortProjectsByGroupOrder( | ||
| 460 | [ | ||
| 461 | project("alpha"), | ||
| 462 | project("zeta"), | ||
| 463 | project("orphan-b"), | ||
| 464 | project("beta"), | ||
| 465 | project("orphan-a"), | ||
| 466 | ], | ||
| 467 | [{ memberProjectRefs: [ref("zeta")] }, { memberProjectRefs: [ref("beta"), ref("alpha")] }], | ||
| 468 | ); | ||
| 469 | expect(sorted.map((entry) => entry.id)).toEqual([ | ||
| 470 | "zeta", | ||
| 471 | "alpha", | ||
| 472 | "beta", | ||
| 473 | "orphan-b", | ||
| 474 | "orphan-a", | ||
| 475 | ]); | ||
| 476 | }); | ||
| 477 | |||
| 478 | it("tells the same project id on two environments apart", () => { | ||
| 479 | const sorted = sortProjectsByGroupOrder( | ||
| 480 | [project("shared"), project("shared", remote)], | ||
| 481 | [{ memberProjectRefs: [ref("shared", remote)] }, { memberProjectRefs: [ref("shared")] }], | ||
| 482 | ); | ||
| 483 | expect(sorted.map((entry) => entry.environmentId)).toEqual([remote, environmentId]); | ||
| 484 | }); | ||
| 485 | }); |
packages/client-runtime/src/state/projectGrouping.ts+22| ... | @@ -376,3 +376,25 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { | ... | @@ -376,3 +376,25 @@ export function buildProjectGroups<TProject extends EnvironmentProject>(input: { |
| 376 | }; | 376 | }; |
| 377 | }); | 377 | }); |
| 378 | } | 378 | } |
| 379 | |||
| 380 | /** | ||
| 381 | * Orders projects the way a sorted group list shows them: by the first group holding each | ||
| 382 | * project. Projects in no group follow, and ties keep their incoming order. | ||
| 383 | */ | ||
| 384 | export function sortProjectsByGroupOrder< | ||
| 385 | TProject extends { readonly id: string; readonly environmentId: string }, | ||
| 386 | >( | ||
| 387 | projects: ReadonlyArray<TProject>, | ||
| 388 | groups: ReadonlyArray<{ readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef> }>, | ||
| 389 | ): TProject[] { | ||
| 390 | const rankByProject = new Map<string, number>(); | ||
| 391 | groups.forEach((group, rank) => { | ||
| 392 | for (const ref of group.memberProjectRefs) { | ||
| 393 | const key = `${ref.environmentId}\0${ref.projectId}`; | ||
| 394 | if (!rankByProject.has(key)) rankByProject.set(key, rank); | ||
| 395 | } | ||
| 396 | }); | ||
| 397 | const rankOf = (project: TProject) => | ||
| 398 | rankByProject.get(`${project.environmentId}\0${project.id}`) ?? groups.length; | ||
| 399 | return [...projects].sort((left, right) => rankOf(left) - rankOf(right)); | ||
| 400 | } |