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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { DashboardSidebarProjectSection } from "./components/DashboardSidebarPro
import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext";
import { DashboardSidebarSessionsSection } from "./components/DashboardSidebarSessionsSection";
import { DashboardSidebarWorkspacesHeader } from "./components/DashboardSidebarWorkspacesHeader";
import { SectionDragSpacer } from "./components/SectionDragSpacer";
import { V2SetupScriptCard } from "./components/V2SetupScriptCard";
import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData";
import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts";
Expand All @@ -40,7 +41,10 @@ import {
DashboardSidebarWorkspaceStatusProvider,
type SidebarStatusWorkspaceRef,
} from "./providers/DashboardSidebarWorkspaceStatusProvider";
import type { DashboardSidebarProject } from "./types";
import type {
DashboardSidebarProject,
DashboardSidebarWorkspace,
} from "./types";
import { getProjectChildrenWorkspaces } from "./utils/projectChildren";

interface DashboardSidebarProps {
Expand Down Expand Up @@ -157,21 +161,33 @@ export function DashboardSidebar({
orderedGroups,
sessionWorkspaces,
);
const selectableWorkspaceIds = useMemo(
() =>
new Set(
orderedGroups.flatMap((project) =>
getProjectChildrenWorkspaces(project.children)
.filter(
(workspace) =>
workspace.type === "worktree" &&
workspace.pendingTransaction?.type !== "insert",
)
.map((workspace) => workspace.id),
),
),
[orderedGroups],
);
const selectableWorkspaceIds = useMemo(() => {
const ids = new Set<string>();
const addWorkspace = (workspace: DashboardSidebarWorkspace) => {
if (
workspace.type === "worktree" &&
workspace.pendingTransaction?.type !== "insert"
) {
ids.add(workspace.id);
}
};
for (const project of orderedGroups) {
for (const child of project.children) {
if (child.type === "workspace") {
addWorkspace(child.workspace);
continue;
}
// Members of collapsed groups are hidden and unclickable; keeping
// them selected would leave invisible rows armed for bulk actions
// (including Delete), so collapsing prunes them from the selection.
if (child.section.isCollapsed) continue;
for (const workspace of child.section.workspaces) {
addWorkspace(workspace);
}
}
}
return ids;
}, [orderedGroups]);

// Every workspace the sidebar can render (pinned, sessions, project rows) —
// the status provider fans out bindings queries and event subscriptions for
Expand Down Expand Up @@ -291,6 +307,7 @@ export function DashboardSidebar({
))}
</SortableContext>
)}
<SectionDragSpacer />
</OverflowFadeContainer>
{!isCollapsed && !inlineWorkspacePortsEnabled && (
<DashboardSidebarPortsList />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ export function DashboardSidebarBulkActions({
<div
role="toolbar"
aria-label="Selected workspace actions"
className="flex min-h-8 w-full shrink-0 items-center gap-0.5 py-1 pl-2 pr-2"
// Sticky: the toolbar's natural slot (the Workspaces header) can be
// scrolled far out of view when selecting rows at the bottom of a
// long sidebar — pin it to the scroller top so the selection always
// has visible actions.
className="sticky top-0 z-10 flex min-h-8 w-full shrink-0 items-center gap-0.5 bg-background/85 py-1 pl-2 pr-2 backdrop-blur-sm"
>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { cn } from "@superset/ui/utils";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import {
Expand Down Expand Up @@ -124,63 +123,48 @@ export function DashboardSidebarExpandedProjectContent({
const isInSection = !!group;
const isInCollapsedSection =
isInSection && collapsedSectionIds.has(group.sectionId);
const sectionDragActive =
activeType === "section" && activeContainer === projectId;
const hidden =
isInCollapsedSection ||
(activeType === "section" &&
activeContainer === projectId &&
isInSection);
isInCollapsedSection || (sectionDragActive && isInSection);
const canBulkSelect =
workspace.type === "worktree" &&
workspace.pendingTransaction?.type !== "insert";

// Rows collapse via a CSS grid-row transition instead of a
// per-row AnimatePresence/motion.div (~80 motion components
// cost real render time). Hidden rows stay mounted: `inert`
// removes them from focus/hit-testing and the disabled
// sortable unregisters their droppable, matching the old
// unmount behavior for DnD.
// The zero-height collapse lives inside the sortable wrapper
// (see SortableWorkspaceItem) so the clip box moves with the
// dnd translate — wrapping here would clip displaced rows
// out of view mid-drag.
return (
<div
<SortableWorkspaceItem
key={String(id)}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-150 ease-out",
hidden
? "grid-rows-[0fr] opacity-0"
: "grid-rows-[1fr] opacity-100",
)}
inert={hidden}
>
<div className="min-h-0 overflow-hidden">
<SortableWorkspaceItem
sortableId={String(id)}
workspace={workspace}
accentColor={group?.color}
isInSection={isInSection}
onHoverCardOpen={onWorkspaceHover}
shortcutLabel={workspaceShortcutLabels.get(
parsed.realId,
)}
isSelected={
canBulkSelect && isWorkspaceSelected(parsed.realId)
}
onSelectionClick={
canBulkSelect
? (event) =>
selectWorkspaceFromEvent(event, {
workspaceId: parsed.realId,
projectId,
orderedWorkspaceIds: selectableWorkspaceIds,
})
: undefined
}
disabled={
hidden ||
(workspace.type === "main" &&
workspace.hostType === "local-device")
}
/>
</div>
</div>
sortableId={String(id)}
workspace={workspace}
accentColor={group?.color}
isInSection={isInSection}
onHoverCardOpen={onWorkspaceHover}
shortcutLabel={workspaceShortcutLabels.get(parsed.realId)}
isSelected={
canBulkSelect && isWorkspaceSelected(parsed.realId)
}
onSelectionClick={
canBulkSelect
? (event) =>
selectWorkspaceFromEvent(event, {
workspaceId: parsed.realId,
projectId,
orderedWorkspaceIds: selectableWorkspaceIds,
})
: undefined
}
collapsed={hidden}
collapseInstantly={sectionDragActive}
disabled={
hidden ||
(workspace.type === "main" &&
workspace.hostType === "local-device")
}
/>
);
})}
</SortableContext>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useDashboardSidebarDnd } from "../../hooks/useSidebarDnd";

/**
* Keeps the sidebar scroller's scrollHeight constant while a section drag
* hides the group's member rows. Without it, the pickup shrinks the content;
* when scrolled near the bottom the browser clamps scrollTop, and dnd-kit
* folds that scroll delta into the DragOverlay transform — the ghost then
* tracks the collapsed height away from the cursor for the whole drag.
*/
export function SectionDragSpacer() {
const { sectionDragSpacerPx } = useDashboardSidebarDnd();
if (sectionDragSpacerPx === 0) return null;
return <div aria-hidden style={{ height: sectionDragSpacerPx }} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SectionDragSpacer } from "./SectionDragSpacer";
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ export function SortableSectionHeader({
style={{
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
// Fully hidden while dragging (the DragOverlay ghost is the drag
// representation): the section pickup collapses the member rows,
// which invalidates dnd-kit's cached initial rect for this node —
// its in-list preview transform then points rows away from the
// real drop slot. Displaced siblings still open the correct gap.
opacity: isDragging ? 0 : undefined,
borderLeft: hasColor
? `2px solid ${section.color}`
: "2px solid var(--color-border)",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@superset/ui/utils";
import { useMemo } from "react";
import type { WorkspaceSelectionEvent } from "../../providers/DashboardSidebarSelectionProvider";
import type { DashboardSidebarWorkspace } from "../../types";
Expand All @@ -13,6 +14,21 @@ interface SortableWorkspaceItemProps {
onHoverCardOpen?: (workspaceId: string) => void | Promise<void>;
shortcutLabel?: string;
disabled?: boolean;
/**
* Collapse the row to zero height (collapsed group / active section drag)
* while keeping it mounted. Lives here — inside the sortable wrapper — so
* the clip box moves WITH the dnd translate; an outer overflow-hidden
* wrapper would clip displaced rows out of view mid-drag.
*/
collapsed?: boolean;
/**
* Skip the collapse transition. Required while a section drag is active:
* dnd-kit re-measures droppables when the hidden members unregister, and
* an animated collapse means that measure runs mid-transition — every row
* below the group then keeps a stale rect for the rest of the drag, so
* downward drops target the wrong slot.
*/
collapseInstantly?: boolean;
isSelected?: boolean;
onSelectionClick?: (event: WorkspaceSelectionEvent) => boolean;
/** Set for rows rendered inside the top-level Pinned section. */
Expand All @@ -27,6 +43,8 @@ export function SortableWorkspaceItem({
onHoverCardOpen,
shortcutLabel,
disabled,
collapsed = false,
collapseInstantly = false,
isSelected = false,
onSelectionClick,
pinnedContext,
Expand Down Expand Up @@ -78,7 +96,24 @@ export function SortableWorkspaceItem({
{...attributes}
{...listeners}
>
{row}
{/* Rows collapse via a CSS grid-row transition instead of a per-row
AnimatePresence/motion.div (~80 motion components cost real render
time). Collapsed rows stay mounted: `inert` removes them from
focus/hit-testing and the disabled sortable unregisters their
droppable, matching the old unmount behavior for DnD. */}
<div
className={cn(
"grid",
!collapseInstantly &&
"transition-[grid-template-rows,opacity] duration-150 ease-out",
collapsed
? "grid-rows-[0fr] opacity-0"
: "grid-rows-[1fr] opacity-100",
)}
inert={collapsed}
>
<div className="min-h-0 overflow-hidden">{row}</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ export function useBulkWorkspaceMoveActions({

const ungroupSelection = () => {
if (!projectId) return;
for (const workspaceId of groupedWorkspaceIds) {
// Each move inserts directly below the row's former group, so processing
// in visual order would stack the rows reversed. Iterate back-to-front to
// keep the selection's visual order.
for (const workspaceId of [...groupedWorkspaceIds].reverse()) {
moveWorkspaceToSection(workspaceId, projectId, null);
}
clearSelection();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,30 @@ describe("buildDashboardSidebarProjects", () => {
).toEqual(["section-b", "section-a"]);
});

it("keeps an ungrouped workspace top-level below a section instead of absorbing it", () => {
const [project] = build({
sidebarSections: [makeSection({ id: "section-1", tabOrder: 2 })],
visibleSidebarWorkspaces: [
makeWorkspace({ id: "ws-above", sectionId: null, tabOrder: 1 }),
makeWorkspace({ id: "ws-member", sectionId: "section-1", tabOrder: 1 }),
makeWorkspace({ id: "ws-below", sectionId: null, tabOrder: 3 }),
],
});

expect(
project.children.map((child) =>
child.type === "section"
? `section:${child.section.id}`
: child.workspace.id,
),
).toEqual(["ws-above", "section:section-1", "ws-below"]);
const section = project.children.find((child) => child.type === "section");
if (section?.type !== "section") throw new Error("expected section");
expect(section.section.workspaces.map((workspace) => workspace.id)).toEqual(
["ws-member"],
);
});

it("orders multiple orphaned workspaces by tabOrder above the sections", () => {
const [project] = build({
sidebarSections: [makeSection({ id: "section-1", tabOrder: 5 })],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,25 +328,11 @@ export function buildDashboardSidebarProjects({
})
.map(({ child }) => child);

// Ungrouped workspaces rendered after a section header are visually
// grouped with that section (shared accent, collapse-together) and will
// be committed into it on next DnD. Reparent them here so section counts
// match what the user sees.
const children: DashboardSidebarProjectChild[] = [];
let currentSection: DashboardSidebarSection | null = null;
for (const child of sortedChildren) {
if (child.type === "section") {
currentSection = child.section;
children.push(child);
} else if (currentSection) {
currentSection.workspaces.push({
...child.workspace,
accentColor: currentSection.color,
});
} else {
children.push(child);
}
}
// Section membership is explicit (sectionId): an ungrouped workspace
// keeps its top-level slot even when its tabOrder places it below a
// section header — groups reorder like any other item, so ungrouped
// rows and sections interleave freely.
const children: DashboardSidebarProjectChild[] = [...sortedChildren];

if (orphanedWorkspaces.length > 0) {
const firstSectionIndex = children.findIndex(
Expand Down
Loading
Loading