| 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 | import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; | |
| 2 | import { useMemo } from "react"; | |
| 1 | 3 | import { ScrollView } from "react-native"; |
| 2 | 4 | import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types"; |
| 3 | 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 | 9 | import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; |
| 5 | 10 | |
| 6 | 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 | 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 | 60 | export function PullRequestListFilters(props: { |
| 32 | 61 | readonly environments: readonly { id: string; label: string }[]; |
| 33 | 62 | readonly environmentId: string | null; |
| ... | ... | @@ -39,6 +68,7 @@ export function PullRequestListFilters(props: { |
| 39 | 68 | readonly projects: readonly { id: string; title: string; environmentId: string }[]; |
| 40 | 69 | }) { |
| 41 | 70 | const { value } = props; |
| 71 | const projects = useProjectsInSidebarOrder(props.projects); | |
| 42 | 72 | const selectedLabels = value.filters.labels?.flat() ?? []; |
| 43 | 73 | const selectedLabelSet = new Set(selectedLabels); |
| 44 | 74 | const author = value.filters.author; |
| ... | ... | @@ -76,7 +106,7 @@ export function PullRequestListFilters(props: { |
| 76 | 106 | props.onChange({ ...value, sort: next as PullRequestListControls["sort"] }); |
| 77 | 107 | break; |
| 78 | 108 | case "project": { |
| 79 | const project = props.projects.find( | |
| 109 | const project = projects.find( | |
| 80 | 110 | (project) => JSON.stringify([project.environmentId, project.id]) === next, |
| 81 | 111 | ); |
| 82 | 112 | props.onChange({ |
| ... | ... | @@ -165,7 +195,7 @@ export function PullRequestListFilters(props: { |
| 165 | 195 | { |
| 166 | 196 | id: "projects", |
| 167 | 197 | title: |
| 168 | props.projects.find( | |
| 198 | projects.find( | |
| 169 | 199 | (project) => |
| 170 | 200 | project.id === value.projectId && project.environmentId === value.projectEnvironmentId, |
| 171 | 201 | )?.title ?? "Project", |
| ... | ... | @@ -173,7 +203,7 @@ export function PullRequestListFilters(props: { |
| 173 | 203 | "project", |
| 174 | 204 | Object.fromEntries([ |
| 175 | 205 | ["", "All projects"], |
| 176 | ...props.projects.map((project) => [ | |
| 206 | ...projects.map((project) => [ | |
| 177 | 207 | JSON.stringify([project.environmentId, project.id]), |
| 178 | 208 | project.title, |
| 179 | 209 | ]), |
apps/web/src/components/Sidebar.tsx+4-41| ... | ... | @@ -128,21 +128,21 @@ import { isMacPlatform } from "~/lib/utils"; |
| 128 | 128 | import { useOpenPrLink } from "../lib/openPullRequestLink"; |
| 129 | 129 | import { readLocalApi } from "../localApi"; |
| 130 | 130 | import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; |
| 131 | import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; | |
| 131 | import { selectProjectGroupingSettings } from "../logicalProject"; | |
| 132 | 132 | import { |
| 133 | buildSidebarProjectSnapshots, | |
| 134 | 133 | projectGroupsSpanEnvironments, |
| 135 | 134 | resolveSidebarProjectScopeKey, |
| 136 | 135 | getSidebarProjectSettingsKey, |
| 137 | 136 | type SidebarProjectSnapshot, |
| 138 | 137 | } from "../sidebarProjectGrouping"; |
| 139 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; | |
| 138 | import { useUiStateStore } from "../uiStateStore"; | |
| 140 | 139 | import { |
| 141 | 140 | getThreadKeysToDeselectAfterDelete, |
| 142 | 141 | useThreadSelectionStore, |
| 143 | 142 | } from "../threadSelectionStore"; |
| 144 | 143 | import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions"; |
| 145 | 144 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 145 | import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; | |
| 146 | 146 | import { useTerminalFocus } from "../hooks/useTerminalFocus"; |
| 147 | 147 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 148 | 148 | import { registerSettleAndAdvance } from "../settleAndAdvance"; |
| ... | ... | @@ -224,7 +224,6 @@ import { |
| 224 | 224 | sidebarThreadKeyAtY, |
| 225 | 225 | sortInboxThreadsByReturn, |
| 226 | 226 | sortPinnedThreadsForSidebar, |
| 227 | sortSidebarV2ProjectGroups, | |
| 228 | 227 | sortThreadsForSidebar, |
| 229 | 228 | sortWorkingThreadsBySend, |
| 230 | 229 | useThreadJumpHintVisibility, |
| ... | ... | @@ -2420,14 +2419,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2420 | 2419 | |
| 2421 | 2420 | export default function Sidebar() { |
| 2422 | 2421 | const projects = useProjects(); |
| 2423 | const projectOrder = useUiStateStore((store) => store.projectOrder); | |
| 2424 | 2422 | const threads = useThreadShells(); |
| 2425 | 2423 | const router = useRouter(); |
| 2426 | 2424 | const { isMobile, setOpenMobile } = useSidebar(); |
| 2427 | 2425 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 2428 | 2426 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 2429 | 2427 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 2430 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | |
| 2431 | 2428 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| 2432 | 2429 | const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); |
| 2433 | 2430 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| ... | ... | @@ -2545,41 +2542,7 @@ export default function Sidebar() { |
| 2545 | 2542 | [environments], |
| 2546 | 2543 | ); |
| 2547 | 2544 | const environmentMachineById = useEnvironmentMachines(); |
| 2548 | const orderedProjects = useMemo( | |
| 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 | ); | |
| 2545 | const projectGroups = useSidebarProjectGroups(); | |
| 2583 | 2546 | const projectGroupsRef = useRef(projectGroups); |
| 2584 | 2547 | projectGroupsRef.current = projectGroups; |
| 2585 | 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 | 5 | import { usePullRequestCloseBatch } from "~/components/pullRequest/usePullRequestActions"; |
| 6 | 6 | import { SidebarPointerSensor } from "~/components/Sidebar.pointer"; |
| 7 | 7 | import { resolveSidebarSweepKeys } from "~/components/Sidebar.logic"; |
| 8 | import { sortProjectsByGroupOrder } from "@t3tools/client-runtime/state/project-grouping"; | |
| 8 | 9 | import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 9 | 10 | import type { |
| 10 | 11 | EnvironmentId, |
| ... | ... | @@ -45,6 +46,7 @@ import { |
| 45 | 46 | useMemo, |
| 46 | 47 | useRef, |
| 47 | 48 | useState, |
| 49 | type ComponentProps, | |
| 48 | 50 | type ReactNode, |
| 49 | 51 | type RefObject, |
| 50 | 52 | } from "react"; |
| ... | ... | @@ -154,6 +156,7 @@ import { |
| 154 | 156 | } from "../rightPanelStore"; |
| 155 | 157 | import { useDebouncedValue } from "../state/queries"; |
| 156 | 158 | import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities"; |
| 159 | import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; | |
| 157 | 160 | import { useEnvironments } from "../state/environments"; |
| 158 | 161 | import { |
| 159 | 162 | pullRequestEnvironment, |
| ... | ... | @@ -2061,7 +2064,7 @@ function PullRequestsRouteView() { |
| 2061 | 2064 | /> |
| 2062 | 2065 | ); |
| 2063 | 2066 | const filtersMenu = ( |
| 2064 | <PullRequestFiltersMenu | |
| 2067 | <PullRequestFiltersMenuInSidebarOrder | |
| 2065 | 2068 | onOpenChange={setFiltersOpen} |
| 2066 | 2069 | state={search.state} |
| 2067 | 2070 | stateOptions={STATE_TABS} |
| ... | ... | @@ -2389,6 +2392,18 @@ function PullRequestsRouteView() { |
| 2389 | 2392 | } |
| 2390 | 2393 | |
| 2391 | 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 | 2407 | function CompactFilterMenu<Value extends string>({ |
| 2393 | 2408 | label, |
| 2394 | 2409 | triggerIcon, |
packages/client-runtime/src/state/projectGrouping.test.ts+38| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | deriveLogicalProjectKeyFromSettings, |
| 9 | 9 | derivePhysicalProjectKey, |
| 10 | 10 | resolveScratchProjectScopeKey, |
| 11 | sortProjectsByGroupOrder, | |
| 11 | 12 | type ProjectGroupingSettings, |
| 12 | 13 | } from "./projectGrouping.ts"; |
| 13 | 14 | |
| ... | ... | @@ -445,3 +446,40 @@ describe("buildProjectGroups", () => { |
| 445 | 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 | 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 | } |