diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts index 6329a340338..eb83c7b34ee 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts @@ -1,5 +1,5 @@ import { workspaceSections, workspaces } from "@superset/local-db"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { PROJECT_COLOR_DEFAULT, @@ -8,6 +8,7 @@ import { import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getMaxProjectChildTabOrder } from "../utils/db-helpers"; +import { getProjectChildItems } from "../utils/project-children-order"; import { reorderItems } from "../utils/reorder"; const SECTION_COLORS = PROJECT_COLORS.filter( @@ -19,6 +20,150 @@ function randomSectionColor(): string { .value; } +function normalizeSectionWorkspaceOrder(sectionId: string): void { + const sectionWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.sectionId, sectionId)) + .all() + .sort((a, b) => a.tabOrder - b.tabOrder); + + for (const [index, workspace] of sectionWorkspaces.entries()) { + localDb + .update(workspaces) + .set({ tabOrder: index }) + .where(eq(workspaces.id, workspace.id)) + .run(); + } +} + +function persistProjectChildOrder( + items: ReturnType, +): void { + for (const item of items) { + if (item.kind === "workspace") { + localDb + .update(workspaces) + .set({ tabOrder: item.tabOrder }) + .where(eq(workspaces.id, item.id)) + .run(); + continue; + } + + localDb + .update(workspaceSections) + .set({ tabOrder: item.tabOrder }) + .where(eq(workspaceSections.id, item.id)) + .run(); + } +} + +function normalizeProjectChildOrder(projectId: string): void { + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where( + and(eq(workspaces.projectId, projectId), isNull(workspaces.deletingAt)), + ) + .all(); + const projectSections = localDb + .select() + .from(workspaceSections) + .where(eq(workspaceSections.projectId, projectId)) + .all(); + const items = getProjectChildItems( + projectId, + projectWorkspaces, + projectSections, + ); + + for (const [index, item] of items.entries()) { + item.tabOrder = index; + } + + persistProjectChildOrder(items); +} + +function reorderProjectChildOrderWithWorkspace( + projectId: string, + workspaceId: string, + targetIndex: number, +): void { + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where( + and(eq(workspaces.projectId, projectId), isNull(workspaces.deletingAt)), + ) + .all(); + const projectSections = localDb + .select() + .from(workspaceSections) + .where(eq(workspaceSections.projectId, projectId)) + .all(); + const items = getProjectChildItems( + projectId, + projectWorkspaces, + projectSections, + ); + const currentIndex = items.findIndex( + (item) => item.kind === "workspace" && item.id === workspaceId, + ); + + if (currentIndex === -1) { + throw new Error( + `Workspace ${workspaceId} not found in project ${projectId}`, + ); + } + + const [moved] = items.splice(currentIndex, 1); + const clampedTargetIndex = Math.max(0, Math.min(targetIndex, items.length)); + items.splice(clampedTargetIndex, 0, moved); + + for (const [index, item] of items.entries()) { + item.tabOrder = index; + } + + persistProjectChildOrder(items); +} + +function reorderSectionWithWorkspace( + sectionId: string, + workspaceId: string, + targetIndex: number, +): void { + const sectionWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.sectionId, sectionId)) + .all() + .sort((a, b) => a.tabOrder - b.tabOrder); + const currentIndex = sectionWorkspaces.findIndex( + (workspace) => workspace.id === workspaceId, + ); + + if (currentIndex === -1) { + throw new Error( + `Workspace ${workspaceId} not found in section ${sectionId}`, + ); + } + + const [moved] = sectionWorkspaces.splice(currentIndex, 1); + const clampedTargetIndex = Math.max( + 0, + Math.min(targetIndex, sectionWorkspaces.length), + ); + sectionWorkspaces.splice(clampedTargetIndex, 0, moved); + + for (const [index, workspace] of sectionWorkspaces.entries()) { + localDb + .update(workspaces) + .set({ tabOrder: index }) + .where(eq(workspaces.id, workspace.id)) + .run(); + } +} + export const createSectionsProcedures = () => { return router({ createSection: publicProcedure @@ -267,5 +412,93 @@ export const createSectionsProcedures = () => { return { success: true }; }), + + moveWorkspaceToSectionAtIndex: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + sectionId: z.string().nullable(), + targetIndex: z.number().int().nonnegative(), + }), + ) + .mutation(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); + + if (!workspace) { + throw new Error(`Workspace ${input.workspaceId} not found`); + } + + if (input.sectionId) { + const section = localDb + .select() + .from(workspaceSections) + .where(eq(workspaceSections.id, input.sectionId)) + .get(); + + if (!section) { + throw new Error(`Section ${input.sectionId} not found`); + } + + if (section.projectId !== workspace.projectId) { + throw new Error( + "Cannot move workspace to a section in a different project", + ); + } + } + + const sourceSectionId = workspace.sectionId ?? null; + + if (sourceSectionId === input.sectionId) { + if (input.sectionId === null) { + reorderProjectChildOrderWithWorkspace( + workspace.projectId, + workspace.id, + input.targetIndex, + ); + } else { + reorderSectionWithWorkspace( + input.sectionId, + workspace.id, + input.targetIndex, + ); + } + + return { success: true }; + } + + localDb + .update(workspaces) + .set({ sectionId: input.sectionId }) + .where(eq(workspaces.id, input.workspaceId)) + .run(); + + if (sourceSectionId === null && input.sectionId !== null) { + normalizeProjectChildOrder(workspace.projectId); + } + + if (sourceSectionId !== null && sourceSectionId !== input.sectionId) { + normalizeSectionWorkspaceOrder(sourceSectionId); + } + + if (input.sectionId === null) { + reorderProjectChildOrderWithWorkspace( + workspace.projectId, + workspace.id, + input.targetIndex, + ); + } else { + reorderSectionWithWorkspace( + input.sectionId, + workspace.id, + input.targetIndex, + ); + } + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 0e8928ea7d2..833b52d1583 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -8,6 +8,7 @@ export { useHandleOpenedWorktree } from "./useHandleOpenedWorktree"; export { useImportAllWorktrees } from "./useImportAllWorktrees"; export { useMoveWorkspacesToSection } from "./useMoveWorkspacesToSection"; export { useMoveWorkspaceToSection } from "./useMoveWorkspaceToSection"; +export { useMoveWorkspaceToSectionAtIndex } from "./useMoveWorkspaceToSectionAtIndex"; export { useOpenExternalWorktree } from "./useOpenExternalWorktree"; export { useOpenMainRepoWorkspace } from "./useOpenMainRepoWorkspace"; export { useOpenTrackedWorktree } from "./useOpenTrackedWorktree"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useMoveWorkspaceToSectionAtIndex.ts b/apps/desktop/src/renderer/react-query/workspaces/useMoveWorkspaceToSectionAtIndex.ts new file mode 100644 index 00000000000..c8484fd6c5e --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useMoveWorkspaceToSectionAtIndex.ts @@ -0,0 +1,13 @@ +import { toast } from "@superset/ui/sonner"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { invalidateWorkspaceQueries } from "./invalidateWorkspaceQueries"; + +export function useMoveWorkspaceToSectionAtIndex() { + const utils = electronTrpc.useUtils(); + + return electronTrpc.workspaces.moveWorkspaceToSectionAtIndex.useMutation({ + onSuccess: () => invalidateWorkspaceQueries(utils), + onError: (error) => + toast.error(`Failed to move workspace: ${error.message}`), + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 8dd19f2100b..5aeba96ad3b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -129,6 +129,7 @@ export function ProjectSection({ canAccept: (item) => item.sectionId !== null && item.projectId === projectId, targetSectionId: null, + getTargetIndex: () => topLevelChildren.length, }); const handleNewWorkspace = () => { @@ -312,19 +313,21 @@ export function ProjectSection({ className="overflow-hidden" >
- {topLevelChildren.length === 0 && ( -
- )} +
{topLevelChildren.map((item) => item.kind === "workspace" ? ( { itemRef.current = node; - drag(drop(node)); + setNodeRef(node); }} onClick={handleClick} onKeyDown={(e) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts index c1be6d1ccf4..27bf649bed3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts @@ -1,10 +1,10 @@ import { toast } from "@superset/ui/sonner"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useMoveWorkspacesToSection, - useMoveWorkspaceToSection, + useMoveWorkspaceToSectionAtIndex, useReorderProjectChildren, useReorderWorkspacesInSection, } from "renderer/react-query/workspaces"; @@ -23,6 +23,23 @@ interface UseWorkspaceDnDOptions { index: number; } +function getTargetIndexFromPointer( + node: HTMLElement | null, + index: number, + monitor: { getClientOffset: () => { x: number; y: number } | null }, +): number { + if (!node) return index; + + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return index; + + const hoverBoundingRect = node.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + return hoverClientY < hoverMiddleY ? index : index + 1; +} + export function useWorkspaceDnD({ id, projectId, @@ -32,9 +49,10 @@ export function useWorkspaceDnD({ const utils = electronTrpc.useUtils(); const reorderProjectChildren = useReorderProjectChildren(); const reorderWorkspacesInSection = useReorderWorkspacesInSection(); - const moveToSection = useMoveWorkspaceToSection(); + const moveToSectionAtIndex = useMoveWorkspaceToSectionAtIndex(); const bulkMoveToSection = useMoveWorkspacesToSection(); const selectionStore = useWorkspaceSelectionStore; + const dropRef = useRef(null); const handleReorder = useCallback( (item: DragItem) => { @@ -155,7 +173,7 @@ export function useWorkspaceDnD({ } item.index = index; }, - drop: (item: DragItem | SectionDragItem) => { + drop: (item: DragItem | SectionDragItem, monitor) => { if (item.kind === "section") { if (sectionId !== null || item.projectId !== projectId) return; reorderProjectChildren.mutate( @@ -185,9 +203,14 @@ export function useWorkspaceDnD({ sectionId, }); } else { - moveToSection.mutate({ + moveToSectionAtIndex.mutate({ workspaceId: item.id, sectionId, + targetIndex: getTargetIndexFromPointer( + dropRef.current, + index, + monitor, + ), }); } item.handled = true; @@ -196,5 +219,13 @@ export function useWorkspaceDnD({ }, }); - return { isDragging, drag, drop }; + const setNodeRef = useCallback( + (node: HTMLElement | null) => { + dropRef.current = node; + drag(drop(node)); + }, + [drag, drop], + ); + + return { isDragging, setNodeRef }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx index f38ec03ce57..8e847df8477 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx @@ -72,6 +72,7 @@ export function WorkspaceSection({ canAccept: (item) => item.projectId === projectId && item.sectionId !== sectionId, targetSectionId: sectionId, + getTargetIndex: () => workspaces.length, onAutoExpand: isCollapsed ? () => mutations.toggle() : undefined, }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/hooks/useSectionDropZone.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/hooks/useSectionDropZone.ts index 1428b9ce457..338c136eb34 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/hooks/useSectionDropZone.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/hooks/useSectionDropZone.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useMoveWorkspacesToSection, - useMoveWorkspaceToSection, + useMoveWorkspaceToSectionAtIndex, } from "renderer/react-query/workspaces"; import { getActiveDragItem, @@ -12,12 +12,14 @@ import type { DragItem } from "../types"; interface UseSectionDropZoneOptions { canAccept: (item: DragItem) => boolean; targetSectionId: string | null; + getTargetIndex?: () => number; onAutoExpand?: () => void; } export function useSectionDropZone({ canAccept, targetSectionId, + getTargetIndex, onAutoExpand, }: UseSectionDropZoneOptions) { const [isDragOver, setIsDragOver] = useState(false); @@ -27,7 +29,7 @@ export function useSectionDropZone({ const isDropTarget = activeDragItem !== null && canAccept(activeDragItem); const dragEnterCount = useRef(0); const autoExpandTimer = useRef | null>(null); - const moveToSection = useMoveWorkspaceToSection(); + const moveToSectionAtIndex = useMoveWorkspaceToSectionAtIndex(); const bulkMoveToSection = useMoveWorkspacesToSection(); useEffect(() => { @@ -61,9 +63,10 @@ export function useSectionDropZone({ sectionId: targetSectionId, }); } else { - moveToSection.mutate({ + moveToSectionAtIndex.mutate({ workspaceId: item.id, sectionId: targetSectionId, + targetIndex: getTargetIndex?.() ?? 0, }); } item.handled = true; @@ -71,7 +74,13 @@ export function useSectionDropZone({ dragEnterCount.current = 0; setIsDragOver(false); }, - [canAccept, targetSectionId, moveToSection, bulkMoveToSection], + [ + canAccept, + targetSectionId, + getTargetIndex, + moveToSectionAtIndex, + bulkMoveToSection, + ], ); const handleDragEnter = useCallback(