Skip to content

Commit c38b265

Browse files
authored
feat(web): filter the sidebar by several projects at once (#25284)
1 parent c570c2c commit c38b265

4 files changed

Lines changed: 180 additions & 67 deletions

File tree

‎apps/web/src/components/Sidebar.tsx‎

Lines changed: 124 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ import {
5959
} from "@t3tools/contracts";
6060
import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads";
6161

62-
import { ChevronDownIcon, FolderIcon, ListFilterIcon, PlusIcon, SettingsIcon } from "lucide-react";
62+
import {
63+
CheckIcon,
64+
ChevronDownIcon,
65+
FolderIcon,
66+
ListFilterIcon,
67+
PlusIcon,
68+
SettingsIcon,
69+
} from "lucide-react";
6370
import {
6471
useEffect,
6572
useLayoutEffect,
@@ -98,7 +105,7 @@ import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
98105
import { selectProjectGroupingSettings } from "../logicalProject";
99106
import {
100107
projectGroupsSpanEnvironments,
101-
resolveSidebarProjectScopeKey,
108+
resolveSidebarProjectScopeKeys,
102109
getSidebarProjectSettingsKey,
103110
type SidebarProjectSnapshot,
104111
} from "../sidebarProjectGrouping";
@@ -755,6 +762,19 @@ function useSidebarLookups() {
755762
};
756763
}
757764

765+
function ProjectScopeRowIcon(props: { isSelected: boolean; children: ReactNode }) {
766+
return (
767+
<span className="relative flex shrink-0">
768+
{props.children}
769+
{props.isSelected ? (
770+
<span className="pointer-events-none absolute -right-1 -bottom-1 grid size-3 place-items-center rounded-full bg-primary ring-2 ring-popover">
771+
<CheckIcon className="size-2 text-primary-foreground" strokeWidth={4} aria-hidden />
772+
</span>
773+
) : null}
774+
</span>
775+
);
776+
}
777+
758778
function canOperateThreads(
759779
threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>,
760780
): boolean {
@@ -900,8 +920,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
900920
);
901921
const environmentMachineById = useEnvironmentMachines();
902922
const projectGroupsRef = useRef(projectGroups);
903-
const persistedProjectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey);
904-
const setPersistedProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey);
923+
const persistedProjectScopeKeys = useUiStateStore((store) => store.sidebarProjectScopeKeys);
924+
const setPersistedProjectScopeKeys = useUiStateStore((store) => store.setSidebarProjectScopeKeys);
905925
const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer(
906926
reduceSidebarProjectScopeMenuState,
907927
{ open: false, query: "" },
@@ -961,28 +981,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
961981
const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
962982

963983
// A selection made under one scope can hold threads the next scope hides.
964-
const setProjectScopeKey = (key: string | null) => {
984+
const setProjectScopeKeys = (keys: readonly string[]) => {
965985
clearSelection();
966-
setPersistedProjectScopeKey(key);
986+
setPersistedProjectScopeKeys(keys);
967987
};
968988
// A scope whose project is gone reads as all projects once every snapshot has
969-
// loaded. The stored key is left alone, so the scope returns with its project.
970-
const chosenProjectScopeKey = resolveSidebarProjectScopeKey({
989+
// loaded. The stored keys are left alone, so the scope returns with its project.
990+
const chosenProjectScopeKeys = resolveSidebarProjectScopeKeys({
971991
groups: projectGroups,
972-
key: persistedProjectScopeKey,
992+
keys: persistedProjectScopeKeys,
973993
settings: projectGroupingSettings,
974994
canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady,
975995
});
976-
const chosenProjectGroup =
977-
chosenProjectScopeKey === null
978-
? null
979-
: (projectGroups.find((project) => project.projectKey === chosenProjectScopeKey) ?? null);
996+
const chosenProjectGroups = projectGroups.filter((project) =>
997+
chosenProjectScopeKeys.includes(project.projectKey),
998+
);
980999
const chosenScopeProjectKeys =
981-
chosenProjectGroup === null
1000+
chosenProjectGroups.length === 0
9821001
? null
9831002
: new Set(
984-
chosenProjectGroup.memberProjectRefs.map(
985-
(projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
1003+
chosenProjectGroups.flatMap((project) =>
1004+
project.memberProjectRefs.map(
1005+
(projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
1006+
),
9861007
),
9871008
);
9881009
const threads = useQuietThreadShells();
@@ -1013,7 +1034,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
10131034
delegatedUnderParent,
10141035
findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key),
10151036
});
1016-
const projectScopeKey = revealWidensScope ? null : chosenProjectScopeKey;
1037+
const projectScopeKeys = revealWidensScope ? [] : chosenProjectScopeKeys;
1038+
const projectScopeKey = projectScopeKeys.length === 0 ? null : projectScopeKeys.join("\n");
1039+
const toggleProjectScopeKey = (key: string) =>
1040+
setProjectScopeKeys(
1041+
projectScopeKeys.includes(key)
1042+
? projectScopeKeys.filter((candidate) => candidate !== key)
1043+
: [...projectScopeKeys, key],
1044+
);
10171045
// The widened scope outlives the reveal, so it is written back to the persisted
10181046
// scope. Route changes arrive from history, links and the palette, and only this
10191047
// component holds the project groups that decide a widening, so no single
@@ -1022,8 +1050,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
10221050
useEffect(() => {
10231051
if (!revealWidensScope) return;
10241052
clearSelection();
1025-
setPersistedProjectScopeKey(null);
1026-
}, [clearSelection, revealWidensScope, setPersistedProjectScopeKey]);
1053+
setPersistedProjectScopeKeys([]);
1054+
}, [clearSelection, revealWidensScope, setPersistedProjectScopeKeys]);
10271055
const revealActiveRow = useActiveRowReveal(routeReveal, routeRowThreadKey);
10281056
const showProjectEnvironments = projectGroupsSpanEnvironments(projectGroups);
10291057
const scratchMachineLabelFor = (
@@ -1040,26 +1068,25 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
10401068
return environmentLabelById.get(thread.environmentId) ?? null;
10411069
};
10421070
const selectedProjectScopeItem =
1043-
projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ??
1044-
projectScopeItems[0]!;
1071+
projectScopeKeys.length > 1
1072+
? null
1073+
: (projectScopeItems.find((item) => item.value === (projectScopeKeys[0] ?? "all")) ??
1074+
projectScopeItems[0]!);
10451075
const filteredProjectScopeItems = filterSidebarProjectScopeItems({
10461076
items: projectScopeItems,
10471077
query: projectScopeMenuState.query,
10481078
matches: (item, query) =>
10491079
projectScopeFilter.contains(item, query, (candidate) => candidate.label),
10501080
});
1051-
const scopedProjectGroup = revealWidensScope ? null : chosenProjectGroup;
1081+
const scopedProjectGroups = revealWidensScope ? [] : chosenProjectGroups;
1082+
const scopedProjectRefs = scopedProjectGroups.flatMap((project) => project.memberProjectRefs);
10521083
const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys;
10531084
const threadSearch = useThreadSearch(
10541085
searchEnvironmentIds,
10551086
threadSearchQuery,
1056-
scopedProjectGroup === null
1087+
scopedProjectGroups.length === 0
10571088
? {}
1058-
: {
1059-
projectIds: scopedProjectGroup.memberProjectRefs.map(
1060-
(projectRef) => projectRef.projectId,
1061-
),
1062-
},
1089+
: { projectIds: scopedProjectRefs.map((projectRef) => projectRef.projectId) },
10631090
);
10641091
const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null;
10651092
const visibleDraftSessionCount = useComposerDraftStore((store) => {
@@ -2022,6 +2049,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
20222049
threadKey,
20232050
currentThreadKey: readRouteThreadKey(router),
20242051
action: "snooze",
2052+
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
20252053
now: new Date().toISOString(),
20262054
thread: readThreadShell(threadRef),
20272055
})
@@ -2061,6 +2089,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
20612089
);
20622090
if (threadKeys.length === 0) return;
20632091
const count = threadKeys.length;
2092+
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
20642093
const selectionNow = new Date();
20652094
const selectedThreads = threadKeys.flatMap((threadKey) => {
20662095
const thread = threadByKeyRef.current.get(threadKey);
@@ -2094,6 +2123,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
20942123
const unpinMenuItem = buildBulkUnpinContextMenuItem({
20952124
pinnedCount: pinnedSelectedThreads.length,
20962125
});
2126+
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
20972127
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
20982128
const clicked = await settlePromise(() =>
20992129
api.contextMenu.show(
@@ -2406,7 +2436,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
24062436
projectFilter: threadProjectGroup
24072437
? {
24082438
label: threadProjectGroup.displayName,
2409-
isActive: projectScopeKey === threadProjectGroup.projectKey,
2439+
isActive: projectScopeKeys.includes(threadProjectGroup.projectKey),
24102440
}
24112441
: null,
24122442
isPinned,
@@ -2444,10 +2474,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
24442474
switch (clicked.value) {
24452475
case "filter-by-project":
24462476
if (threadProjectGroup) {
2447-
setProjectScopeKey(
2448-
projectScopeKey === threadProjectGroup.projectKey
2449-
? null
2450-
: threadProjectGroup.projectKey,
2477+
setProjectScopeKeys(
2478+
projectScopeKeys.includes(threadProjectGroup.projectKey)
2479+
? []
2480+
: [threadProjectGroup.projectKey],
24512481
);
24522482
}
24532483
return;
@@ -2732,37 +2762,59 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
27322762
itemToStringLabel={(item) => item.label}
27332763
isItemEqualToValue={(a, b) => a.value === b.value}
27342764
open={projectScopeMenuState.open}
2735-
onOpenChange={(open) => {
2765+
onOpenChange={(open, eventDetails) => {
2766+
if (eventDetails.isCanceled) return;
27362767
if (open) suppressNextScopeChangeRef.current = false;
27372768
dispatchProjectScopeMenu({ type: "open-changed", open });
27382769
}}
27392770
onItemHighlighted={(item) => {
27402771
highlightedProjectScopeKeyRef.current = item?.value ?? null;
27412772
}}
27422773
value={selectedProjectScopeItem}
2743-
onValueChange={(item) => {
2774+
onValueChange={(item, eventDetails) => {
27442775
if (suppressNextScopeChangeRef.current) {
27452776
suppressNextScopeChangeRef.current = false;
27462777
return;
27472778
}
27482779
if (!item) return;
2749-
setProjectScopeKey(item.value === "all" ? null : item.value);
2780+
if (item.value === "all") {
2781+
setProjectScopeKeys([]);
2782+
} else if (
2783+
eventDetails.event instanceof MouseEvent &&
2784+
eventDetails.event.shiftKey
2785+
) {
2786+
// Canceling skips Base UI's commit, which would freeze the search query, and its close.
2787+
eventDetails.cancel();
2788+
toggleProjectScopeKey(item.value);
2789+
} else {
2790+
setProjectScopeKeys([item.value]);
2791+
}
27502792
}}
27512793
>
27522794
<ComboboxTrigger
27532795
render={
27542796
<SidebarHeaderIconButton
27552797
label={
2756-
scopedProjectGroup
2757-
? `Filter threads by project: ${scopedProjectGroup.displayName}`
2798+
scopedProjectGroups.length > 0
2799+
? `Filter threads by project: ${scopedProjectGroups.map((project) => project.displayName).join(", ")}`
27582800
: "Filter threads by project"
27592801
}
27602802
/>
27612803
}
27622804
>
2763-
{scopedProjectGroup ? (
2805+
{scopedProjectGroups.length > 1 ? (
2806+
<span className="flex shrink-0 -space-x-1.5">
2807+
{scopedProjectGroups.slice(0, 2).map((project) => (
2808+
<ProjectFavicon
2809+
key={project.projectKey}
2810+
project={project}
2811+
className="size-3.5"
2812+
/>
2813+
))}
2814+
</span>
2815+
) : scopedProjectGroups[0] ? (
27642816
<span className="flex shrink-0">
2765-
<ProjectFavicon project={scopedProjectGroup} className="size-4" />
2817+
<ProjectFavicon project={scopedProjectGroups[0]} className="size-4" />
27662818
</span>
27672819
) : (
27682820
<ListFilterIcon className="size-4" />
@@ -2781,15 +2833,27 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
27812833
if (
27822834
event.defaultPrevented ||
27832835
event.nativeEvent.isComposing ||
2836+
event.keyCode === 229 ||
27842837
event.ctrlKey ||
27852838
event.altKey ||
2786-
event.metaKey ||
2787-
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
2839+
event.metaKey
27882840
) {
27892841
return;
27902842
}
27912843
const scopeKey = highlightedProjectScopeKeyRef.current;
27922844
const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
2845+
if (event.shiftKey && event.key === "Enter") {
2846+
if (!project) return;
2847+
event.preventDefault();
2848+
toggleProjectScopeKey(project.projectKey);
2849+
return;
2850+
}
2851+
if (
2852+
event.key !== "ContextMenu" &&
2853+
!(event.shiftKey && event.key === "F10")
2854+
) {
2855+
return;
2856+
}
27932857
if (project) handleProjectSettings(event, project);
27942858
}}
27952859
onChange={(event) =>
@@ -2800,23 +2864,31 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
28002864
}
28012865
/>
28022866
<ComboboxEmpty>No matching projects.</ComboboxEmpty>
2803-
<ComboboxList>
2867+
<ComboboxList aria-multiselectable>
28042868
{(item: (typeof projectScopeItems)[number]) => {
28052869
const project = projectGroupByScopeKey.get(item.value) ?? null;
2870+
const isSelected = project
2871+
? projectScopeKeys.includes(item.value)
2872+
: projectScopeKeys.length === 0;
28062873
return (
28072874
<ComboboxItem
28082875
key={item.value}
28092876
hideIndicator
28102877
value={item}
2878+
aria-selected={isSelected}
2879+
// The icon badge marks selection; Base UI's single value would shade one row.
2880+
data-selected={undefined}
28112881
onContextMenu={(event) => {
28122882
if (project) handleProjectSettings(event, project);
28132883
}}
28142884
>
2815-
{project ? (
2816-
<ProjectFavicon project={project} className="size-4 shrink-0" />
2817-
) : (
2818-
<FolderIcon className="size-4 shrink-0" />
2819-
)}
2885+
<ProjectScopeRowIcon isSelected={isSelected}>
2886+
{project ? (
2887+
<ProjectFavicon project={project} className="size-4 shrink-0" />
2888+
) : (
2889+
<FolderIcon className="size-4 shrink-0" />
2890+
)}
2891+
</ProjectScopeRowIcon>
28202892
<span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
28212893
{project && showProjectEnvironments ? (
28222894
<ProjectEnvironmentBadge
@@ -3261,8 +3333,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
32613333
Add project
32623334
</button>
32633335
</>
3264-
) : scopedProjectGroup ? (
3265-
`No threads in ${scopedProjectGroup.displayName} yet`
3336+
) : scopedProjectGroups.length > 1 ? (
3337+
"No threads in these projects yet"
3338+
) : scopedProjectGroups[0] ? (
3339+
`No threads in ${scopedProjectGroups[0].displayName} yet`
32663340
) : (
32673341
"No threads yet"
32683342
)}

‎apps/web/src/sidebarProjectGrouping.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ export function resolveSidebarProjectScopeKey(input: {
8484
return group?.projectKey ?? (input.canClearMissingScope ? null : input.key);
8585
}
8686

87+
export function resolveSidebarProjectScopeKeys(input: {
88+
readonly groups: ReadonlyArray<SidebarProjectSnapshot>;
89+
readonly keys: ReadonlyArray<string>;
90+
readonly settings?: ProjectGroupingSettings;
91+
readonly canClearMissingScope?: boolean;
92+
}): string[] {
93+
return [
94+
...new Set(input.keys.flatMap((key) => resolveSidebarProjectScopeKey({ ...input, key }) ?? [])),
95+
];
96+
}
97+
8798
export function buildPhysicalToLogicalProjectKeyMap(input: {
8899
projects: ReadonlyArray<Project>;
89100
settings: ProjectGroupingSettings;

0 commit comments

Comments
 (0)