From 6a126473d709b3f8f77b51d917b4189034123bac Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 17:44:20 -0700 Subject: [PATCH 01/20] feat(desktop): rewrite sidebar DnD with flat list approach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace multi-container pattern with a single flat SortableContext. Sections are just headers/dividers — position determines membership. Workspaces before the first section are ungrouped, workspaces after a section header belong to that section. This eliminates all multi-container complexity: no onDragOver, no findContainer, no custom collision detection, no container key mismatches, no cross-container oscillation. Just arrayMove on a flat list + parse positions on drop to persist. Section drags move the header and all its workspaces as a group. Collapsed sections exclude their workspaces from the flat array. --- .../DashboardSidebarProjectSection.tsx | 4 +- ...DashboardSidebarExpandedProjectContent.tsx | 119 +++++-- .../SidebarDragOverlay/SidebarDragOverlay.tsx | 48 +++ .../components/SidebarDragOverlay/index.ts | 1 + .../SortableSectionHeader.tsx | 86 +++++ .../components/SortableSectionHeader/index.ts | 1 + .../SortableWorkspaceItem.tsx | 42 +++ .../components/SortableWorkspaceItem/index.ts | 1 + .../hooks/useSidebarDnd/index.ts | 1 + .../hooks/useSidebarDnd/useSidebarDnd.ts | 297 ++++++++++++++++++ .../useDashboardSidebarState.ts | 59 ++++ 11 files changed, 629 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index 13fc81d25a2..277a32a408f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -26,7 +26,7 @@ export function DashboardSidebarProjectSection({ onWorkspaceHover, onToggleCollapse, }: DashboardSidebarProjectSectionProps) { - const allSections = useMemo( + const _allSections = useMemo( () => getProjectChildrenSections(project.children), [project.children], ); @@ -108,9 +108,9 @@ export function DashboardSidebarProjectSection({ ; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onDeleteSection: (sectionId: string) => void; @@ -15,15 +28,28 @@ interface DashboardSidebarExpandedProjectContentProps { } export function DashboardSidebarExpandedProjectContent({ + projectId, isCollapsed, projectChildren, - allSections, workspaceShortcutLabels, onWorkspaceHover, onDeleteSection, onRenameSection, onToggleSectionCollapse, }: DashboardSidebarExpandedProjectContentProps) { + const { + sensors, + measuring, + collisionDetection, + flatItems, + activeId, + activeItem, + groupInfo, + workspacesById, + sectionsById, + handlers, + } = useSidebarDnd({ projectId, projectChildren }); + return ( {!isCollapsed && ( @@ -35,30 +61,67 @@ export function DashboardSidebarExpandedProjectContent({ className="overflow-hidden" >
- {projectChildren.map((child) => - child.type === "workspace" ? ( - onWorkspaceHover(child.workspace.id)} - shortcutLabel={workspaceShortcutLabels.get( - child.workspace.id, - )} - /> - ) : ( - - ), - )} + + + {flatItems.map((id) => { + const parsed = parseId(id); + if (!parsed) return null; + + if (parsed.type === "section") { + const section = sectionsById.get(parsed.realId); + if (!section) return null; + return ( + + ); + } + + const workspace = workspacesById.get(parsed.realId); + if (!workspace) return null; + const group = groupInfo.get(parsed.realId); + + return ( + onWorkspaceHover(parsed.realId)} + shortcutLabel={workspaceShortcutLabels.get(parsed.realId)} + /> + ); + })} + + + {createPortal( + + {activeId ? ( + + ) : null} + , + document.body, + )} +
)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx new file mode 100644 index 00000000000..13a0f755b9a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx @@ -0,0 +1,48 @@ +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; +import type { + DashboardSidebarSection, + DashboardSidebarWorkspace, +} from "../../types"; +import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; + +type ActiveItem = + | { type: "workspace"; workspace: DashboardSidebarWorkspace } + | { type: "section"; section: DashboardSidebarSection }; + +interface SidebarDragOverlayProps { + activeItem: ActiveItem | null; +} + +export function SidebarDragOverlay({ activeItem }: SidebarDragOverlayProps) { + if (!activeItem) return null; + + if (activeItem.type === "workspace") { + return ( +
+ +
+ ); + } + + const { section } = activeItem; + const hasColor = + section.color != null && section.color !== PROJECT_COLOR_DEFAULT; + + return ( +
+
+ {section.name} + + ({section.workspaces.length}) + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts new file mode 100644 index 00000000000..58e16f18656 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts @@ -0,0 +1 @@ +export { SidebarDragOverlay } from "./SidebarDragOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx new file mode 100644 index 00000000000..ffaf10713cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx @@ -0,0 +1,86 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useState } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; +import type { DashboardSidebarSection } from "../../types"; +import { DashboardSidebarSectionContextMenu } from "../DashboardSidebarSection/components/DashboardSidebarSectionContextMenu"; +import { DashboardSidebarSectionHeader } from "../DashboardSidebarSection/components/DashboardSidebarSectionHeader"; + +interface SortableSectionHeaderProps { + sortableId: string; + section: DashboardSidebarSection; + onDelete: (sectionId: string) => void; + onRename: (sectionId: string, name: string) => void; + onToggleCollapse: (sectionId: string) => void; +} + +export function SortableSectionHeader({ + sortableId, + section, + onDelete, + onRename, + onToggleCollapse, +}: SortableSectionHeaderProps) { + const { setSectionColor } = useDashboardSidebarState(); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(section.name); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: sortableId }); + + const hasColor = + section.color != null && section.color !== PROJECT_COLOR_DEFAULT; + + const handleSubmitRename = () => { + const trimmed = renameValue.trim(); + if (trimmed) onRename(section.id, trimmed); + setIsRenaming(false); + }; + + return ( +
+ setIsRenaming(true)} + onSetColor={(color) => setSectionColor(section.id, color)} + onDelete={() => onDelete(section.id)} + > + { + setRenameValue(section.name); + setIsRenaming(false); + }} + onStartRename={() => { + setRenameValue(section.name); + setIsRenaming(true); + }} + onToggleCollapse={() => onToggleCollapse(section.id)} + {...attributes} + {...listeners} + /> + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts new file mode 100644 index 00000000000..6816fe842bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts @@ -0,0 +1 @@ +export { SortableSectionHeader } from "./SortableSectionHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx new file mode 100644 index 00000000000..61e7daf7368 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -0,0 +1,42 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { DashboardSidebarWorkspace } from "../../types"; +import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; + +interface SortableWorkspaceItemProps { + sortableId: string; + workspace: DashboardSidebarWorkspace; + accentColor?: string | null; + onHoverCardOpen?: () => void; + shortcutLabel?: string; +} + +export function SortableWorkspaceItem({ + sortableId, + workspace, + accentColor, + onHoverCardOpen, + shortcutLabel, +}: SortableWorkspaceItemProps) { + const { setNodeRef, listeners, isDragging, transform, transition } = + useSortable({ id: sortableId }); + + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts new file mode 100644 index 00000000000..b10a73a7c4f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts @@ -0,0 +1 @@ +export { SortableWorkspaceItem } from "./SortableWorkspaceItem"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts new file mode 100644 index 00000000000..342f8978ef9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts @@ -0,0 +1 @@ +export { useSidebarDnd } from "./useSidebarDnd"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts new file mode 100644 index 00000000000..6b6a4927589 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -0,0 +1,297 @@ +import { + closestCenter, + type DragEndEvent, + type DragStartEvent, + KeyboardSensor, + MeasuringStrategy, + MouseSensor, + TouchSensor, + type UniqueIdentifier, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import type { + DashboardSidebarProjectChild, + DashboardSidebarSection, + DashboardSidebarWorkspace, +} from "../../types"; + +// ── ID helpers ─────────────────────────────────────────────────────── + +const WS = "ws::"; +const SEC = "sec::"; + +export const wsId = (id: string) => `${WS}${id}`; +export const secId = (id: string) => `${SEC}${id}`; +export const isSec = (id: UniqueIdentifier) => String(id).startsWith(SEC); + +export const parseId = (id: UniqueIdentifier) => { + const s = String(id); + if (s.startsWith(WS)) + return { type: "workspace" as const, realId: s.slice(WS.length) }; + if (s.startsWith(SEC)) + return { type: "section" as const, realId: s.slice(SEC.length) }; + return null; +}; + +// ── Measuring config ───────────────────────────────────────────────── + +export const measuring = { + droppable: { strategy: MeasuringStrategy.Always as const }, +}; + +// ── Build flat list from project children ──────────────────────────── + +function buildFlatItems( + children: DashboardSidebarProjectChild[], +): UniqueIdentifier[] { + const items: UniqueIdentifier[] = []; + for (const child of children) { + if (child.type === "workspace") { + items.push(wsId(child.workspace.id)); + } else { + items.push(secId(child.section.id)); + // Only include workspaces if section is expanded + if (!child.section.isCollapsed) { + for (const ws of child.section.workspaces) { + items.push(wsId(ws.id)); + } + } + } + } + return items; +} + +// ── Parse flat list to determine section membership ────────────────── + +interface ParsedFlatItems { + topLevel: Array<{ type: "workspace" | "section"; id: string }>; + sections: Record; +} + +function parseFlatItems(items: UniqueIdentifier[]): ParsedFlatItems { + const result: ParsedFlatItems = { topLevel: [], sections: {} }; + let currentSection: string | null = null; + + for (const id of items) { + const parsed = parseId(id); + if (!parsed) continue; + if (parsed.type === "section") { + currentSection = parsed.realId; + result.topLevel.push({ type: "section", id: parsed.realId }); + result.sections[parsed.realId] = []; + } else if (parsed.type === "workspace") { + if (currentSection) { + result.sections[currentSection].push(parsed.realId); + } else { + result.topLevel.push({ type: "workspace", id: parsed.realId }); + } + } + } + return result; +} + +// ── Hook ───────────────────────────────────────────────────────────── + +interface UseSidebarDndOptions { + projectId: string; + projectChildren: DashboardSidebarProjectChild[]; +} + +export function useSidebarDnd({ + projectId, + projectChildren, +}: UseSidebarDndOptions) { + const { reorderProjectChildren, moveWorkspaceToSectionAtIndex } = + useDashboardSidebarState(); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const [flatItems, setFlatItems] = useState(() => + buildFlatItems(projectChildren), + ); + const [activeId, setActiveId] = useState(null); + const clonedRef = useRef(null); + + // Sync from external data only when items are added/removed + const prevFingerprintRef = useRef(""); + useEffect(() => { + const fingerprint = projectChildren + .map((c) => + c.type === "workspace" + ? c.workspace.id + : `s:${c.section.id}:${c.section.isCollapsed}`, + ) + .sort() + .join(","); + if (fingerprint !== prevFingerprintRef.current) { + prevFingerprintRef.current = fingerprint; + setFlatItems(buildFlatItems(projectChildren)); + } + }, [projectChildren]); + + // ── Lookups ────────────────────────────────────────────────────── + + const workspacesById = useMemo(() => { + const map = new Map(); + for (const child of projectChildren) { + if (child.type === "workspace") { + map.set(child.workspace.id, child.workspace); + } else { + for (const ws of child.section.workspaces) { + map.set(ws.id, ws); + } + } + } + return map; + }, [projectChildren]); + + const sectionsById = useMemo(() => { + const map = new Map(); + for (const child of projectChildren) { + if (child.type === "section") { + map.set(child.section.id, child.section); + } + } + return map; + }, [projectChildren]); + + // Which section does each workspace belong to? (for visual grouping) + const groupInfo = useMemo(() => { + const map = new Map(); + let currentSection: { id: string; color: string | null } | null = null; + + for (const id of flatItems) { + const parsed = parseId(id); + if (!parsed) continue; + if (parsed.type === "section") { + const sec = sectionsById.get(parsed.realId); + currentSection = sec ? { id: sec.id, color: sec.color } : null; + } else if (parsed.type === "workspace" && currentSection) { + map.set(parsed.realId, { + sectionId: currentSection.id, + color: currentSection.color, + }); + } + } + return map; + }, [flatItems, sectionsById]); + + const activeItem = useMemo(() => { + if (!activeId) return null; + const parsed = parseId(activeId); + if (!parsed) return null; + if (parsed.type === "workspace") { + const ws = workspacesById.get(parsed.realId); + return ws ? { type: "workspace" as const, workspace: ws } : null; + } + const sec = sectionsById.get(parsed.realId); + return sec ? { type: "section" as const, section: sec } : null; + }, [activeId, workspacesById, sectionsById]); + + // ── Persistence ────────────────────────────────────────────────── + + const commitToDb = useCallback( + (items: UniqueIdentifier[]) => { + const parsed = parseFlatItems(items); + + // Top-level order (ungrouped workspaces + sections interleaved) + reorderProjectChildren(projectId, parsed.topLevel); + + // Each section's workspace order + for (const [sectionId, wsIds] of Object.entries(parsed.sections)) { + for (let i = 0; i < wsIds.length; i++) { + moveWorkspaceToSectionAtIndex(wsIds[i], projectId, sectionId, i); + } + } + }, + [projectId, reorderProjectChildren, moveWorkspaceToSectionAtIndex], + ); + + // ── Handlers ───────────────────────────────────────────────────── + + const onDragStart = useCallback( + ({ active }: DragStartEvent) => { + setActiveId(active.id); + clonedRef.current = [...flatItems]; + }, + [flatItems], + ); + + const onDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + setActiveId(null); + + if (!over || active.id === over.id) return; + + const oldIndex = flatItems.indexOf(active.id); + const overIndex = flatItems.indexOf(over.id); + if (oldIndex === -1 || overIndex === -1) return; + + let newItems: UniqueIdentifier[]; + + if (isSec(active.id)) { + // Section drag: move the header AND all its workspaces as a group + const groupStart = oldIndex; + let groupEnd = groupStart + 1; + while (groupEnd < flatItems.length && !isSec(flatItems[groupEnd])) { + groupEnd++; + } + const group = flatItems.slice(groupStart, groupEnd); + const without = [ + ...flatItems.slice(0, groupStart), + ...flatItems.slice(groupEnd), + ]; + + // Find insertion point in the array without the group + const newOverIndex = without.indexOf(over.id); + const insertAt = newOverIndex >= 0 ? newOverIndex : without.length; + + newItems = [ + ...without.slice(0, insertAt), + ...group, + ...without.slice(insertAt), + ]; + } else { + // Workspace drag: simple arrayMove + newItems = arrayMove(flatItems, oldIndex, overIndex); + } + + setFlatItems(newItems); + commitToDb(newItems); + }, + [flatItems, commitToDb], + ); + + const onDragCancel = useCallback(() => { + if (clonedRef.current) { + setFlatItems(clonedRef.current); + } + setActiveId(null); + clonedRef.current = null; + }, []); + + return { + sensors, + measuring, + collisionDetection: closestCenter, + flatItems, + activeId, + activeItem, + groupInfo, + workspacesById, + sectionsById, + handlers: { onDragStart, onDragEnd, onDragCancel }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 1ab72fb083a..73859e5e81c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -123,6 +123,63 @@ export function useDashboardSidebarState() { [collections], ); + const reorderProjectChildren = useCallback( + ( + projectId: string, + orderedItems: Array<{ type: "workspace" | "section"; id: string }>, + ) => { + orderedItems.forEach((item, index) => { + const tabOrder = index + 1; + if (item.type === "workspace") { + if (!collections.v2WorkspaceLocalState.get(item.id)) return; + collections.v2WorkspaceLocalState.update(item.id, (draft) => { + draft.sidebarState.tabOrder = tabOrder; + draft.sidebarState.sectionId = null; + draft.sidebarState.projectId = projectId; + }); + } else { + if (!collections.v2SidebarSections.get(item.id)) return; + collections.v2SidebarSections.update(item.id, (draft) => { + draft.tabOrder = tabOrder; + }); + } + }); + }, + [collections], + ); + + const moveWorkspaceToSectionAtIndex = useCallback( + ( + workspaceId: string, + projectId: string, + sectionId: string, + index: number, + ) => { + const existing = collections.v2WorkspaceLocalState.get(workspaceId); + if (!existing) return; + const siblings = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) + .filter( + (item) => + item.sidebarState.projectId === projectId && + item.workspaceId !== workspaceId && + item.sidebarState.sectionId === sectionId, + ) + .sort((a, b) => a.sidebarState.tabOrder - b.sidebarState.tabOrder); + const reordered = [...siblings]; + reordered.splice(index, 0, existing); + reordered.forEach((item, i) => { + collections.v2WorkspaceLocalState.update(item.workspaceId, (draft) => { + draft.sidebarState.tabOrder = i + 1; + draft.sidebarState.sectionId = sectionId; + draft.sidebarState.projectId = projectId; + }); + }); + }, + [collections], + ); + const createSection = useCallback( (projectId: string, name = "New Section") => { ensureSidebarProjectRecord(collections, projectId); @@ -274,7 +331,9 @@ export function useDashboardSidebarState() { ensureProjectInSidebar, ensureWorkspaceInSidebar, moveWorkspaceToSection, + moveWorkspaceToSectionAtIndex, removeProjectFromSidebar, + reorderProjectChildren, removeWorkspaceFromSidebar, reorderProjects, reorderWorkspaces, From d751262b3718e92c3ab3e9e19f152c846e194695 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:00:05 -0700 Subject: [PATCH 02/20] feat(desktop): separate section and workspace drag modes When dragging a section (via grip), only section IDs are in the SortableContext. Workspaces inside sections are hidden visually. Sections reorder among each other only. On drop, the full flat list is rebuilt with workspaces in their correct positions. When dragging a workspace, the full flat list is in SortableContext. Sections are inert position references. Standard arrayMove + persist. --- ...DashboardSidebarExpandedProjectContent.tsx | 8 +- .../hooks/useSidebarDnd/useSidebarDnd.ts | 91 ++++++++++++------- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 673a30f60d7..d5492b0cf79 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -42,7 +42,9 @@ export function DashboardSidebarExpandedProjectContent({ measuring, collisionDetection, flatItems, + sortableItems, activeId, + activeType, activeItem, groupInfo, workspacesById, @@ -68,7 +70,7 @@ export function DashboardSidebarExpandedProjectContent({ {...handlers} > {flatItems.map((id) => { @@ -90,6 +92,10 @@ export function DashboardSidebarExpandedProjectContent({ ); } + // During section drag, hide workspaces that belong to a section + if (activeType === "section" && groupInfo.has(parsed.realId)) + return null; + const workspace = workspacesById.get(parsed.realId); if (!workspace) return null; const group = groupInfo.get(parsed.realId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts index 6b6a4927589..9521d53f673 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -122,8 +122,22 @@ export function useSidebarDnd({ buildFlatItems(projectChildren), ); const [activeId, setActiveId] = useState(null); + const activeType: "workspace" | "section" | null = activeId + ? isSec(activeId) + ? "section" + : "workspace" + : null; const clonedRef = useRef(null); + // When dragging a section, SortableContext only has section IDs. + // When dragging a workspace (or idle), SortableContext has everything. + const sortableItems = useMemo(() => { + if (activeType === "section") { + return flatItems.filter((id) => isSec(id)); + } + return flatItems; + }, [flatItems, activeType]); + // Sync from external data only when items are added/removed const prevFingerprintRef = useRef(""); useEffect(() => { @@ -235,41 +249,54 @@ export function useSidebarDnd({ if (!over || active.id === over.id) return; - const oldIndex = flatItems.indexOf(active.id); - const overIndex = flatItems.indexOf(over.id); - if (oldIndex === -1 || overIndex === -1) return; - - let newItems: UniqueIdentifier[]; - if (isSec(active.id)) { - // Section drag: move the header AND all its workspaces as a group - const groupStart = oldIndex; - let groupEnd = groupStart + 1; - while (groupEnd < flatItems.length && !isSec(flatItems[groupEnd])) { - groupEnd++; + // Section drag: only section IDs were in the SortableContext. + // Reorder sections, then rebuild the full flat list with + // workspaces in their original positions under each section. + const sectionIds = flatItems.filter((id) => isSec(id)); + const oldIdx = sectionIds.indexOf(active.id); + const newIdx = sectionIds.indexOf(over.id); + if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return; + + const reorderedSections = arrayMove(sectionIds, oldIdx, newIdx); + + // Rebuild flat list: ungrouped workspaces first, then + // each section with its workspaces in new section order + const ungrouped: UniqueIdentifier[] = []; + const sectionGroups = new Map(); + + let currentSec: string | null = null; + for (const id of flatItems) { + if (isSec(id)) { + currentSec = String(id); + sectionGroups.set(currentSec, []); + } else if (currentSec) { + sectionGroups.get(currentSec)?.push(id); + } else { + ungrouped.push(id); + } } - const group = flatItems.slice(groupStart, groupEnd); - const without = [ - ...flatItems.slice(0, groupStart), - ...flatItems.slice(groupEnd), - ]; - - // Find insertion point in the array without the group - const newOverIndex = without.indexOf(over.id); - const insertAt = newOverIndex >= 0 ? newOverIndex : without.length; - - newItems = [ - ...without.slice(0, insertAt), - ...group, - ...without.slice(insertAt), - ]; + + const newItems: UniqueIdentifier[] = [...ungrouped]; + for (const secSortId of reorderedSections) { + newItems.push(secSortId); + const wsInSec = sectionGroups.get(String(secSortId)) ?? []; + newItems.push(...wsInSec); + } + + setFlatItems(newItems); + commitToDb(newItems); } else { - // Workspace drag: simple arrayMove - newItems = arrayMove(flatItems, oldIndex, overIndex); + // Workspace drag: simple arrayMove in the full flat list + const oldIndex = flatItems.indexOf(active.id); + const overIndex = flatItems.indexOf(over.id); + if (oldIndex === -1 || overIndex === -1 || oldIndex === overIndex) + return; + + const newItems = arrayMove(flatItems, oldIndex, overIndex); + setFlatItems(newItems); + commitToDb(newItems); } - - setFlatItems(newItems); - commitToDb(newItems); }, [flatItems, commitToDb], ); @@ -287,7 +314,9 @@ export function useSidebarDnd({ measuring, collisionDetection: closestCenter, flatItems, + sortableItems, activeId, + activeType, activeItem, groupInfo, workspacesById, From 4d3a4d9bc1966e8313c460938c8b8e4b560735a2 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:05:43 -0700 Subject: [PATCH 03/20] feat(desktop): animate section workspace collapse on section drag start --- ...DashboardSidebarExpandedProjectContent.tsx | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index d5492b0cf79..d5c707e7b47 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -92,23 +92,36 @@ export function DashboardSidebarExpandedProjectContent({ ); } - // During section drag, hide workspaces that belong to a section - if (activeType === "section" && groupInfo.has(parsed.realId)) - return null; - const workspace = workspacesById.get(parsed.realId); if (!workspace) return null; const group = groupInfo.get(parsed.realId); + const isInSection = groupInfo.has(parsed.realId); + const hiddenBySectionDrag = + activeType === "section" && isInSection; return ( - onWorkspaceHover(parsed.realId)} - shortcutLabel={workspaceShortcutLabels.get(parsed.realId)} - /> + + {!hiddenBySectionDrag && ( + + + onWorkspaceHover(parsed.realId) + } + shortcutLabel={workspaceShortcutLabels.get( + parsed.realId, + )} + /> + + )} + ); })} From 69979a794f38b45859e7cfeeee87a174dc41357b Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:06:09 -0700 Subject: [PATCH 04/20] fix(desktop): hide all workspaces during section drag, not just grouped ones --- .../DashboardSidebarExpandedProjectContent.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index d5c707e7b47..6ae41b0389d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -96,8 +96,7 @@ export function DashboardSidebarExpandedProjectContent({ if (!workspace) return null; const group = groupInfo.get(parsed.realId); const isInSection = groupInfo.has(parsed.realId); - const hiddenBySectionDrag = - activeType === "section" && isInSection; + const hiddenBySectionDrag = activeType === "section"; return ( From 92acb0d40baed89dd1b55ee63ac60cc6b983663f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:06:28 -0700 Subject: [PATCH 05/20] Revert "fix(desktop): hide all workspaces during section drag, not just grouped ones" This reverts commit 69979a794f38b45859e7cfeeee87a174dc41357b. --- .../DashboardSidebarExpandedProjectContent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 6ae41b0389d..d5c707e7b47 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -96,7 +96,8 @@ export function DashboardSidebarExpandedProjectContent({ if (!workspace) return null; const group = groupInfo.get(parsed.realId); const isInSection = groupInfo.has(parsed.realId); - const hiddenBySectionDrag = activeType === "section"; + const hiddenBySectionDrag = + activeType === "section" && isInSection; return ( From f7dd3d7119e06b14b2004e9e7488653386fed9b6 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:10:51 -0700 Subject: [PATCH 06/20] feat(desktop): add project-level drag and drop reordering Projects are sortable via DndContext at the sidebar level. On drag start, project content (sections + workspaces) animates out, leaving only project headers for clean sorting. On drop, arrayMove + persist via reorderProjects. DragOverlay shows a collapsed project preview. Uses the same pattern as section drag: project row gets drag handle via attributes/listeners spread, content collapses with AnimatePresence. --- .../DashboardSidebar/DashboardSidebar.tsx | 181 +++++++++++++++++- .../DashboardSidebarProjectSection.tsx | 55 ++++-- 2 files changed, 207 insertions(+), 29 deletions(-) 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 815311dacd3..2a13bbd1a7b 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 @@ -1,34 +1,195 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + DragOverlay, + defaultDropAnimationSideEffects, + KeyboardSensor, + MeasuringStrategy, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { 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 { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; +import type { DashboardSidebarProject } from "./types"; interface DashboardSidebarProps { isCollapsed?: boolean; } +function SortableProjectWrapper({ + project, + isCollapsed, + isDraggingProject, + workspaceShortcutLabels, + onWorkspaceHover, + onToggleCollapse, +}: { + project: DashboardSidebarProject; + isCollapsed: boolean; + isDraggingProject: boolean; + workspaceShortcutLabels: Map; + onWorkspaceHover: (workspaceId: string) => void | Promise; + onToggleCollapse: (projectId: string) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: project.id }); + + return ( +
+ +
+ ); +} + export function DashboardSidebar({ isCollapsed = false, }: DashboardSidebarProps) { const { groups, refreshWorkspacePullRequest, toggleProjectCollapsed } = useDashboardSidebarData(); const workspaceShortcutLabels = useDashboardSidebarShortcuts(groups); + const { reorderProjects } = useDashboardSidebarState(); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const [activeProject, setActiveProject] = + useState(null); + + // Local project order — syncs from groups, updated on drag end + const [projectOrder, setProjectOrder] = useState(() => + groups.map((p) => p.id), + ); + useEffect(() => { + setProjectOrder(groups.map((p) => p.id)); + }, [groups]); + + const orderedGroups = useMemo(() => { + const byId = new Map(groups.map((g) => [g.id, g])); + return projectOrder + .map((id) => byId.get(id)) + .filter((g): g is DashboardSidebarProject => g != null); + }, [groups, projectOrder]); + + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) { + const oldIndex = projectOrder.indexOf(String(active.id)); + const newIndex = projectOrder.indexOf(String(over.id)); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(projectOrder, oldIndex, newIndex); + setProjectOrder(reordered); + reorderProjects(reordered); + } + } + setActiveProject(null); + }, + [projectOrder, reorderProjects], + ); return (
- {groups.map((project) => ( - - ))} + { + const project = groups.find((p) => p.id === active.id); + setActiveProject(project ?? null); + }} + onDragEnd={handleDragEnd} + onDragCancel={() => setActiveProject(null)} + > + + {orderedGroups.map((project) => ( + + ))} + + + {createPortal( + + {activeProject && ( +
+ {}} + onToggleCollapse={() => {}} + /> +
+ )} +
, + document.body, + )} +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index 277a32a408f..35f44b3c0a8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -1,10 +1,12 @@ +import type { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; import { cn } from "@superset/ui/utils"; +import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; import type { DashboardSidebarProject } from "../../types"; -import { - getProjectChildrenSections, - getProjectChildrenWorkspaces, -} from "../../utils/projectChildren"; +import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; import { DashboardSidebarCollapsedProjectContent } from "./components/DashboardSidebarCollapsedProjectContent"; import { DashboardSidebarExpandedProjectContent } from "./components/DashboardSidebarExpandedProjectContent"; import { DashboardSidebarProjectContextMenu } from "./components/DashboardSidebarProjectContextMenu"; @@ -14,23 +16,24 @@ import { useDashboardSidebarProjectSectionActions } from "./hooks/useDashboardSi interface DashboardSidebarProjectSectionProps { project: DashboardSidebarProject; isSidebarCollapsed?: boolean; + isDraggingProject?: boolean; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; + dragHandleListeners?: DraggableSyntheticListeners; + dragHandleAttributes?: DraggableAttributes; } export function DashboardSidebarProjectSection({ project, isSidebarCollapsed = false, + isDraggingProject = false, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, + dragHandleListeners, + dragHandleAttributes, }: DashboardSidebarProjectSectionProps) { - const _allSections = useMemo( - () => getProjectChildrenSections(project.children), - [project.children], - ); - const flattenedCollapsedWorkspaces = useMemo( () => getProjectChildrenWorkspaces(project.children), [project.children], @@ -104,19 +107,33 @@ export function DashboardSidebarProjectSection({ onStartRename={startRename} onToggleCollapse={() => onToggleCollapse(project.id)} onNewWorkspace={handleNewWorkspace} + {...(dragHandleAttributes ?? {})} + {...(dragHandleListeners ?? {})} /> - + + {!isDraggingProject && ( + + + + )} + ); } From 7ee9b1315abbb9a3628a68be06b13195dfd3dcb8 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:11:43 -0700 Subject: [PATCH 07/20] fix(desktop): disable project drop animation to avoid size mismatch --- .../components/DashboardSidebar/DashboardSidebar.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 2a13bbd1a7b..9093d77e55d 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 @@ -167,13 +167,7 @@ export function DashboardSidebar({ {createPortal( - + {activeProject && (
Date: Mon, 6 Apr 2026 18:12:13 -0700 Subject: [PATCH 08/20] fix(desktop): disable section drop animation to match project behavior --- .../DashboardSidebarExpandedProjectContent.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index d5c707e7b47..cea26f1290d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -127,13 +127,7 @@ export function DashboardSidebarExpandedProjectContent({ {createPortal( - + {activeId ? ( ) : null} From e47c14be3d829a3c072c6c0e0ccbd1af62a18271 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:18:16 -0700 Subject: [PATCH 09/20] feat(desktop): ghost shows predicted section color during workspace drag --- .../DashboardSidebar/DashboardSidebar.tsx | 1 - ...DashboardSidebarExpandedProjectContent.tsx | 11 ++++---- .../hooks/useSidebarDnd/useSidebarDnd.ts | 27 ++++++++++++++++++- 3 files changed, 31 insertions(+), 8 deletions(-) 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 9093d77e55d..67bd4c68c79 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 @@ -3,7 +3,6 @@ import { DndContext, type DragEndEvent, DragOverlay, - defaultDropAnimationSideEffects, KeyboardSensor, MeasuringStrategy, MouseSensor, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index cea26f1290d..7cf8ddb277d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -1,8 +1,4 @@ -import { - DndContext, - DragOverlay, - defaultDropAnimationSideEffects, -} from "@dnd-kit/core"; +import { DndContext, DragOverlay } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, @@ -46,6 +42,7 @@ export function DashboardSidebarExpandedProjectContent({ activeId, activeType, activeItem, + predictedColor, groupInfo, workspacesById, sectionsById, @@ -111,7 +108,9 @@ export function DashboardSidebarExpandedProjectContent({ onWorkspaceHover(parsed.realId) } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts index 9521d53f673..75189827466 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -1,6 +1,7 @@ import { closestCenter, type DragEndEvent, + type DragOverEvent, type DragStartEvent, KeyboardSensor, MeasuringStrategy, @@ -127,6 +128,7 @@ export function useSidebarDnd({ ? "section" : "workspace" : null; + const [overId, setOverId] = useState(null); const clonedRef = useRef(null); // When dragging a section, SortableContext only has section IDs. @@ -214,6 +216,22 @@ export function useSidebarDnd({ return sec ? { type: "section" as const, section: sec } : null; }, [activeId, workspacesById, sectionsById]); + // Color the active workspace's ghost should show based on where it would land + const predictedColor = useMemo(() => { + if (!activeId || !overId || activeType !== "workspace") return null; + const overIndex = flatItems.indexOf(overId); + if (overIndex === -1) return null; + // Walk backwards from the over position to find the nearest section header + for (let i = overIndex; i >= 0; i--) { + const p = parseId(flatItems[i]); + if (p?.type === "section") { + const sec = sectionsById.get(p.realId); + return sec?.color ?? null; + } + } + return null; // ungrouped — no section above + }, [activeId, overId, activeType, flatItems, sectionsById]); + // ── Persistence ────────────────────────────────────────────────── const commitToDb = useCallback( @@ -243,9 +261,14 @@ export function useSidebarDnd({ [flatItems], ); + const onDragOver = useCallback(({ over }: DragOverEvent) => { + setOverId(over?.id ?? null); + }, []); + const onDragEnd = useCallback( ({ active, over }: DragEndEvent) => { setActiveId(null); + setOverId(null); if (!over || active.id === over.id) return; @@ -306,6 +329,7 @@ export function useSidebarDnd({ setFlatItems(clonedRef.current); } setActiveId(null); + setOverId(null); clonedRef.current = null; }, []); @@ -318,9 +342,10 @@ export function useSidebarDnd({ activeId, activeType, activeItem, + predictedColor, groupInfo, workspacesById, sectionsById, - handlers: { onDragStart, onDragEnd, onDragCancel }, + handlers: { onDragStart, onDragOver, onDragEnd, onDragCancel }, }; } From 473659fed7d4783c1bd0cbe260e4e961b6de2772 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:21:34 -0700 Subject: [PATCH 10/20] fix(desktop): correct predicted color when hovering above a section header --- .../DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts index 75189827466..aecd5edf7fc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -221,8 +221,10 @@ export function useSidebarDnd({ if (!activeId || !overId || activeType !== "workspace") return null; const overIndex = flatItems.indexOf(overId); if (overIndex === -1) return null; - // Walk backwards from the over position to find the nearest section header - for (let i = overIndex; i >= 0; i--) { + // If over is a section header, the workspace lands ABOVE it, + // so look for the section above the over position (skip the over itself) + const startFrom = isSec(overId) ? overIndex - 1 : overIndex; + for (let i = startFrom; i >= 0; i--) { const p = parseId(flatItems[i]); if (p?.type === "section") { const sec = sectionsById.get(p.realId); From 50b1da60c97774691bdb6ba5c7d0a967b42bbecc Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:28:20 -0700 Subject: [PATCH 11/20] feat(desktop): redesign section headers as ruled dividers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section headers now render as: grip ── • Name (✎) ── ▸ - Horizontal rules (flex-1 bg-border) flank the name - Colored dot shows section color - Pencil appears on hover (replaces count) - Grip icon for drag handle on hover - Chevron for collapse toggle - Remove left border from SortableSectionHeader (no longer containers) - Center rename input between rules --- .../DashboardSidebarSectionHeader.tsx | 81 +++++++++++-------- .../SortableSectionHeader.tsx | 7 -- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index 49dd48bbcde..eba55bbd3d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -1,7 +1,7 @@ import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; import { HiChevronRight } from "react-icons/hi2"; -import { LuPencil } from "react-icons/lu"; +import { LuGripVertical, LuPencil } from "react-icons/lu"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; import type { DashboardSidebarSection } from "../../../../types"; @@ -36,6 +36,8 @@ export const DashboardSidebarSectionHeader = forwardRef< }, ref, ) => { + const sectionColor = section.color; + return ( // biome-ignore lint/a11y/noStaticElementInteractions: The header acts as a single toggle target in view mode while preserving nested inline controls.
-
- {isRenaming ? ( - - ) : ( - {section.name} - )} + + +
+ + {sectionColor && ( + + )} + + {isRenaming ? ( + + ) : ( + {section.name} + )} + + {!isRenaming && ( +
+ + ({section.workspaces.length}) + + +
+ )} - {!isRenaming && ( -
- - ({section.workspaces.length}) - - -
- )} -
+
-
- )} +
*]:col-start-1 [&>*]:row-start-1", isRenaming && "invisible")}> + + ({section.workspaces.length}) + + +
From b41c49c0b8c6f098ded9c4c2cd2359240c37151d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:36:21 -0700 Subject: [PATCH 14/20] fix(desktop): replace pencil/count with separator line during section rename --- .../DashboardSidebarSectionHeader.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index f0893758460..9f57144d7da 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -90,7 +90,10 @@ export const DashboardSidebarSectionHeader = forwardRef< {section.name} )} -
*]:col-start-1 [&>*]:row-start-1", isRenaming && "invisible")}> + {isRenaming ? ( +
+ ) : null} +
*]:col-start-1 [&>*]:row-start-1", isRenaming && "hidden")}> ({section.workspaces.length}) From 829394c922ead28ead7ec190d7b096b13afdce7c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:37:07 -0700 Subject: [PATCH 15/20] fix(desktop): contiguous right rule during section rename --- .../DashboardSidebarSectionHeader.tsx | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index 9f57144d7da..22530bc6afd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -90,25 +90,24 @@ export const DashboardSidebarSectionHeader = forwardRef< {section.name} )} - {isRenaming ? ( -
- ) : null} -
*]:col-start-1 [&>*]:row-start-1", isRenaming && "hidden")}> - - ({section.workspaces.length}) - - -
+ {!isRenaming && ( +
+ + ({section.workspaces.length}) + + +
+ )}
From 470e54d0e01cc28a14c982ef2013ef034b6776ef Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 18:39:59 -0700 Subject: [PATCH 16/20] fix(desktop): tighten section rename input width for longer right rule --- .../DashboardSidebarSectionHeader.tsx | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index 22530bc6afd..87bf98ff16f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -66,48 +66,49 @@ export const DashboardSidebarSectionHeader = forwardRef<
- {sectionColor && ( - - )} +
+ {sectionColor && ( + + )} - {isRenaming ? ( -
+ {isRenaming ? ( -
- ) : ( - {section.name} - )} + ) : ( + {section.name} + )} - {!isRenaming && ( -
- + {isRenaming ? ( + ({section.workspaces.length}) - -
- )} + ) : ( +
+ + ({section.workspaces.length}) + + +
+ )} +
From 625756b68f7433a87e532f84e2350d75236c3672 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 19:37:31 -0700 Subject: [PATCH 17/20] feat(desktop): polish sidebar DnD UI - Section grip handle is the only drag trigger (not whole header) - Drag overlay matches ruled divider style for sections - Remove from Group in context menu (with up arrow, only for grouped workspaces) - Remove Default from color palette, sections always get random color - Color dots in Move to Section submenu with separator - Animated section collapse/expand via AnimatePresence - Predicted section color on ghost during workspace drag - field-sizing:content on section rename input --- ...DashboardSidebarExpandedProjectContent.tsx | 13 +++-- .../DashboardSidebarSectionHeader.tsx | 20 +++++++- .../DashboardSidebarWorkspaceItem.tsx | 4 ++ .../DashboardSidebarWorkspaceContextMenu.tsx | 22 +++++++-- .../SidebarDragOverlay/SidebarDragOverlay.tsx | 29 +++++++----- .../SortableSectionHeader.tsx | 4 +- .../SortableWorkspaceItem.tsx | 3 ++ .../hooks/useSidebarDnd/useSidebarDnd.ts | 23 +++++---- .../useDashboardSidebarState.ts | 47 ++++++++++++++++++- .../shared/constants/project-colors.test.ts | 8 +--- .../src/shared/constants/project-colors.ts | 5 +- 11 files changed, 133 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 7cf8ddb277d..dbd7a960c72 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -44,6 +44,7 @@ export function DashboardSidebarExpandedProjectContent({ activeItem, predictedColor, groupInfo, + collapsedSectionIds, workspacesById, sectionsById, handlers, @@ -92,13 +93,16 @@ export function DashboardSidebarExpandedProjectContent({ const workspace = workspacesById.get(parsed.realId); if (!workspace) return null; const group = groupInfo.get(parsed.realId); - const isInSection = groupInfo.has(parsed.realId); - const hiddenBySectionDrag = - activeType === "section" && isInSection; + const isInSection = !!group; + const isInCollapsedSection = + isInSection && collapsedSectionIds.has(group.sectionId); + const hidden = + isInCollapsedSection || + (activeType === "section" && isInSection); return ( - {!hiddenBySectionDrag && ( + {!hidden && ( onWorkspaceHover(parsed.realId) } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index 87bf98ff16f..3bf87541fe9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -1,3 +1,7 @@ +import type { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; import { HiChevronRight } from "react-icons/hi2"; @@ -15,6 +19,8 @@ interface DashboardSidebarSectionHeaderProps onCancelRename: () => void; onStartRename: () => void; onToggleCollapse: () => void; + dragHandleListeners?: DraggableSyntheticListeners; + dragHandleAttributes?: DraggableAttributes; } export const DashboardSidebarSectionHeader = forwardRef< @@ -31,6 +37,8 @@ export const DashboardSidebarSectionHeader = forwardRef< onCancelRename, onStartRename, onToggleCollapse, + dragHandleListeners, + dragHandleAttributes, className, ...props }, @@ -62,7 +70,15 @@ export const DashboardSidebarSectionHeader = forwardRef< )} {...props} > - +
@@ -80,7 +96,7 @@ export const DashboardSidebarSectionHeader = forwardRef< onChange={onRenameValueChange} onSubmit={onSubmitRename} onCancel={onCancelRename} - className="-ml-1 h-5 min-w-0 px-1 py-0 text-[11px] font-medium bg-transparent border-none outline-none text-muted-foreground" + className="-ml-1 -mr-1 h-5 min-w-0 px-1 py-0 text-[11px] font-medium bg-transparent border-none outline-none text-muted-foreground [field-sizing:content]" /> ) : ( {section.name} 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 391563f4b14..f26008f4ad6 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 @@ -12,6 +12,7 @@ interface DashboardSidebarWorkspaceItemProps { onHoverCardOpen?: () => void; shortcutLabel?: string; isCollapsed?: boolean; + isInSection?: boolean; } export function DashboardSidebarWorkspaceItem({ @@ -19,6 +20,7 @@ export function DashboardSidebarWorkspaceItem({ onHoverCardOpen, shortcutLabel, isCollapsed = false, + isInSection = false, }: DashboardSidebarWorkspaceItemProps) { const { id, @@ -88,6 +90,7 @@ export function DashboardSidebarWorkspaceItem({ ) : ( void; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; @@ -44,6 +45,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { export function DashboardSidebarWorkspaceContextMenu({ projectId, + isInSection, onHoverCardOpen, hoverCardContent, onCreateSection, @@ -68,6 +70,7 @@ export function DashboardSidebarWorkspaceContextMenu({ .select(({ sidebarSections }) => ({ id: sidebarSections.sectionId, name: sidebarSections.name, + color: sidebarSections.color, })), [collections, projectId], ); @@ -98,20 +101,29 @@ export function DashboardSidebarWorkspaceContextMenu({ New Section - onMoveToSection(null)}> - - Ungrouped - + {sections.length > 0 && } {sections.map((section) => ( onMoveToSection(section.id)} > + {section.color && ( + + )} {section.name} ))} + {isInSection && ( + onMoveToSection(null)}> + + Remove from Group + + )} -
- {section.name} - - ({section.workspaces.length}) - +
+
+
+
+ {hasColor && ( + + )} + {section.name} + + ({section.workspaces.length}) + +
+
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx index 9913689a16c..cbec3b2ebeb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx @@ -70,8 +70,8 @@ export function SortableSectionHeader({ setIsRenaming(true); }} onToggleCollapse={() => onToggleCollapse(section.id)} - {...attributes} - {...listeners} + dragHandleListeners={listeners} + dragHandleAttributes={attributes} />
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx index 61e7daf7368..7fec4301664 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -7,6 +7,7 @@ interface SortableWorkspaceItemProps { sortableId: string; workspace: DashboardSidebarWorkspace; accentColor?: string | null; + isInSection?: boolean; onHoverCardOpen?: () => void; shortcutLabel?: string; } @@ -15,6 +16,7 @@ export function SortableWorkspaceItem({ sortableId, workspace, accentColor, + isInSection, onHoverCardOpen, shortcutLabel, }: SortableWorkspaceItemProps) { @@ -36,6 +38,7 @@ export function SortableWorkspaceItem({ workspace={workspace} onHoverCardOpen={onHoverCardOpen} shortcutLabel={shortcutLabel} + isInSection={isInSection} />
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts index aecd5edf7fc..ea62d12a9d9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -55,11 +55,9 @@ function buildFlatItems( items.push(wsId(child.workspace.id)); } else { items.push(secId(child.section.id)); - // Only include workspaces if section is expanded - if (!child.section.isCollapsed) { - for (const ws of child.section.workspaces) { - items.push(wsId(ws.id)); - } + // Always include workspaces so AnimatePresence can animate collapse + for (const ws of child.section.workspaces) { + items.push(wsId(ws.id)); } } } @@ -145,9 +143,7 @@ export function useSidebarDnd({ useEffect(() => { const fingerprint = projectChildren .map((c) => - c.type === "workspace" - ? c.workspace.id - : `s:${c.section.id}:${c.section.isCollapsed}`, + c.type === "workspace" ? c.workspace.id : `s:${c.section.id}`, ) .sort() .join(","); @@ -157,6 +153,16 @@ export function useSidebarDnd({ } }, [projectChildren]); + const collapsedSectionIds = useMemo(() => { + const set = new Set(); + for (const child of projectChildren) { + if (child.type === "section" && child.section.isCollapsed) { + set.add(child.section.id); + } + } + return set; + }, [projectChildren]); + // ── Lookups ────────────────────────────────────────────────────── const workspacesById = useMemo(() => { @@ -346,6 +352,7 @@ export function useSidebarDnd({ activeItem, predictedColor, groupInfo, + collapsedSectionIds, workspacesById, sectionsById, handlers: { onDragStart, onDragOver, onDragEnd, onDragCancel }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 73859e5e81c..c8af1c62c12 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -2,6 +2,7 @@ import type { WorkspaceState } from "@superset/panes"; import { useCallback } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { PROJECT_CUSTOM_COLORS } from "shared/constants/project-colors"; function getNextTabOrder(items: Array<{ tabOrder: number }>): number { const maxTabOrder = items.reduce( @@ -189,6 +190,11 @@ export function useDashboardSidebarState() { collections.v2SidebarSections.state.values(), ).filter((item) => item.projectId === projectId); + const randomColor = + PROJECT_CUSTOM_COLORS[ + Math.floor(Math.random() * PROJECT_CUSTOM_COLORS.length) + ].value; + collections.v2SidebarSections.insert({ sectionId, projectId, @@ -196,7 +202,7 @@ export function useDashboardSidebarState() { createdAt: new Date(), tabOrder: getNextTabOrder(sectionOrders), isCollapsed: false, - color: null, + color: randomColor, }); return sectionId; @@ -239,6 +245,45 @@ export function useDashboardSidebarState() { const existing = collections.v2WorkspaceLocalState.get(workspaceId); if (!existing) return; + if (sectionId === null) { + // "Remove from group" — place right above the first section. + // Find the lowest section tabOrder, then use tabOrder - 1. + // If no sections exist, append to end of ungrouped workspaces. + const sectionOrders = Array.from( + collections.v2SidebarSections.state.values(), + ) + .filter((s) => s.projectId === projectId) + .map((s) => s.tabOrder); + + const firstSectionOrder = + sectionOrders.length > 0 ? Math.min(...sectionOrders) : null; + + let newTabOrder: number; + if (firstSectionOrder != null) { + // Place just before the first section + newTabOrder = firstSectionOrder - 1; + } else { + // No sections — append to end + const ungroupedOrders = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) + .filter( + (item) => + item.sidebarState.projectId === projectId && + item.workspaceId !== workspaceId && + item.sidebarState.sectionId === null, + ) + .map((item) => ({ tabOrder: item.sidebarState.tabOrder })); + newTabOrder = getNextTabOrder(ungroupedOrders); + } + + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.sectionId = null; + draft.sidebarState.tabOrder = newTabOrder; + }); + return; + } + const siblingRows = Array.from( collections.v2WorkspaceLocalState.state.values(), ) diff --git a/apps/desktop/src/shared/constants/project-colors.test.ts b/apps/desktop/src/shared/constants/project-colors.test.ts index 10ae785e603..f1a957b1abb 100644 --- a/apps/desktop/src/shared/constants/project-colors.test.ts +++ b/apps/desktop/src/shared/constants/project-colors.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { - PROJECT_COLOR_DEFAULT, - PROJECT_COLORS, - PROJECT_CUSTOM_COLORS, -} from "./project-colors"; +import { PROJECT_COLORS, PROJECT_CUSTOM_COLORS } from "./project-colors"; function hexToRgb(hex: string) { const normalizedHex = hex.replace("#", ""); @@ -32,7 +28,7 @@ describe("PROJECT_COLORS", () => { expect(new Set(colorNames).size).toBe(colorNames.length); expect(new Set(colorValues).size).toBe(colorValues.length); - expect(PROJECT_COLORS[0]?.value).toBe(PROJECT_COLOR_DEFAULT); + expect(PROJECT_COLORS.length).toBeGreaterThan(0); }); it("keeps custom swatches visually distinct", () => { diff --git a/apps/desktop/src/shared/constants/project-colors.ts b/apps/desktop/src/shared/constants/project-colors.ts index a4142db1525..0341c438e22 100644 --- a/apps/desktop/src/shared/constants/project-colors.ts +++ b/apps/desktop/src/shared/constants/project-colors.ts @@ -2,7 +2,6 @@ export const PROJECT_COLOR_DEFAULT = "default"; export const PROJECT_COLORS = [ - { name: "Default", value: PROJECT_COLOR_DEFAULT }, { name: "Red", value: "#ef4444" }, { name: "Orange", value: "#f97316" }, { name: "Yellow", value: "#eab308" }, @@ -17,9 +16,7 @@ export const PROJECT_COLORS = [ { name: "Slate", value: "#64748b" }, ] as const; -export const PROJECT_CUSTOM_COLORS = PROJECT_COLORS.filter( - (color) => color.value !== PROJECT_COLOR_DEFAULT, -); +export const PROJECT_CUSTOM_COLORS = PROJECT_COLORS; export const PROJECT_COLOR_VALUES: string[] = PROJECT_COLORS.map( (color) => color.value, From f8afc6b5611c04e9af0b5103ab3b5f460408bd83 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 19:47:45 -0700 Subject: [PATCH 18/20] feat(desktop): restore original section header style with grip icon Revert section headers to the original look (name, count/pencil swap, chevron, colored left border) instead of the ruled divider style. Added grip icon that shows on hover with cursor-grab. Whole header is draggable, grip is just a visual affordance. Drag overlay matches. --- .../DashboardSidebarSectionHeader.tsx | 49 ++++--------------- .../SidebarDragOverlay/SidebarDragOverlay.tsx | 29 +++++------ .../SortableSectionHeader.tsx | 11 ++++- 3 files changed, 31 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index 3bf87541fe9..91584d872f5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -1,7 +1,3 @@ -import type { - DraggableAttributes, - DraggableSyntheticListeners, -} from "@dnd-kit/core"; import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; import { HiChevronRight } from "react-icons/hi2"; @@ -19,8 +15,6 @@ interface DashboardSidebarSectionHeaderProps onCancelRename: () => void; onStartRename: () => void; onToggleCollapse: () => void; - dragHandleListeners?: DraggableSyntheticListeners; - dragHandleAttributes?: DraggableAttributes; } export const DashboardSidebarSectionHeader = forwardRef< @@ -37,15 +31,11 @@ export const DashboardSidebarSectionHeader = forwardRef< onCancelRename, onStartRename, onToggleCollapse, - dragHandleListeners, - dragHandleAttributes, className, ...props }, ref, ) => { - const sectionColor = section.color; - return ( // biome-ignore lint/a11y/noStaticElementInteractions: The header acts as a single toggle target in view mode while preserving nested inline controls.
- - -
- -
- {sectionColor && ( - - )} +
+
{isRenaming ? ( ) : ( - {section.name} + {section.name} )} - {isRenaming ? ( - - ({section.workspaces.length}) - - ) : ( + {!isRenaming && (
({section.workspaces.length}) @@ -117,7 +88,7 @@ export const DashboardSidebarSectionHeader = forwardRef< event.stopPropagation(); onStartRename(); }} - className="flex items-center justify-center opacity-0 text-muted-foreground transition-[opacity,color] duration-150 group-hover:opacity-100 hover:text-foreground" + className="z-10 flex items-center justify-center opacity-0 text-muted-foreground transition-[opacity,color] duration-150 group-hover:opacity-100 hover:text-foreground" aria-label="Rename section" > @@ -126,8 +97,6 @@ export const DashboardSidebarSectionHeader = forwardRef< )}
-
- - - - - - -
- +
+ {isRenaming ? ( + + ) : ( + + > + {name || branch} + + )} - {showBranchSubtitle && ( - - {branch} +
+ {creationStatusText ? ( + + {creationStatusText} - )} - - {pullRequest && ( - - )} -
- ) : ( -
- {isRenaming ? ( - ) : ( - - {name || branch} - + <> + +
+ {shortcutLabel && ( + + {shortcutLabel} + + )} + + + + + + + + +
+ )} - -
- {creationStatusText ? ( - - {creationStatusText} - - ) : ( - <> - -
- {shortcutLabel && ( - - {shortcutLabel} - - )} - - - - - - - - -
- - )} -
- )} + + + {branch} + + + {pullRequest && ( + + )} +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx index 13a0f755b9a..7c5eac718cd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx @@ -1,3 +1,4 @@ +import { LuGripVertical } from "react-icons/lu"; import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; import type { DashboardSidebarSection, @@ -37,7 +38,10 @@ export function SidebarDragOverlay({ activeItem }: SidebarDragOverlayProps) { : "2px solid var(--color-border)", }} > -
+
+
+ +
{section.name} ({section.workspaces.length}) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts index ea62d12a9d9..09d04fdd2cb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -138,14 +138,15 @@ export function useSidebarDnd({ return flatItems; }, [flatItems, activeType]); - // Sync from external data only when items are added/removed + // Sync from external data when items or their order/membership changes const prevFingerprintRef = useRef(""); useEffect(() => { const fingerprint = projectChildren .map((c) => - c.type === "workspace" ? c.workspace.id : `s:${c.section.id}`, + c.type === "workspace" + ? c.workspace.id + : `s:${c.section.id}:${c.section.workspaces.map((w) => w.id).join("|")}`, ) - .sort() .join(","); if (fingerprint !== prevFingerprintRef.current) { prevFingerprintRef.current = fingerprint; From 5a615255b9989f7fc11d18c2c40d450918e18963 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 6 Apr 2026 20:28:06 -0700 Subject: [PATCH 20/20] fix(desktop): address PR review comments - Fix tabOrder collision when removing multiple workspaces from groups: use getNextTabOrder on ungrouped items instead of firstSectionOrder-1 - Guard flatItems sync effect with activeId to prevent overwriting local drag state during background collection updates - Add missing useSortable attributes to SortableWorkspaceItem for keyboard drag accessibility --- .../SortableWorkspaceItem.tsx | 11 +++++++++-- .../hooks/useSidebarDnd/useSidebarDnd.ts | 3 ++- .../useDashboardSidebarState.ts | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx index 7fec4301664..67bc43abbd7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -20,8 +20,14 @@ export function SortableWorkspaceItem({ onHoverCardOpen, shortcutLabel, }: SortableWorkspaceItemProps) { - const { setNodeRef, listeners, isDragging, transform, transition } = - useSortable({ id: sortableId }); + const { + setNodeRef, + attributes, + listeners, + isDragging, + transform, + transition, + } = useSortable({ id: sortableId }); return (
{ + if (activeId) return; // Don't reset during active drag const fingerprint = projectChildren .map((c) => c.type === "workspace" @@ -152,7 +153,7 @@ export function useSidebarDnd({ prevFingerprintRef.current = fingerprint; setFlatItems(buildFlatItems(projectChildren)); } - }, [projectChildren]); + }, [projectChildren, activeId]); const collapsedSectionIds = useMemo(() => { const set = new Set(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index c8af1c62c12..83aee1ce847 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -260,8 +260,18 @@ export function useDashboardSidebarState() { let newTabOrder: number; if (firstSectionOrder != null) { - // Place just before the first section - newTabOrder = firstSectionOrder - 1; + // Place right before the first section, after existing ungrouped + const ungroupedOrders = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) + .filter( + (item) => + item.sidebarState.projectId === projectId && + item.workspaceId !== workspaceId && + item.sidebarState.sectionId === null, + ) + .map((item) => ({ tabOrder: item.sidebarState.tabOrder })); + newTabOrder = getNextTabOrder(ungroupedOrders); } else { // No sections — append to end const ungroupedOrders = Array.from(