+
;
attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void;
projectsLength: number;
+ projectRevealTarget: { readonly projectKey: string; readonly requestId: number } | null;
+ completeProjectReveal: (requestId: number) => void;
}
const SidebarProjectsContent = memo(function SidebarProjectsContent(
@@ -2814,6 +2870,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
suppressProjectClickForContextMenuRef,
attachProjectListAutoAnimateRef,
projectsLength,
+ projectRevealTarget,
+ completeProjectReveal,
} = props;
const handleProjectSortOrderChange = useCallback(
@@ -2962,6 +3020,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
}
isManualProjectSorting={isManualProjectSorting}
dragHandleProps={dragHandleProps}
+ projectRevealRequestId={
+ projectRevealTarget?.projectKey === project.projectKey
+ ? projectRevealTarget.requestId
+ : null
+ }
+ completeProjectReveal={completeProjectReveal}
/>
)}
@@ -2992,6 +3056,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
isManualProjectSorting={isManualProjectSorting}
dragHandleProps={null}
+ projectRevealRequestId={
+ projectRevealTarget?.projectKey === project.projectKey
+ ? projectRevealTarget.requestId
+ : null
+ }
+ completeProjectReveal={completeProjectReveal}
/>
))}
@@ -3037,6 +3107,8 @@ export default function Sidebar() {
);
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const openAddProjectCommandPalette = useOpenAddProjectCommandPalette();
+ const sidebarProjectRevealRequest = useSidebarProjectRevealRequest();
+ const completeSidebarProjectReveal = useCompleteSidebarProjectReveal();
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet
>(() => new Set());
@@ -3103,6 +3175,17 @@ export default function Sidebar() {
() => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)),
[sidebarProjects],
);
+ const projectRevealKey = sidebarProjectRevealRequest
+ ? resolveSidebarProjectRevealKey({
+ projectRef: sidebarProjectRevealRequest.projectRef,
+ physicalProjectKeyByScopedRef: projectPhysicalKeyByScopedRef,
+ physicalToLogicalKey,
+ })
+ : null;
+ const projectRevealTarget =
+ projectRevealKey && sidebarProjectRevealRequest
+ ? { projectKey: projectRevealKey, requestId: sidebarProjectRevealRequest.requestId }
+ : null;
const sidebarThreadByKey = useMemo(
() =>
new Map(
@@ -3629,6 +3712,8 @@ export default function Sidebar() {
suppressProjectClickForContextMenuRef={suppressProjectClickForContextMenuRef}
attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
projectsLength={projects.length}
+ projectRevealTarget={projectRevealTarget}
+ completeProjectReveal={completeSidebarProjectReveal}
/>
diff --git a/apps/web/src/sidebarProjectReveal.test.ts b/apps/web/src/sidebarProjectReveal.test.ts
new file mode 100644
index 00000000000..15ffd5a36a2
--- /dev/null
+++ b/apps/web/src/sidebarProjectReveal.test.ts
@@ -0,0 +1,62 @@
+import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
+import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import { describe, expect, it, vi } from "vite-plus/test";
+import {
+ resolveSidebarProjectRevealKey,
+ scrollSidebarProjectIntoView,
+} from "./sidebarProjectReveal";
+
+const projectRef = scopeProjectRef(
+ EnvironmentId.make("environment-local"),
+ ProjectId.make("project-1"),
+);
+
+describe("resolveSidebarProjectRevealKey", () => {
+ it("resolves a physical project to its grouped sidebar row", () => {
+ expect(
+ resolveSidebarProjectRevealKey({
+ projectRef,
+ physicalProjectKeyByScopedRef: new Map([
+ [scopedProjectKey(projectRef), "physical-project"],
+ ]),
+ physicalToLogicalKey: new Map([["physical-project", "logical-project"]]),
+ }),
+ ).toBe("logical-project");
+ });
+
+ it("uses the physical row key when the project is not grouped", () => {
+ expect(
+ resolveSidebarProjectRevealKey({
+ projectRef,
+ physicalProjectKeyByScopedRef: new Map([
+ [scopedProjectKey(projectRef), "physical-project"],
+ ]),
+ physicalToLogicalKey: new Map(),
+ }),
+ ).toBe("physical-project");
+ });
+
+ it("waits until the requested project is available in the sidebar", () => {
+ expect(
+ resolveSidebarProjectRevealKey({
+ projectRef,
+ physicalProjectKeyByScopedRef: new Map(),
+ physicalToLogicalKey: new Map(),
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("scrollSidebarProjectIntoView", () => {
+ it("smoothly centers the selected project header", () => {
+ const scrollIntoView = vi.fn();
+
+ scrollSidebarProjectIntoView({ scrollIntoView });
+
+ expect(scrollIntoView).toHaveBeenCalledWith({
+ behavior: "smooth",
+ block: "center",
+ inline: "nearest",
+ });
+ });
+});
diff --git a/apps/web/src/sidebarProjectReveal.ts b/apps/web/src/sidebarProjectReveal.ts
new file mode 100644
index 00000000000..49d21eef715
--- /dev/null
+++ b/apps/web/src/sidebarProjectReveal.ts
@@ -0,0 +1,30 @@
+import { scopedProjectKey } from "@t3tools/client-runtime/environment";
+import type { ScopedProjectRef } from "@t3tools/contracts";
+
+export interface SidebarProjectRevealRequest {
+ readonly requestId: number;
+ readonly projectRef: ScopedProjectRef;
+}
+
+export function scrollSidebarProjectIntoView(
+ projectHeader: Pick,
+): void {
+ projectHeader.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "nearest",
+ });
+}
+
+export function resolveSidebarProjectRevealKey(input: {
+ readonly projectRef: ScopedProjectRef;
+ readonly physicalProjectKeyByScopedRef: ReadonlyMap;
+ readonly physicalToLogicalKey: ReadonlyMap;
+}): string | null {
+ const physicalKey = input.physicalProjectKeyByScopedRef.get(scopedProjectKey(input.projectRef));
+ if (!physicalKey) {
+ return null;
+ }
+
+ return input.physicalToLogicalKey.get(physicalKey) ?? physicalKey;
+}