Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 124 additions & 50 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ import {
} from "@t3tools/contracts";
import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads";

import { ChevronDownIcon, FolderIcon, ListFilterIcon, PlusIcon, SettingsIcon } from "lucide-react";
import {
CheckIcon,
ChevronDownIcon,
FolderIcon,
ListFilterIcon,
PlusIcon,
SettingsIcon,
} from "lucide-react";
import {
useEffect,
useLayoutEffect,
Expand Down Expand Up @@ -98,7 +105,7 @@ import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
import { selectProjectGroupingSettings } from "../logicalProject";
import {
projectGroupsSpanEnvironments,
resolveSidebarProjectScopeKey,
resolveSidebarProjectScopeKeys,
getSidebarProjectSettingsKey,
type SidebarProjectSnapshot,
} from "../sidebarProjectGrouping";
Expand Down Expand Up @@ -755,6 +762,19 @@ function useSidebarLookups() {
};
}

function ProjectScopeRowIcon(props: { isSelected: boolean; children: ReactNode }) {
return (
<span className="relative flex shrink-0">
{props.children}
{props.isSelected ? (
<span className="pointer-events-none absolute -right-1 -bottom-1 grid size-3 place-items-center rounded-full bg-primary ring-2 ring-popover">
<CheckIcon className="size-2 text-primary-foreground" strokeWidth={4} aria-hidden />
</span>
) : null}
</span>
);
}

