diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index 7563e46d142..a541e0d7b31 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -1,7 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "streamdown/styles.css"; -@import "./styles/fade-edge.css"; @source "./**/*.{ts,tsx}"; @source "../../../../packages/ui/src/**/*.{ts,tsx}"; @@ -311,20 +310,6 @@ display: none; /* Chrome/Safari/Electron */ } - /* Fade out the right edge of a horizontally-scrolling container */ - .fade-edge-r { - mask-image: linear-gradient( - to right, - black calc(100% - 1.5rem), - transparent - ); - -webkit-mask-image: linear-gradient( - to right, - black calc(100% - 1.5rem), - transparent - ); - } - /* Dark mode scrollbar styling */ * { scrollbar-width: thin; diff --git a/apps/desktop/src/renderer/lib/dev-chat.ts b/apps/desktop/src/renderer/lib/dev-chat.ts index 35e27230061..ac7d5b7157d 100644 --- a/apps/desktop/src/renderer/lib/dev-chat.ts +++ b/apps/desktop/src/renderer/lib/dev-chat.ts @@ -23,6 +23,11 @@ export const DEV_CHAT_MODELS: ModelOption[] = [ name: "Haiku 4.5", provider: "Anthropic", }, + { + id: "openai/gpt-5.5", + name: "GPT-5.5", + provider: "OpenAI", + }, { id: "openai/gpt-5.4", name: "GPT-5.4", 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/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx index 958d0ff2c69..8ac7e8749fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx @@ -1,3 +1,4 @@ +import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; @@ -67,14 +68,17 @@ export function DashboardSidebarPortGroup({ -
+ {group.ports.map((port) => ( ))} -
+ ); } 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 0878c23cd53..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,13 +1,16 @@ import { useNavigate } from "@tanstack/react-router"; +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 { @@ -67,6 +70,13 @@ export function DashboardSidebarWorkspaceItem({ }); const navigate = useNavigate(); + const { v2Workspaces: v2WorkspaceActions } = useOptimisticCollectionActions(); + const [renameBranchTarget, setRenameBranchTarget] = useState( + null, + ); + const handleAfterBranchRename = (newBranchName: string) => { + v2WorkspaceActions.updateWorkspace(id, { branch: newBranchName }); + }; const isPending = !!creationStatus; // Keep the delete dialog outside the hidden wrapper below — the destroy // flow reopens it into an error pane on conflict/teardown-failed. @@ -79,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) => @@ -153,30 +192,45 @@ export function DashboardSidebarWorkspaceItem({ onDeleted={handleDeleted} /> )} + {renameBranchTarget && ( + { + if (!open) setRenameBranchTarget(null); + }} + onAfterRename={handleAfterBranchRename} + /> + )} ); } 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 ( @@ -189,15 +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) @@ -227,6 +272,17 @@ export function DashboardSidebarWorkspaceItem({ onDeleted={handleDeleted} /> )} + {renameBranchTarget && ( + { + if (!open) setRenameBranchTarget(null); + }} + onAfterRename={handleAfterBranchRename} + /> + )} ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx index 912b877884b..a1d029fe7fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx @@ -1,12 +1,16 @@ import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; import type { ActivePaneStatus } from "shared/tabs-types"; -import type { DashboardSidebarWorkspaceHostType } from "../../../../types"; +import type { + DashboardSidebarWorkspaceHostType, + DashboardSidebarWorkspaceType, +} from "../../../../types"; import { DashboardSidebarWorkspaceIcon } from "../DashboardSidebarWorkspaceIcon"; interface DashboardSidebarCollapsedWorkspaceButtonProps extends ComponentPropsWithoutRef<"button"> { hostType: DashboardSidebarWorkspaceHostType; + workspaceType: DashboardSidebarWorkspaceType; hostIsOnline: boolean | null; isActive: boolean; workspaceStatus?: ActivePaneStatus | null; @@ -20,6 +24,7 @@ export const DashboardSidebarCollapsedWorkspaceButton = forwardRef< ( { hostType, + workspaceType, hostIsOnline, isActive, workspaceStatus = null, @@ -43,6 +48,7 @@ export const DashboardSidebarCollapsedWorkspaceButton = forwardRef< > void; onDoubleClick?: () => void; - onDeleteClick: () => void; + onCloseWorkspaceClick: () => void; + onRemoveFromSidebarClick: () => void; onRenameValueChange: (value: string) => void; onSubmitRename: () => void; onCancelRename: () => void; @@ -63,7 +64,8 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< workspaceStatus = null, onClick, onDoubleClick, - onDeleteClick, + onCloseWorkspaceClick, + onRemoveFromSidebarClick, onRenameValueChange, onSubmitRename, onCancelRename, @@ -105,9 +107,6 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< const workspaceKindDescription = isMainWorkspace ? "Uses the repository checkout on this host" : "Isolated copy for parallel development"; - const closeLabel = isMainWorkspace - ? "Remove from sidebar" - : "Close workspace"; return ( // biome-ignore lint/a11y/noStaticElementInteractions: Mirrors the legacy sidebar row UI, which includes nested action buttons. @@ -167,6 +166,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< > )} - - - - - - - - + {isMainWorkspace ? ( + + + + + + + + + ) : ( + + + + + + + + + )}
)} 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/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx index 667b816e84e..e3dffa07be2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx @@ -2,7 +2,12 @@ import { Button } from "@superset/ui/button"; import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { formatDistanceToNow } from "date-fns"; import { FaGithub } from "react-icons/fa"; -import { LuExternalLink, LuGlobe, LuTriangleAlert } from "react-icons/lu"; +import { + LuExternalLink, + LuGlobe, + LuPencil, + LuTriangleAlert, +} from "react-icons/lu"; import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useHotkeyDisplay } from "renderer/hotkeys"; import type { DashboardSidebarWorkspace } from "../../../../types"; @@ -14,11 +19,13 @@ import { ReviewStatus } from "./components/ReviewStatus"; interface DashboardSidebarWorkspaceHoverCardContentProps { workspace: DashboardSidebarWorkspace; diffStats: DiffStats | null; + onEditBranchClick?: (branchName: string) => void; } export function DashboardSidebarWorkspaceHoverCardContent({ workspace, diffStats, + onEditBranchClick, }: DashboardSidebarWorkspaceHoverCardContentProps) { const { name, @@ -59,23 +66,37 @@ export function DashboardSidebarWorkspaceHoverCardContent({ Branch - {repoUrl && branchExistsOnRemote ? ( - - {branch} - - - ) : ( - - {branch} - - )} +
+ {onEditBranchClick ? ( + + ) : ( + + {branch} + + )} + {repoUrl && branchExistsOnRemote && ( + e.stopPropagation()} + > + + + )} +
{formatDistanceToNow(createdAt, { addSuffix: true })} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx index e2ab16510bc..ed268448037 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx @@ -1,4 +1,5 @@ import { cn } from "@superset/ui/utils"; +import { CgLaptop } from "react-icons/cg"; import { HiExclamationTriangle } from "react-icons/hi2"; import { LuGitMerge, @@ -14,10 +15,12 @@ import type { ActivePaneStatus } from "shared/tabs-types"; import type { DashboardSidebarWorkspaceHostType, DashboardSidebarWorkspacePullRequest, + DashboardSidebarWorkspaceType, } from "../../../../types"; interface DashboardSidebarWorkspaceIconProps { hostType: DashboardSidebarWorkspaceHostType; + workspaceType: DashboardSidebarWorkspaceType; hostIsOnline: boolean | null; isActive: boolean; variant: "collapsed" | "expanded"; @@ -47,6 +50,7 @@ const PR_COLOR_BY_STATE = { export function DashboardSidebarWorkspaceIcon({ hostType, + workspaceType, hostIsOnline, isActive, variant, @@ -71,6 +75,12 @@ export function DashboardSidebarWorkspaceIcon({ } if (hostType === "local-device") { + if (workspaceType === "main") { + return ( + + ); + } + return ; } 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/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx index fb910d4fd49..14a052c8356 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx @@ -17,7 +17,11 @@ import { LuCopy, LuGitBranch, LuX } from "react-icons/lu"; import { createContextMenuDeleteDialogCoordinator } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import type { ActivePaneStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; -import { DeleteWorkspaceDialog, WorkspaceHoverCardContent } from "./components"; +import { + DeleteWorkspaceDialog, + RenameBranchDialog, + WorkspaceHoverCardContent, +} from "./components"; import { HOVER_CARD_CLOSE_DELAY, HOVER_CARD_OPEN_DELAY } from "./constants"; import { WorkspaceIcon } from "./WorkspaceIcon"; @@ -62,6 +66,9 @@ export function CollapsedWorkspaceItem({ [onDeleteClick], ); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const [renameBranchTarget, setRenameBranchTarget] = useState( + null, + ); const collapsedButton = ( + + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/index.ts new file mode 100644 index 00000000000..4c810563485 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/index.ts @@ -0,0 +1 @@ +export { RenameBranchDialog } from "./RenameBranchDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx index 4fab6df7245..42ef7472217 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -6,6 +6,7 @@ import { LuExternalLink, LuGlobe, LuLoaderCircle, + LuPencil, LuTriangleAlert, } from "react-icons/lu"; import { useHotkeyDisplay } from "renderer/hotkeys"; @@ -20,11 +21,13 @@ import { ReviewStatus } from "./components/ReviewStatus"; interface WorkspaceHoverCardContentProps { workspaceId: string; workspaceAlias?: string; + onEditBranchClick?: (branchName: string) => void; } export function WorkspaceHoverCardContent({ workspaceId, workspaceAlias, + onEditBranchClick, }: WorkspaceHoverCardContentProps) { const { data: worktreeInfo } = electronTrpc.workspaces.getWorktreeInfo.useQuery( @@ -80,26 +83,43 @@ export function WorkspaceHoverCardContent({ Branch - {repoUrl && branchExistsOnRemote ? ( - - {branchName} - - - ) : ( - - {branchName} - - )} +
+ {onEditBranchClick ? ( + + ) : ( + + {branchName} + + )} + {repoUrl && branchExistsOnRemote && ( + e.stopPropagation()} + > + + + )} +
)} {worktreeInfo?.createdAt && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts index 489dd1ba41a..4d0ce41efb9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts @@ -1,2 +1,3 @@ export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; +export { RenameBranchDialog } from "./RenameBranchDialog"; export { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; diff --git a/apps/relay/Dockerfile b/apps/relay/Dockerfile index cc658688556..8e4aca9ac67 100644 --- a/apps/relay/Dockerfile +++ b/apps/relay/Dockerfile @@ -4,12 +4,11 @@ WORKDIR /app # Stage 1: Prune monorepo to relay's dependency graph FROM base AS prune COPY . . -RUN bunx turbo prune @superset/relay --docker +RUN bunx turbo@2.8.7 prune @superset/relay --docker # Stage 2: Install deps + copy full source FROM base AS builder COPY --from=prune /app/out/json/ ./ -COPY patches patches RUN bun install --frozen-lockfile --ignore-scripts COPY --from=prune /app/out/full/ ./ diff --git a/apps/relay/fly.toml b/apps/relay/fly.toml index a7bb85dab68..2d3f2dcbf2f 100644 --- a/apps/relay/fly.toml +++ b/apps/relay/fly.toml @@ -28,6 +28,6 @@ primary_region = "sjc" path = "/health" [[vm]] - memory = "2gb" + memory = "4gb" cpu_kind = "performance" cpus = 2 diff --git a/packages/panes/src/core/store/store.test.ts b/packages/panes/src/core/store/store.test.ts index 3f98e498f0d..e42a0392b1c 100644 --- a/packages/panes/src/core/store/store.test.ts +++ b/packages/panes/src/core/store/store.test.ts @@ -598,6 +598,48 @@ describe("movePaneToSplit", () => { }); }); +describe("movePaneToNewTab", () => { + it("moves a pane into a new tab at the requested index", () => { + const store = makeStore(); + store.getState().addTab({ + id: "t1", + panes: [tp("p1"), tp("p2")], + activePaneId: "p1", + }); + store.getState().addTab({ id: "t2", panes: [tp("p3")] }); + + store.getState().movePaneToNewTab({ paneId: "p2", toIndex: 1 }); + + const tabs = store.getState().tabs; + const newTab = tabs[1]; + if (!newTab) throw new Error("Expected new tab at index 1"); + + expect(tabs.map((t) => t.id)).toEqual(["t1", newTab.id, "t2"]); + expect(newTab.panes.p2).toBeDefined(); + expect(newTab.activePaneId).toBe("p2"); + expect(newTab.layout).toEqual({ type: "pane", paneId: "p2" }); + expect(tabs[0]?.panes.p2).toBeUndefined(); + expect(tabs[0]?.layout).toEqual({ type: "pane", paneId: "p1" }); + expect(store.getState().activeTabId).toBe(newTab.id); + }); + + it("keeps insertion position stable when the source tab is removed", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().addTab({ id: "t2", panes: [tp("p2")] }); + + store.getState().movePaneToNewTab({ paneId: "p1", toIndex: 1 }); + + const tabs = store.getState().tabs; + const newTab = tabs[0]; + if (!newTab) throw new Error("Expected new tab at index 0"); + + expect(tabs.map((t) => t.id)).toEqual([newTab.id, "t2"]); + expect(newTab.panes.p1).toBeDefined(); + expect(store.getState().activeTabId).toBe(newTab.id); + }); +}); + describe("edge cases", () => { it("invalid IDs are no-ops", () => { const store = makeStore(); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index 276e5d0d17f..eda98dc6657 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -167,7 +167,7 @@ export interface WorkspaceStore extends WorkspaceState { }) => void; movePaneToTab: (args: { paneId: string; targetTabId: string }) => void; - movePaneToNewTab: (args: { paneId: string }) => void; + movePaneToNewTab: (args: { paneId: string; toIndex?: number }) => void; reorderTab: (args: { tabId: string; toIndex: number }) => void; @@ -795,10 +795,12 @@ export function createWorkspaceStore( set((s) => { let sourceTab: Tab | undefined; let pane: Pane | undefined; - for (const t of s.tabs) { + let sourceTabIndex = -1; + for (const [index, t] of s.tabs.entries()) { if (t.panes[args.paneId]) { sourceTab = t; pane = t.panes[args.paneId]; + sourceTabIndex = index; break; } } @@ -835,7 +837,19 @@ export function createWorkspaceStore( }) .filter((t): t is Tab => t !== null); - nextTabs.push(newTab); + const requestedIndex = args.toIndex ?? nextTabs.length; + const adjustedIndex = + args.toIndex !== undefined && + !nextSourceLayout && + sourceTabIndex < args.toIndex + ? args.toIndex - 1 + : requestedIndex; + const insertIndex = Math.max( + 0, + Math.min(adjustedIndex, nextTabs.length), + ); + + nextTabs.splice(insertIndex, 0, newTab); return { tabs: nextTabs, activeTabId: newTab.id }; }); diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index f0d3a72dd87..69201d88f45 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -78,6 +78,9 @@ export function Workspace({ onReorderTab={(tabId, toIndex) => store.getState().reorderTab({ tabId, toIndex }) } + onMovePaneToNewTab={(paneId, toIndex) => + store.getState().movePaneToNewTab({ paneId, toIndex }) + } getTabTitle={(tab) => resolveTabTitle(tab, tabs, registry)} renderTabIcon={renderTabIcon} renderAddTabMenu={renderAddTabMenu} diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx index d4a34257016..c6fb91f210f 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx @@ -4,18 +4,18 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { cn } from "@superset/ui/utils"; +import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container"; import { PlusIcon } from "lucide-react"; import { + type ComponentProps, type ReactNode, useCallback, - useEffect, - useLayoutEffect, useRef, useState, } from "react"; import { useDrop } from "react-dnd"; import type { Tab } from "../../../../../types"; +import { PANE_DRAG_TYPE } from "../Tab/components/Pane/components/PaneHeader"; import { TAB_DRAG_TYPE, TabItem } from "./components/TabItem"; import { computeInsertIndex, TAB_WIDTH } from "./utils"; @@ -28,12 +28,16 @@ interface TabBarProps { onCloseAllTabs: () => void; onRenameTab: (tabId: string, title: string | undefined) => void; onReorderTab: (tabId: string, toIndex: number) => void; + onMovePaneToNewTab: (paneId: string, toIndex: number) => void; getTabTitle: (tab: Tab) => string; renderTabIcon?: (tab: Tab) => ReactNode; renderAddTabMenu?: () => ReactNode; renderTabAccessory?: (tab: Tab) => ReactNode; } +type TabDragItem = { tabId: string }; +type PaneDragItem = { paneId: string }; + function AddTabButton<_TData>({ renderAddTabMenu, }: { @@ -73,12 +77,12 @@ export function TabBar({ onCloseAllTabs, onRenameTab, onReorderTab, + onMovePaneToNewTab, getTabTitle, renderTabIcon, renderAddTabMenu, renderTabAccessory, }: TabBarProps) { - const scrollContainerRef = useRef(null); const tabsTrackRef = useRef(null); const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); @@ -87,8 +91,8 @@ export function TabBar({ const [{ isOver }, connectDrop] = useDrop( () => ({ - accept: TAB_DRAG_TYPE, - hover: (_item, monitor) => { + accept: [TAB_DRAG_TYPE, PANE_DRAG_TYPE], + hover: (_item: TabDragItem | PaneDragItem, monitor) => { const track = tabsTrackRef.current; const offset = monitor.getClientOffset(); if (!track || !offset) return; @@ -103,10 +107,22 @@ export function TabBar({ setInsertIndex(idx); } }, - drop: (item: { tabId: string }) => { + drop: (item: TabDragItem | PaneDragItem, monitor) => { const idx = insertIndexRef.current; if (idx === null) return; + insertIndexRef.current = null; + setInsertIndex(null); + + if (monitor.getItemType() === PANE_DRAG_TYPE && "paneId" in item) { + onMovePaneToNewTab(item.paneId, idx); + return; + } + + if (monitor.getItemType() !== TAB_DRAG_TYPE || !("tabId" in item)) { + return; + } + const dragIndex = tabs.findIndex((t) => t.id === item.tabId); if (dragIndex === -1) return; @@ -114,15 +130,13 @@ export function TabBar({ let toIndex = idx; if (dragIndex < toIndex) toIndex--; - insertIndexRef.current = null; - setInsertIndex(null); onReorderTab(item.tabId, toIndex); }, collect: (monitor) => ({ isOver: monitor.isOver(), }), }), - [tabs, onReorderTab], + [tabs, onReorderTab, onMovePaneToNewTab], ); // Clear indicator when cursor leaves the tab bar @@ -131,49 +145,29 @@ export function TabBar({ if (insertIndex !== null) setInsertIndex(null); } - const setScrollContainerRef = useCallback( + const setRootRef = useCallback( (node: HTMLDivElement | null) => { - ( - scrollContainerRef as React.MutableRefObject - ).current = node; connectDrop(node); }, [connectDrop], ); - const updateOverflow = useCallback(() => { - const container = scrollContainerRef.current; - const track = tabsTrackRef.current; - if (!container || !track) return; - setHasHorizontalOverflow(track.scrollWidth > container.clientWidth + 1); + const handleOverflowChange = useCallback< + NonNullable< + ComponentProps["onOverflowChange"] + > + >((state) => { + setHasHorizontalOverflow(state.hasOverflowX); }, []); - useLayoutEffect(() => { - const container = scrollContainerRef.current; - const track = tabsTrackRef.current; - if (!container || !track) return; - - updateOverflow(); - const resizeObserver = new ResizeObserver(updateOverflow); - resizeObserver.observe(container); - resizeObserver.observe(track); - window.addEventListener("resize", updateOverflow); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener("resize", updateOverflow); - }; - }, [updateOverflow]); - - useEffect(() => { - requestAnimationFrame(updateOverflow); - }, [updateOverflow]); - const insertLineLeft = insertIndex !== null ? insertIndex * TAB_WIDTH : null; if (tabs.length === 0) { return ( -
+
@@ -183,21 +177,14 @@ export function TabBar({ } return ( -
-
+
{tabs.map((tab, i) => ( @@ -233,7 +220,7 @@ export function TabBar({
)}
-
+ {hasHorizontalOverflow && (
diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx index e96c9ebce60..cb2fb6978c0 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx @@ -6,6 +6,7 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@superset/ui/context-menu"; +import { OverflowFadeText } from "@superset/ui/overflow-fade-text"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { PencilIcon, XIcon } from "lucide-react"; @@ -151,7 +152,9 @@ export function TabItem({ type="button" > {icon && {icon}} - {title} + + {title} + {accessory && ( {accessory} )} diff --git a/packages/trpc/src/router/chat/chat.ts b/packages/trpc/src/router/chat/chat.ts index 0ffeaf658e9..faaef0cee44 100644 --- a/packages/trpc/src/router/chat/chat.ts +++ b/packages/trpc/src/router/chat/chat.ts @@ -29,6 +29,11 @@ const AVAILABLE_MODELS = [ name: "Haiku 4.5", provider: "Anthropic", }, + { + id: "openai/gpt-5.5", + name: "GPT-5.5", + provider: "OpenAI", + }, { id: "openai/gpt-5.4", name: "GPT-5.4", diff --git a/packages/ui/package.json b/packages/ui/package.json index 8d09421e41c..5aa1bb458d5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,6 +8,9 @@ "./lib/*": "./src/lib/*.ts", "./hooks/*": "./src/hooks/*.ts", "./utils": "./src/lib/utils.ts", + "./overflow-fade-container": "./src/components/overflow-fade/OverflowFadeContainer/index.ts", + "./overflow-fade-text": "./src/components/overflow-fade/OverflowFadeText/index.ts", + "./overflow-fade.css": "./src/components/overflow-fade/fade-edge.css", "./ai-elements/*": "./src/components/ai-elements/*.tsx", "./mesh-gradient": "./src/components/mesh-gradient.tsx", "./icons/preset-icons": "./src/assets/icons/preset-icons/index.ts", diff --git a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx new file mode 100644 index 00000000000..3870c8e0a0a --- /dev/null +++ b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { type ComponentProps, useLayoutEffect, useRef } from "react"; +import { + type OverflowFadeState, + useOverflowFade, +} from "../../../hooks/use-overflow-fade"; +import { cn } from "../../../lib/utils"; +import "../fade-edge.css"; + +type OverflowFadeEdge = "top" | "right" | "bottom" | "left"; + +const DEFAULT_FADE_EDGES: OverflowFadeEdge[] = ["right"]; + +interface OverflowFadeContainerProps extends ComponentProps<"div"> { + /** + * Edges to fade while that edge still has hidden scrollable content. + * Keep this for scroll containers; masks apply to the whole painted element. + */ + fadeEdges?: OverflowFadeEdge[]; + /** + * Reports measured overflow for consumers that need layout decisions, such as + * moving an action button outside the scroller once content overflows. + */ + onOverflowChange?: (state: OverflowFadeState) => void; + /** + * Observe direct children for size/list changes. Useful for small dynamic + * scrollers such as tabs; avoid on large or virtualized lists without profiling. + */ + observeChildren?: boolean; +} + +export function OverflowFadeContainer({ + ref: forwardedRef, + className, + fadeEdges = DEFAULT_FADE_EDGES, + onOverflowChange, + observeChildren = false, + ...props +}: OverflowFadeContainerProps) { + const { + ref, + hasOverflowX, + hasOverflowY, + canScrollTop, + canScrollRight, + canScrollBottom, + canScrollLeft, + } = useOverflowFade({ observeChildren }); + + const setRef = (node: HTMLDivElement | null) => { + ref.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }; + + const onOverflowChangeRef = useRef(onOverflowChange); + useLayoutEffect(() => { + onOverflowChangeRef.current = onOverflowChange; + }); + + useLayoutEffect(() => { + onOverflowChangeRef.current?.({ + hasOverflowX, + hasOverflowY, + canScrollLeft, + canScrollRight, + canScrollTop, + canScrollBottom, + }); + }, [ + canScrollBottom, + canScrollLeft, + canScrollRight, + canScrollTop, + hasOverflowX, + hasOverflowY, + ]); + + return ( +
+ ); +} diff --git a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/index.ts b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/index.ts new file mode 100644 index 00000000000..5c1880bd8a8 --- /dev/null +++ b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/index.ts @@ -0,0 +1 @@ +export { OverflowFadeContainer } from "./OverflowFadeContainer"; diff --git a/packages/ui/src/components/overflow-fade/OverflowFadeText/OverflowFadeText.tsx b/packages/ui/src/components/overflow-fade/OverflowFadeText/OverflowFadeText.tsx new file mode 100644 index 00000000000..b3c769658eb --- /dev/null +++ b/packages/ui/src/components/overflow-fade/OverflowFadeText/OverflowFadeText.tsx @@ -0,0 +1,39 @@ +"use client"; + +import type { ComponentPropsWithoutRef } from "react"; +import { useOverflowFade } from "../../../hooks/use-overflow-fade"; +import { cn } from "../../../lib/utils"; +import "../fade-edge.css"; + +interface OverflowFadeTextProps extends ComponentPropsWithoutRef<"span"> { + /** + * Override the fade class for specialized text treatments. The default right + * fade is the expected choice for single-line labels. + */ + fadeClassName?: string; +} + +export function OverflowFadeText({ + className, + fadeClassName = "fade-edge-r", + children, + ...props +}: OverflowFadeTextProps) { + const { ref, hasOverflowX } = useOverflowFade({ + observeParent: true, + }); + + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/overflow-fade/OverflowFadeText/index.ts b/packages/ui/src/components/overflow-fade/OverflowFadeText/index.ts new file mode 100644 index 00000000000..3d5b5adcc5c --- /dev/null +++ b/packages/ui/src/components/overflow-fade/OverflowFadeText/index.ts @@ -0,0 +1 @@ +export { OverflowFadeText } from "./OverflowFadeText"; diff --git a/apps/desktop/src/renderer/styles/fade-edge.css b/packages/ui/src/components/overflow-fade/fade-edge.css similarity index 87% rename from apps/desktop/src/renderer/styles/fade-edge.css rename to packages/ui/src/components/overflow-fade/fade-edge.css index 801718a17d8..8d110b482cd 100644 --- a/apps/desktop/src/renderer/styles/fade-edge.css +++ b/packages/ui/src/components/overflow-fade/fade-edge.css @@ -4,10 +4,6 @@ * Fades out one or more edges of an element via a linear-gradient mask. * Composable: `class="fade-edge-r fade-edge-b"` fades both edges. * Override the fade size per element with `[--fade-edge-size:2rem]`. - * - * NOTE: This is a generic, app-agnostic utility. When a second consumer - * outside `apps/desktop` needs it, promote this file to - * `packages/ui/src/styles/fade-edge.css` and update consumers' imports. */ @layer utilities { diff --git a/packages/ui/src/hooks/use-overflow-fade.ts b/packages/ui/src/hooks/use-overflow-fade.ts new file mode 100644 index 00000000000..7e8dabf51dc --- /dev/null +++ b/packages/ui/src/hooks/use-overflow-fade.ts @@ -0,0 +1,120 @@ +"use client"; + +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +interface UseOverflowFadeOptions { + observeChildren?: boolean; + observeParent?: boolean; +} + +export interface OverflowFadeState { + hasOverflowX: boolean; + hasOverflowY: boolean; + canScrollLeft: boolean; + canScrollRight: boolean; + canScrollTop: boolean; + canScrollBottom: boolean; +} + +const INITIAL_STATE: OverflowFadeState = { + hasOverflowX: false, + hasOverflowY: false, + canScrollLeft: false, + canScrollRight: false, + canScrollTop: false, + canScrollBottom: false, +}; + +function getOverflowState(node: HTMLElement): OverflowFadeState { + const maxScrollLeft = node.scrollWidth - node.clientWidth; + const maxScrollTop = node.scrollHeight - node.clientHeight; + + return { + hasOverflowX: maxScrollLeft > 1, + hasOverflowY: maxScrollTop > 1, + canScrollLeft: node.scrollLeft > 1, + canScrollRight: node.scrollLeft < maxScrollLeft - 1, + canScrollTop: node.scrollTop > 1, + canScrollBottom: node.scrollTop < maxScrollTop - 1, + }; +} + +function areOverflowStatesEqual( + left: OverflowFadeState, + right: OverflowFadeState, +): boolean { + return ( + left.hasOverflowX === right.hasOverflowX && + left.hasOverflowY === right.hasOverflowY && + left.canScrollLeft === right.canScrollLeft && + left.canScrollRight === right.canScrollRight && + left.canScrollTop === right.canScrollTop && + left.canScrollBottom === right.canScrollBottom + ); +} + +export function useOverflowFade({ + observeChildren = false, + observeParent = false, +}: UseOverflowFadeOptions = {}) { + const ref = useRef(null); + const [state, setState] = useState(INITIAL_STATE); + + const updateOverflow = useCallback(() => { + const node = ref.current; + if (!node) return; + + const nextState = getOverflowState(node); + setState((currentState) => + areOverflowStatesEqual(currentState, nextState) + ? currentState + : nextState, + ); + }, []); + + useLayoutEffect(() => { + const node = ref.current; + if (!node) return; + + const resizeObserver = new ResizeObserver(updateOverflow); + + const observeResizeTargets = () => { + resizeObserver.disconnect(); + resizeObserver.observe(node); + + if (observeParent && node.parentElement) { + resizeObserver.observe(node.parentElement); + } + + if (observeChildren) { + for (const child of node.children) { + resizeObserver.observe(child); + } + } + + updateOverflow(); + }; + + observeResizeTargets(); + + const mutationObserver = observeChildren + ? new MutationObserver(observeResizeTargets) + : null; + mutationObserver?.observe(node, { childList: true }); + + node.addEventListener("scroll", updateOverflow, { passive: true }); + window.addEventListener("resize", updateOverflow); + + return () => { + resizeObserver.disconnect(); + mutationObserver?.disconnect(); + node.removeEventListener("scroll", updateOverflow); + window.removeEventListener("resize", updateOverflow); + }; + }, [observeChildren, observeParent, updateOverflow]); + + return { + ref, + ...state, + }; +}