diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index 8d2482cd569..d52c7d9db3f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -22,11 +22,13 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; +import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay"; import { DashboardSidebarPortsList } from "./components/DashboardSidebarPortsList"; import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection"; import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; +import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider"; import type { DashboardSidebarProject } from "./types"; interface DashboardSidebarProps { @@ -136,61 +138,65 @@ export function DashboardSidebar({ return ( -
- + + +
+ -
- { - const project = groups.find((p) => p.id === active.id); - setActiveProject(project ?? null); - }} - onDragEnd={handleDragEnd} - onDragCancel={() => setActiveProject(null)} - > - - {orderedGroups.map((project) => ( - - ))} - - - {createPortal( - - {activeProject && ( -
- + { + const project = groups.find((p) => p.id === active.id); + setActiveProject(project ?? null); + }} + onDragEnd={handleDragEnd} + onDragCancel={() => setActiveProject(null)} + > + + {orderedGroups.map((project) => ( + {}} - onToggleCollapse={() => {}} + onWorkspaceHover={refreshWorkspacePullRequest} + onToggleCollapse={toggleProjectCollapsed} /> -
+ ))} + + + {createPortal( + + {activeProject && ( +
+ {}} + onToggleCollapse={() => {}} + /> +
+ )} +
, + document.body, )} -
, - document.body, - )} -
-
- {!isCollapsed && } -
+ +
+ {!isCollapsed && } + + +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.css b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.css new file mode 100644 index 00000000000..7219daef45e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.css @@ -0,0 +1,10 @@ +/* Animate the sidebar hover card sliding between rows. Targets the + * Radix popper wrapper (which carries the position transform) only after + * it has been placed at its anchor (`data-…="ready"`), so the initial + * measuring jump from translate(0, -200%) is not animated. */ +[data-radix-popper-content-wrapper]:has( + > [data-dashboard-sidebar-hover-card="ready"] + ) { + transition: transform 220ms cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx new file mode 100644 index 00000000000..7d6cc1495f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx @@ -0,0 +1,81 @@ +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import type { RefObject } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; +import { useDashboardSidebarHover } from "../../providers/DashboardSidebarHoverProvider"; +import { DashboardSidebarWorkspaceHoverCardContent } from "../DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent"; +import "./DashboardSidebarHoverCardOverlay.css"; + +type Measurable = { getBoundingClientRect(): DOMRect }; + +export function DashboardSidebarHoverCardOverlay({ + children, +}: { + children: React.ReactNode; +}) { + const { + hoveredId, + anchorElement, + payload, + contextMenuOpen, + cancelClose, + requestClose, + forceClose, + } = useDashboardSidebarHover(); + + const virtualRef = useRef(null); + virtualRef.current = anchorElement; + + const open = hoveredId !== null && payload !== null && !contextMenuOpen; + const diffStats = useDiffStats(hoveredId ?? ""); + + // Suppress the transform transition until Radix has placed the popover at + // its real anchor — otherwise the initial jump from the off-screen measuring + // position (translate(0, -200%)) gets animated. + const [hasPositioned, setHasPositioned] = useState(false); + const frameRef = useRef(null); + useEffect(() => { + if (!open) { + setHasPositioned(false); + return; + } + const first = requestAnimationFrame(() => { + frameRef.current = requestAnimationFrame(() => setHasPositioned(true)); + }); + frameRef.current = first; + return () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + }; + }, [open]); + + return ( + { + if (!nextOpen) forceClose(); + }} + > + {children} + } /> + {payload && ( + event.preventDefault()} + onPointerEnter={cancelClose} + onPointerLeave={() => { + if (hoveredId) requestClose(hoveredId); + }} + > + + + )} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/index.ts new file mode 100644 index 00000000000..5d26d8f7664 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarHoverCardOverlay } from "./DashboardSidebarHoverCardOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 08b63e18016..a15b9043320 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,16 +1,16 @@ import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; import { RenameBranchDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; import { useV2WorkspaceNotificationStatus } from "renderer/stores/v2-notifications"; +import { useDashboardSidebarHover } from "../../providers/DashboardSidebarHoverProvider"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; import { DashboardSidebarExpandedWorkspaceRow } from "./components/DashboardSidebarExpandedWorkspaceRow"; import { DashboardSidebarWorkspaceContextMenu } from "./components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu"; -import { DashboardSidebarWorkspaceHoverCardContent } from "./components/DashboardSidebarWorkspaceHoverCardContent"; import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSidebarWorkspaceItemActions"; interface DashboardSidebarWorkspaceItemProps { @@ -89,9 +89,46 @@ export function DashboardSidebarWorkspaceItem({ } : undefined; + const { + hoveredId: hoverHoveredId, + requestOpen: hoverRequestOpen, + requestClose: hoverRequestClose, + syncIfHovered: hoverSyncIfHovered, + } = useDashboardSidebarHover(); + const rowRef = useRef(null); + const hoverEligible = !isPending; + const hoverPayload = useMemo( + () => ({ workspace, onEditBranchClick: setRenameBranchTarget }), + [workspace], + ); + + const handleMouseEnter = useCallback(() => { + if (!hoverEligible || !rowRef.current) return; + hoverRequestOpen(id, rowRef.current, hoverPayload); + }, [hoverEligible, hoverRequestOpen, id, hoverPayload]); + const handleMouseLeave = useCallback(() => { + if (!hoverEligible) return; + hoverRequestClose(id); + }, [hoverEligible, hoverRequestClose, id]); + + const isHovered = hoverHoveredId === id; + useEffect(() => { + if (isHovered && hostType === "local-device") onHoverCardOpen?.(); + }, [isHovered, hostType, onHoverCardOpen]); + useEffect(() => { + if (!isHovered) return; + hoverSyncIfHovered(id, hoverPayload); + }, [isHovered, hoverSyncIfHovered, id, hoverPayload]); + if (isCollapsed) { const content = ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: hover handlers drive a non-interactive popover, no new keyboard semantics +
{(accentColor || isActive) && (
- } isLocalWorkspace={hostType === "local-device"} onCreateSection={handleCreateSection} onMoveToSection={(targetSectionId) => @@ -181,22 +208,29 @@ export function DashboardSidebarWorkspaceItem({ } const expandedContent = ( - setIsDeleteDialogOpen(true)} - onRenameValueChange={setRenameValue} - onSubmitRename={submitRename} - onCancelRename={cancelRename} - /> + // biome-ignore lint/a11y/noStaticElementInteractions: hover handlers drive a non-interactive popover, no new keyboard semantics +
+ setIsDeleteDialogOpen(true)} + onRenameValueChange={setRenameValue} + onSubmitRename={submitRename} + onCancelRename={cancelRename} + /> +
); return ( @@ -209,16 +243,6 @@ export function DashboardSidebarWorkspaceItem({ projectId={projectId} isInSection={isInSection} isUnread={isUnread} - onHoverCardOpen={ - hostType === "local-device" ? onHoverCardOpen : undefined - } - hoverCardContent={ - - } onCreateSection={handleCreateSection} onMoveToSection={(targetSectionId) => moveWorkspaceToSection(id, projectId, targetSectionId) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index 8c74ff2ede4..c650ea308c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -8,14 +8,8 @@ import { ContextMenuSubTrigger, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@superset/ui/hover-card"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useState } from "react"; import { LuArrowRightLeft, LuArrowUp, @@ -30,14 +24,13 @@ import { LuX, } from "react-icons/lu"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useDashboardSidebarHover } from "../../../../providers/DashboardSidebarHoverProvider"; interface DashboardSidebarWorkspaceContextMenuProps { - hoverCardContent?: React.ReactNode; projectId: string; isInSection?: boolean; isLocalWorkspace: boolean; isUnread: boolean; - onHoverCardOpen?: () => void; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; onOpenInFinder: () => void; @@ -55,8 +48,6 @@ export function DashboardSidebarWorkspaceContextMenu({ isInSection, isLocalWorkspace, isUnread, - onHoverCardOpen, - hoverCardContent, onCreateSection, onMoveToSection, onOpenInFinder, @@ -69,7 +60,7 @@ export function DashboardSidebarWorkspaceContextMenu({ children, }: DashboardSidebarWorkspaceContextMenuProps) { const collections = useCollections(); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const { setContextMenuOpen } = useDashboardSidebarHover(); const { data: sections = [] } = useLiveQuery( (q) => q @@ -86,129 +77,100 @@ export function DashboardSidebarWorkspaceContextMenu({ [collections, projectId], ); - const menuContent = ( - event.preventDefault()}> - - - Rename - - {isLocalWorkspace && ( - <> - - - - Open in Finder - - - - Copy Path - - - )} - {!isLocalWorkspace && } - - - Copy Branch Name - - - - {isUnread ? ( - <> - - Mark as Read - - ) : ( + return ( + + {children} + event.preventDefault()}> + + + Rename + + {isLocalWorkspace && ( <> - - Mark as Unread + + + + Open in Finder + + + + Copy Path + )} - - - - - New group from workspace - - {(sections.length > 0 || isInSection) && } - {sections.length > 0 && ( - - - - Move to group - - - {sections.map((section) => ( - onMoveToSection(section.id)} - > - {section.color && ( - - )} - {section.name} - - ))} - - - )} - {isInSection && ( - onMoveToSection(null)}> - - Ungroup + {!isLocalWorkspace && } + + + Copy Branch Name + + + + {isUnread ? ( + <> + + Mark as Read + + ) : ( + <> + + Mark as Unread + + )} + + + + + New group from workspace - )} - - - - Remove from Sidebar - - {onDelete ? ( + {(sections.length > 0 || isInSection) && } + {sections.length > 0 && ( + + + + Move to group + + + {sections.map((section) => ( + onMoveToSection(section.id)} + > + {section.color && ( + + )} + {section.name} + + ))} + + + )} + {isInSection && ( + onMoveToSection(null)}> + + Ungroup + + )} + - - Delete + + Remove from Sidebar - ) : null} - - ); - - if (!hoverCardContent) { - return ( - - {children} - {menuContent} - - ); - } - - return ( - { - if (open) { - onHoverCardOpen?.(); - } - }} - > - - - {children} - - {menuContent} - - - {hoverCardContent} - - + {onDelete ? ( + + + Delete + + ) : null} + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/DashboardSidebarHoverProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/DashboardSidebarHoverProvider.tsx new file mode 100644 index 00000000000..017292f32e9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/DashboardSidebarHoverProvider.tsx @@ -0,0 +1,185 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { DashboardSidebarWorkspace } from "../../types"; + +const OPEN_DELAY_MS = 400; +const CLOSE_DELAY_MS = 100; + +export interface DashboardSidebarHoverPayload { + workspace: DashboardSidebarWorkspace; + onEditBranchClick: (branchName: string) => void; +} + +interface HoverState { + hoveredId: string | null; + anchorElement: HTMLElement | null; + payload: DashboardSidebarHoverPayload | null; +} + +interface HoverContextValue { + hoveredId: string | null; + anchorElement: HTMLElement | null; + payload: DashboardSidebarHoverPayload | null; + contextMenuOpen: boolean; + requestOpen: ( + id: string, + anchor: HTMLElement, + payload: DashboardSidebarHoverPayload, + ) => void; + requestClose: (id: string) => void; + cancelClose: () => void; + forceClose: () => void; + setContextMenuOpen: (open: boolean) => void; + syncIfHovered: (id: string, payload: DashboardSidebarHoverPayload) => void; +} + +const HoverContext = createContext(null); + +export function DashboardSidebarHoverProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [state, setState] = useState({ + hoveredId: null, + anchorElement: null, + payload: null, + }); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + const stateRef = useRef(state); + useEffect(() => { + stateRef.current = state; + }, [state]); + + const openTimerRef = useRef | null>(null); + const closeTimerRef = useRef | null>(null); + + const clearOpenTimer = useCallback(() => { + if (openTimerRef.current) { + clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + }, []); + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const requestOpen = useCallback( + (id, anchor, payload) => { + clearCloseTimer(); + if (stateRef.current.hoveredId !== null) { + clearOpenTimer(); + setState({ hoveredId: id, anchorElement: anchor, payload }); + return; + } + clearOpenTimer(); + openTimerRef.current = setTimeout(() => { + setState({ hoveredId: id, anchorElement: anchor, payload }); + openTimerRef.current = null; + }, OPEN_DELAY_MS); + }, + [clearCloseTimer, clearOpenTimer], + ); + + const requestClose = useCallback( + (id) => { + if (openTimerRef.current && stateRef.current.hoveredId === null) { + // Pending open for this id — cancel it. + clearOpenTimer(); + return; + } + if (stateRef.current.hoveredId !== id) return; + clearCloseTimer(); + closeTimerRef.current = setTimeout(() => { + setState({ hoveredId: null, anchorElement: null, payload: null }); + closeTimerRef.current = null; + }, CLOSE_DELAY_MS); + }, + [clearCloseTimer, clearOpenTimer], + ); + + const cancelClose = useCallback(() => { + clearCloseTimer(); + }, [clearCloseTimer]); + + const forceClose = useCallback(() => { + clearOpenTimer(); + clearCloseTimer(); + setState({ hoveredId: null, anchorElement: null, payload: null }); + }, [clearCloseTimer, clearOpenTimer]); + + const syncIfHovered = useCallback( + (id, payload) => { + setState((prev) => { + if (prev.hoveredId !== id) return prev; + if ( + prev.payload?.workspace === payload.workspace && + prev.payload.onEditBranchClick === payload.onEditBranchClick + ) { + return prev; + } + return { ...prev, payload }; + }); + }, + [], + ); + + useEffect( + () => () => { + clearOpenTimer(); + clearCloseTimer(); + }, + [clearCloseTimer, clearOpenTimer], + ); + + const value = useMemo( + () => ({ + hoveredId: state.hoveredId, + anchorElement: state.anchorElement, + payload: state.payload, + contextMenuOpen, + requestOpen, + requestClose, + cancelClose, + forceClose, + setContextMenuOpen, + syncIfHovered, + }), + [ + state.hoveredId, + state.anchorElement, + state.payload, + contextMenuOpen, + requestOpen, + requestClose, + cancelClose, + forceClose, + syncIfHovered, + ], + ); + + return ( + {children} + ); +} + +export function useDashboardSidebarHover() { + const ctx = useContext(HoverContext); + if (!ctx) { + throw new Error( + "useDashboardSidebarHover must be used inside DashboardSidebarHoverProvider", + ); + } + return ctx; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/index.ts new file mode 100644 index 00000000000..d8e00156972 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/index.ts @@ -0,0 +1,5 @@ +export { + type DashboardSidebarHoverPayload, + DashboardSidebarHoverProvider, + useDashboardSidebarHover, +} from "./DashboardSidebarHoverProvider"; diff --git a/bun.lock b/bun.lock index ed538233849..f68abeb726f 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.6.1", + "version": "1.6.2", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",