function canOperateThreads(
threads: ReadonlyArray<Pick<SidebarThreadSummary, "environmentId">>,
): boolean {
Expand Down Expand Up @@ -900,8 +920,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
);
const environmentMachineById = useEnvironmentMachines();
const projectGroupsRef = useRef(projectGroups);
const persistedProjectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey);
const setPersistedProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey);
const persistedProjectScopeKeys = useUiStateStore((store) => store.sidebarProjectScopeKeys);
const setPersistedProjectScopeKeys = useUiStateStore((store) => store.setSidebarProjectScopeKeys);
const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer(
reduceSidebarProjectScopeMenuState,
{ open: false, query: "" },
Expand Down Expand Up @@ -961,28 +981,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;

// A selection made under one scope can hold threads the next scope hides.
const setProjectScopeKey = (key: string | null) => {
const setProjectScopeKeys = (keys: readonly string[]) => {
clearSelection();
setPersistedProjectScopeKey(key);
setPersistedProjectScopeKeys(keys);
};
// A scope whose project is gone reads as all projects once every snapshot has
// loaded. The stored key is left alone, so the scope returns with its project.
const chosenProjectScopeKey = resolveSidebarProjectScopeKey({
// loaded. The stored keys are left alone, so the scope returns with its project.
const chosenProjectScopeKeys = resolveSidebarProjectScopeKeys({
groups: projectGroups,
key: persistedProjectScopeKey,
keys: persistedProjectScopeKeys,
settings: projectGroupingSettings,
canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady,
});
const chosenProjectGroup =
chosenProjectScopeKey === null
? null
: (projectGroups.find((project) => project.projectKey === chosenProjectScopeKey) ?? null);
const chosenProjectGroups = projectGroups.filter((project) =>
chosenProjectScopeKeys.includes(project.projectKey),
);
const chosenScopeProjectKeys =
chosenProjectGroup === null
chosenProjectGroups.length === 0
? null
: new Set(
chosenProjectGroup.memberProjectRefs.map(
(projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
chosenProjectGroups.flatMap((project) =>
project.memberProjectRefs.map(
(projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`,
),
),
);
const threads = useQuietThreadShells();
Expand Down Expand Up @@ -1013,7 +1034,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
delegatedUnderParent,
findThread: (key) => threads.find((candidate) => threadShellKey(candidate) === key),
});
const projectScopeKey = revealWidensScope ? null : chosenProjectScopeKey;
const projectScopeKeys = revealWidensScope ? [] : chosenProjectScopeKeys;
const projectScopeKey = projectScopeKeys.length === 0 ? null : projectScopeKeys.join("\n");
const toggleProjectScopeKey = (key: string) =>
setProjectScopeKeys(
projectScopeKeys.includes(key)
? projectScopeKeys.filter((candidate) => candidate !== key)
: [...projectScopeKeys, key],
);
// The widened scope outlives the reveal, so it is written back to the persisted
// scope. Route changes arrive from history, links and the palette, and only this
// component holds the project groups that decide a widening, so no single
Expand All @@ -1022,8 +1050,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
useEffect(() => {
if (!revealWidensScope) return;
clearSelection();
setPersistedProjectScopeKey(null);
}, [clearSelection, revealWidensScope, setPersistedProjectScopeKey]);
setPersistedProjectScopeKeys([]);
}, [clearSelection, revealWidensScope, setPersistedProjectScopeKeys]);
const revealActiveRow = useActiveRowReveal(routeReveal, routeRowThreadKey);
const showProjectEnvironments = projectGroupsSpanEnvironments(projectGroups);
const scratchMachineLabelFor = (
Expand All @@ -1040,26 +1068,25 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
return environmentLabelById.get(thread.environmentId) ?? null;
};
const selectedProjectScopeItem =
projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ??
projectScopeItems[0]!;
projectScopeKeys.length > 1
? null
: (projectScopeItems.find((item) => item.value === (projectScopeKeys[0] ?? "all")) ??
projectScopeItems[0]!);
const filteredProjectScopeItems = filterSidebarProjectScopeItems({
items: projectScopeItems,
query: projectScopeMenuState.query,
matches: (item, query) =>
projectScopeFilter.contains(item, query, (candidate) => candidate.label),
});
const scopedProjectGroup = revealWidensScope ? null : chosenProjectGroup;
const scopedProjectGroups = revealWidensScope ? [] : chosenProjectGroups;
const scopedProjectRefs = scopedProjectGroups.flatMap((project) => project.memberProjectRefs);
const scopedProjectKeys = revealWidensScope ? null : chosenScopeProjectKeys;
const threadSearch = useThreadSearch(
searchEnvironmentIds,
threadSearchQuery,
scopedProjectGroup === null
scopedProjectGroups.length === 0
? {}
: {
projectIds: scopedProjectGroup.memberProjectRefs.map(
(projectRef) => projectRef.projectId,
),
},
: { projectIds: scopedProjectRefs.map((projectRef) => projectRef.projectId) },
);
const routeDraftIdForRows = routeTarget?.kind === "draft" ? routeTarget.draftId : null;
const visibleDraftSessionCount = useComposerDraftStore((store) => {
Expand Down Expand Up @@ -2022,6 +2049,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
threadKey,
currentThreadKey: readRouteThreadKey(router),
action: "snooze",
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
now: new Date().toISOString(),
thread: readThreadShell(threadRef),
})
Expand Down Expand Up @@ -2061,6 +2089,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
);
if (threadKeys.length === 0) return;
const count = threadKeys.length;
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
const selectionNow = new Date();
const selectedThreads = threadKeys.flatMap((threadKey) => {
const thread = threadByKeyRef.current.get(threadKey);
Expand Down Expand Up @@ -2094,6 +2123,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const unpinMenuItem = buildBulkUnpinContextMenuItem({
pinnedCount: pinnedSelectedThreads.length,
});
// oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309)
const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat);
const clicked = await settlePromise(() =>
api.contextMenu.show(
Expand Down Expand Up @@ -2406,7 +2436,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
projectFilter: threadProjectGroup
? {
label: threadProjectGroup.displayName,
isActive: projectScopeKey === threadProjectGroup.projectKey,
isActive: projectScopeKeys.includes(threadProjectGroup.projectKey),
}
: null,
isPinned,
Expand Down Expand Up @@ -2444,10 +2474,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
switch (clicked.value) {
case "filter-by-project":
if (threadProjectGroup) {
setProjectScopeKey(
projectScopeKey === threadProjectGroup.projectKey
? null
: threadProjectGroup.projectKey,
setProjectScopeKeys(
projectScopeKeys.includes(threadProjectGroup.projectKey)
? []
: [threadProjectGroup.projectKey],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
}
return;
Expand Down Expand Up @@ -2732,37 +2762,59 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(a, b) => a.value === b.value}
open={projectScopeMenuState.open}
onOpenChange={(open) => {
onOpenChange={(open, eventDetails) => {
if (eventDetails.isCanceled) return;
if (open) suppressNextScopeChangeRef.current = false;
dispatchProjectScopeMenu({ type: "open-changed", open });
}}
onItemHighlighted={(item) => {
highlightedProjectScopeKeyRef.current = item?.value ?? null;
}}
value={selectedProjectScopeItem}
onValueChange={(item) => {
onValueChange={(item, eventDetails) => {
if (suppressNextScopeChangeRef.current) {
suppressNextScopeChangeRef.current = false;
return;
}
if (!item) return;
setProjectScopeKey(item.value === "all" ? null : item.value);
if (item.value === "all") {
setProjectScopeKeys([]);
} else if (
eventDetails.event instanceof MouseEvent &&
eventDetails.event.shiftKey
) {
// Canceling skips Base UI's commit, which would freeze the search query, and its close.
eventDetails.cancel();
toggleProjectScopeKey(item.value);
} else {
setProjectScopeKeys([item.value]);
}
}}
>
<ComboboxTrigger
render={
<SidebarHeaderIconButton
label={
scopedProjectGroup
? `Filter threads by project: ${scopedProjectGroup.displayName}`
scopedProjectGroups.length > 0
? `Filter threads by project: ${scopedProjectGroups.map((project) => project.displayName).join(", ")}`
: "Filter threads by project"
}
/>
}
>
{scopedProjectGroup ? (
{scopedProjectGroups.length > 1 ? (
<span className="flex shrink-0 -space-x-1.5">
{scopedProjectGroups.slice(0, 2).map((project) => (
<ProjectFavicon
key={project.projectKey}
project={project}
className="size-3.5"
/>
))}
</span>
) : scopedProjectGroups[0] ? (
<span className="flex shrink-0">
<ProjectFavicon project={scopedProjectGroup} className="size-4" />
<ProjectFavicon project={scopedProjectGroups[0]} className="size-4" />
</span>
) : (
<ListFilterIcon className="size-4" />
Expand All @@ -2781,15 +2833,27 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
if (
event.defaultPrevented ||
event.nativeEvent.isComposing ||
event.keyCode === 229 ||
event.ctrlKey ||
event.altKey ||
event.metaKey ||
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
event.metaKey
) {
return;
}
const scopeKey = highlightedProjectScopeKeyRef.current;
const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null;
if (event.shiftKey && event.key === "Enter") {
if (!project) return;
event.preventDefault();
toggleProjectScopeKey(project.projectKey);
return;
}
if (
event.key !== "ContextMenu" &&
!(event.shiftKey && event.key === "F10")
) {
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (project) handleProjectSettings(event, project);
}}
onChange={(event) =>
Expand All @@ -2800,23 +2864,31 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
}
/>
<ComboboxEmpty>No matching projects.</ComboboxEmpty>
<ComboboxList>
<ComboboxList aria-multiselectable>
{(item: (typeof projectScopeItems)[number]) => {
const project = projectGroupByScopeKey.get(item.value) ?? null;
const isSelected = project
? projectScopeKeys.includes(item.value)
: projectScopeKeys.length === 0;
return (
<ComboboxItem
key={item.value}
hideIndicator
value={item}
aria-selected={isSelected}
// The icon badge marks selection; Base UI's single value would shade one row.
data-selected={undefined}
onContextMenu={(event) => {
if (project) handleProjectSettings(event, project);
}}
>
{project ? (
<ProjectFavicon project={project} className="size-4 shrink-0" />
) : (
<FolderIcon className="size-4 shrink-0" />
)}
<ProjectScopeRowIcon isSelected={isSelected}>
{project ? (
<ProjectFavicon project={project} className="size-4 shrink-0" />
) : (
<FolderIcon className="size-4 shrink-0" />
)}
</ProjectScopeRowIcon>
<span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
{project && showProjectEnvironments ? (
<ProjectEnvironmentBadge
Expand Down Expand Up @@ -3261,8 +3333,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
Add project
</button>
</>
) : scopedProjectGroup ? (
`No threads in ${scopedProjectGroup.displayName} yet`
) : scopedProjectGroups.length > 1 ? (
"No threads in these projects yet"
) : scopedProjectGroups[0] ? (
`No threads in ${scopedProjectGroups[0].displayName} yet`
) : (
"No threads yet"
)}
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/sidebarProjectGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ export function resolveSidebarProjectScopeKey(input: {
return group?.projectKey ?? (input.canClearMissingScope ? null : input.key);
}

export function resolveSidebarProjectScopeKeys(input: {
readonly groups: ReadonlyArray<SidebarProjectSnapshot>;
readonly keys: ReadonlyArray<string>;
readonly settings?: ProjectGroupingSettings;
readonly canClearMissingScope?: boolean;
}): string[] {
return [
...new Set(input.keys.flatMap((key) => resolveSidebarProjectScopeKey({ ...input, key }) ?? [])),
];
}

export function buildPhysicalToLogicalProjectKeyMap(input: {
projects: ReadonlyArray<Project>;
settings: ProjectGroupingSettings;
Expand Down
Loading
Loading