diff --git a/src/main/ipc/app/drag-token.ts b/src/main/ipc/app/drag-token.ts new file mode 100644 index 000000000..b8556230f --- /dev/null +++ b/src/main/ipc/app/drag-token.ts @@ -0,0 +1,36 @@ +// One-use drag token for cross-window tab drag-and-drop authentication. +// +// When an external drag starts, the source renderer generates a random token +// and registers it here via IPC alongside the tab ID being dragged. Only one +// token is active at a time — registering a new one replaces any previous one. +// +// When the drop target calls tabs:move-tab-to-window-space with a dragToken, +// validateAndConsumeToken is called to verify the token matches the registered +// tab and then immediately clears it so it cannot be reused. + +interface ActiveDragToken { + token: string; + tabId: number; +} + +let activeToken: ActiveDragToken | null = null; + +/** + * Registers a new one-use drag token tied to a specific tab. + * Any previously registered token is discarded. + */ +export function registerToken(token: string, tabId: number): void { + activeToken = { token, tabId }; +} + +/** + * Validates the provided token against the active token and, if valid, + * consumes it so it cannot be used again. Returns true only if the token + * matches and is bound to the expected tab ID. + */ +export function validateAndConsumeToken(token: string, tabId: number): boolean { + if (!activeToken) return false; + if (activeToken.token !== token || activeToken.tabId !== tabId) return false; + activeToken = null; + return true; +} diff --git a/src/main/ipc/browser/tabs.ts b/src/main/ipc/browser/tabs.ts index fb850c530..57ca4b4ed 100644 --- a/src/main/ipc/browser/tabs.ts +++ b/src/main/ipc/browser/tabs.ts @@ -9,6 +9,7 @@ import { tabsController } from "@/controllers/tabs-controller"; import { serializeTabForRenderer, serializeTabGroupForRenderer } from "@/saving/tabs/serialization"; import { recentlyClosedManager } from "@/saving/tabs/recently-closed"; import { GlanceTabGroup } from "@/controllers/tabs-controller/tab-groups/glance"; +import { registerToken, validateAndConsumeToken } from "@/ipc/app/drag-token"; /** * Attempts to restore a tab's group membership after it has been recreated. @@ -335,46 +336,63 @@ ipcMain.handle("tabs:move-tab", async (event, tabId: number, newPosition: number return true; }); -ipcMain.handle("tabs:move-tab-to-window-space", async (event, tabId: number, spaceId: string, newPosition?: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; +ipcMain.on("drag:register-token", (_event, token: string, tabId: number) => { + registerToken(token, tabId); +}); - const tab = tabsController.getTabById(tabId); - if (!tab) return false; +ipcMain.handle( + "tabs:move-tab-to-window-space", + async (event, tabId: number, spaceId: string, newPosition?: number, dragToken?: string) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return false; - const space = await spacesController.get(spaceId); - if (!space) return false; + const tab = tabsController.getTabById(tabId); + if (!tab) return false; - // Capture source space before move (for normalizing after) - const sourceSpaceId = tab.spaceId; + const space = await spacesController.get(spaceId); + if (!space) return false; - // Collect all tabs to move (includes tab group members) - let targetTabs: Tab[] = [tab]; - const tabGroup = tabsController.getTabGroupByTabId(tab.id); - if (tabGroup) { - targetTabs = tabGroup.tabs; - } + // For external cross-window drops a one-use drag token is required. + // Validate and consume it before proceeding so it cannot be replayed. + if (dragToken !== undefined) { + if (!validateAndConsumeToken(dragToken, tabId)) return false; + } - // Move all tabs in the group to the new space - for (const targetTab of targetTabs) { - targetTab.setSpace(spaceId); - targetTab.setWindow(window); + // Capture source location before move (for normalizing after) + const sourceSpaceId = tab.spaceId; + const sourceWindowId = tab.getWindow()?.id; - if (newPosition !== undefined) { - targetTab.updateStateProperty("position", newPosition); + // Collect all tabs to move (includes tab group members) + let targetTabs: Tab[] = [tab]; + const tabGroup = tabsController.getTabGroupByTabId(tab.id); + if (tabGroup) { + targetTabs = tabGroup.tabs; } - } - // Normalize positions in both source and target spaces - tabsController.normalizePositions(window.id, spaceId); - if (sourceSpaceId !== spaceId) { - tabsController.normalizePositions(window.id, sourceSpaceId); - } + // Move all tabs in the group to the new space + for (const targetTab of targetTabs) { + targetTab.setSpace(spaceId); + targetTab.setWindow(window); - tabsController.setActiveTab(tab); - return true; -}); + if (newPosition !== undefined) { + targetTab.updateStateProperty("position", newPosition); + } + } + + // Normalize positions in the target space + tabsController.normalizePositions(window.id, spaceId); + + // Normalize positions in the source space (may be in a different window for cross-window moves) + if (sourceSpaceId !== spaceId || sourceWindowId !== window.id) { + const normalizeWindowId = sourceWindowId ?? window.id; + tabsController.normalizePositions(normalizeWindowId, sourceSpaceId); + } + + tabsController.setActiveTab(tab); + return true; + } +); ipcMain.on("tabs:show-context-menu", (event, tabId: number) => { const webContents = event.sender; diff --git a/src/preload/index.ts b/src/preload/index.ts index 928371af6..ad69d213b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -446,8 +446,12 @@ const tabsAPI: FlowTabsAPI = { return ipcRenderer.invoke("tabs:move-tab", tabId, newPosition); }, - moveTabToWindowSpace: async (tabId: number, spaceId: string, newPosition?: number) => { - return ipcRenderer.invoke("tabs:move-tab-to-window-space", tabId, spaceId, newPosition); + moveTabToWindowSpace: async (tabId: number, spaceId: string, newPosition?: number, dragToken?: string) => { + return ipcRenderer.invoke("tabs:move-tab-to-window-space", tabId, spaceId, newPosition, dragToken); + }, + + registerDragToken: (token: string, tabId: number) => { + ipcRenderer.send("drag:register-token", token, tabId); }, // Special Exception: This is allowed for all internal protocols. diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-switcher.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-switcher.tsx index a9e4ed309..8f52293c6 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-switcher.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-switcher.tsx @@ -3,8 +3,15 @@ import { cn } from "@/lib/utils"; import { useSpaces } from "@/components/providers/spaces-provider"; import { SpaceIcon } from "@/lib/phosphor-icons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group"; +import { + type TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop +} from "@/lib/tab-drag-mime"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { AnimatePresence, motion } from "motion/react"; // Layout constants (px) @@ -64,35 +71,57 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { removeDraggingTimeout(); } - return dropTargetForElements({ - element, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") return false; - - const sourceProfileId = sourceData.profileId; - const targetProfileId = space.profileId; - - // Does not support moving tabs between profiles - if (sourceProfileId !== targetProfileId) return false; - - // Don't allow dropping on the space the tab is already in - if (sourceData.spaceId === space.id) return false; - - return true; - }, - onDragEnter: startDragging, - onDrag: startDragging, - onDragLeave: stopDragging, - onDrop: (args) => { - stopDragging(); - - // Move the tab to this space (no specific position — append to end) - const sourceData = args.source.data as TabGroupSourceData; - const sourceTabId = sourceData.primaryTabId; - flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { + stopDragging(); + + // Validate profile compatibility + if (sourceData.profileId !== space.profileId) { + // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet + return; } - }); + + // For external (cross-window) drops, always move via IPC even if same space + if (!isExternal && sourceData.spaceId === space.id) { + return; + } + + // Move the tab to this space (no specific position — append to end) + const sourceTabId = sourceData.primaryTabId; + flow.tabs.moveTabToWindowSpace(sourceTabId, space.id, undefined, sourceData.dragToken); + } + + return combine( + dropTargetForElements({ + element, + canDrop: (args) => + canDropElementTabGroup(args.source.data, { + profileId: space.profileId, + excludeSpaceId: space.id + }), + onDragEnter: startDragging, + onDrag: startDragging, + onDragLeave: stopDragging, + onDrop: (args) => { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, false); + } + }), + + dropTargetForExternal({ + element, + canDrop: (args) => canDropExternalTabGroup(args.source.types, space.profileId), + onDragEnter: startDragging, + onDrag: startDragging, + onDragLeave: stopDragging, + onDrop: (args) => { + stopDragging(); + + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); + } + }) + ); }, [onClick, removeDraggingTimeout, space.profileId, space.id]); const showIcon = !compact || isHovered || isActive; diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx index 20f174cba..478a7f2cc 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx @@ -1,4 +1,9 @@ -import { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group"; +import { + TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop +} from "@/lib/tab-drag-mime"; import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_components/drop-indicator"; import { useEffect, useRef, useState } from "react"; import { Space } from "~/flow/interfaces/sessions/spaces"; @@ -6,6 +11,11 @@ import { dropTargetForElements, ElementDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + dropTargetForExternal, + ExternalDropTargetEventBasePayload +} from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; type TabDropTargetProps = { spaceData: Space; @@ -26,49 +36,59 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } const el = dropTargetRef.current; if (!el) return () => {}; - function onDrop(args: ElementDropTargetEventBasePayload) { + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { setShowDropIndicator(false); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; - const newPos = biggestIndex + 1; - if (sourceData.spaceId !== spaceData.id) { + if (sourceData.spaceId !== spaceData.id || isExternal) { if (sourceData.profileId !== spaceData.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet } else { - flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos); + flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos, sourceData.dragToken); } } else { moveTab(sourceTabId, newPos); } } + function onDrop(args: ElementDropTargetEventBasePayload) { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, false); + } + + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + setShowDropIndicator(false); + + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); + } + function onChange() { setShowDropIndicator(true); } - const cleanupDropTarget = dropTargetForElements({ - element: el, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") { - return false; - } - if (sourceData.profileId !== spaceData.profileId) { - // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet - return false; - } - return true; - }, - onDrop: onDrop, - onDragEnter: onChange, - onDrag: onChange, - onDragLeave: () => setShowDropIndicator(false) - }); + return combine( + dropTargetForElements({ + element: el, + canDrop: (args) => canDropElementTabGroup(args.source.data, { profileId: spaceData.profileId }), + onDrop: onDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setShowDropIndicator(false) + }), - return cleanupDropTarget; + dropTargetForExternal({ + element: el, + canDrop: (args) => canDropExternalTabGroup(args.source.types, spaceData.profileId), + onDrop: onExternalDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setShowDropIndicator(false) + }) + ); }, [spaceData.profileId, isSpaceLight, moveTab, biggestIndex, spaceData.id]); return ( diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx index 32e6ca4eb..cd58c747c 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx @@ -9,19 +9,23 @@ import { dropTargetForElements, ElementDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + dropTargetForExternal, + ExternalDropTargetEventBasePayload +} from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { attachClosestEdge, extractClosestEdge, Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import type { Input } from "@atlaskit/pragmatic-drag-and-drop/types"; import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_components/drop-indicator"; +import { + TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop, + makeTabGroupExternalPayload +} from "@/lib/tab-drag-mime"; -// --- Types --- // - -export type TabGroupSourceData = { - type: "tab-group"; - tabGroupId: string; - primaryTabId: number; - profileId: string; - spaceId: string; - position: number; -}; +export type { TabGroupSourceData }; // --- SidebarTab (memoized) --- // @@ -207,11 +211,14 @@ export const TabGroup = memo( setClosestEdge(edge); } - function onDrop(args: ElementDropTargetEventBasePayload) { - const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + function onExternalChange({ self }: ExternalDropTargetEventBasePayload) { + const edge = extractClosestEdge(self.data); + setClosestEdge(edge); + } + + function handleDrop(sourceData: TabGroupSourceData, closestEdgeOfTarget: Edge | null, isExternal: boolean) { setClosestEdge(null); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; let newPos: number | undefined = undefined; @@ -222,67 +229,100 @@ export const TabGroup = memo( newPos = position + 0.5; } - if (sourceData.spaceId !== tabGroup.spaceId) { + if (sourceData.spaceId !== tabGroup.spaceId || isExternal) { if (sourceData.profileId !== tabGroup.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet } else { - flow.tabs.moveTabToWindowSpace(sourceTabId, tabGroup.spaceId, newPos); + flow.tabs.moveTabToWindowSpace( + sourceTabId, + tabGroup.spaceId, + newPos, + isExternal ? sourceData.dragToken : undefined + ); } } else if (newPos !== undefined) { moveTab(sourceTabId, newPos); } } - const draggableCleanup = draggable({ - element: el, - getInitialData: () => { - const data: TabGroupSourceData = { - type: "tab-group", - tabGroupId: tabGroup.id, - primaryTabId: primaryTabId, - profileId: tabGroup.profileId, - spaceId: tabGroup.spaceId, - position: position - }; - return data; - } - }); - - const cleanupDropTarget = dropTargetForElements({ - element: el, - getData: ({ input, element }) => { - return attachClosestEdge( - {}, - { - input, - element, - allowedEdges: ["top", "bottom"] - } - ); - }, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") { - return false; - } - if (sourceData.tabGroupId === tabGroup.id) { - return false; + function onDrop(args: ElementDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, closestEdgeOfTarget, false); + } + + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + setClosestEdge(null); + + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, closestEdgeOfTarget, true); + } + + const edgeData = ({ input, element }: { input: Input; element: Element }) => + attachClosestEdge( + {}, + { + input, + element, + allowedEdges: ["top", "bottom"] } - if (sourceData.profileId !== tabGroup.profileId) { - return false; + ); + + function generateBasicTabGroupSourceData(): TabGroupSourceData { + return { + type: "tab-group", + tabGroupId: tabGroup.id, + primaryTabId, + profileId: tabGroup.profileId, + spaceId: tabGroup.spaceId, + position + }; + } + + return combine( + draggable({ + element: el, + getInitialData: () => { + return generateBasicTabGroupSourceData(); + }, + getInitialDataForExternal: () => { + const dragToken = crypto.randomUUID(); + flow.tabs.registerDragToken(dragToken, primaryTabId); + const data: TabGroupSourceData = { + ...generateBasicTabGroupSourceData(), + dragToken + }; + return makeTabGroupExternalPayload(data); } - return true; - }, - onDrop: onDrop, - onDragEnter: onChange, - onDrag: onChange, - onDragLeave: () => setClosestEdge(null) - }); - - return () => { - draggableCleanup(); - cleanupDropTarget(); - }; + }), + + dropTargetForElements({ + element: el, + getData: ({ input, element }) => edgeData({ input, element }), + canDrop: (args) => { + return canDropElementTabGroup(args.source.data, { + profileId: tabGroup.profileId, + excludeTabGroupId: tabGroup.id + }); + }, + onDrop: onDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setClosestEdge(null) + }), + + dropTargetForExternal({ + element: el, + getData: ({ input, element }) => edgeData({ input, element }), + canDrop: (args) => canDropExternalTabGroup(args.source.types, tabGroup.profileId), + onDrop: onExternalDrop, + onDragEnter: onExternalChange, + onDrag: onExternalChange, + onDragLeave: () => setClosestEdge(null) + }) + ); }, [moveTab, tabGroup.id, position, primaryTabId, tabGroup.spaceId, tabGroup.profileId]); return ( diff --git a/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-drop-target.tsx b/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-drop-target.tsx index 7c0aea50f..ec3555779 100644 --- a/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-drop-target.tsx +++ b/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-drop-target.tsx @@ -1,4 +1,9 @@ -import { TabGroupSourceData } from "@/components/old-browser-ui/sidebar/content/sidebar-tab-groups"; +import { + TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop +} from "@/lib/tab-drag-mime"; import { DropIndicator } from "@/components/old-browser-ui/sidebar/content/space-sidebar"; import { useEffect, useRef, useState } from "react"; import { Space } from "~/flow/interfaces/sessions/spaces"; @@ -6,6 +11,11 @@ import { dropTargetForElements, ElementDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + dropTargetForExternal, + ExternalDropTargetEventBasePayload +} from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; type SidebarTabDropTargetProps = { spaceData: Space; @@ -26,52 +36,60 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest const el = dropTargetRef.current; if (!el) return () => {}; - function onDrop(args: ElementDropTargetEventBasePayload) { + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { setShowDropIndicator(false); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; - const newPos = biggestIndex + 1; - if (sourceData.spaceId !== spaceData.id) { + if (sourceData.spaceId !== spaceData.id || isExternal) { if (sourceData.profileId !== spaceData.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet } else { // move tab to new space - flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos); + flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos, sourceData.dragToken); } } else { moveTab(sourceTabId, newPos); } } - function onChange() { - setShowDropIndicator(true); + function onDrop(args: ElementDropTargetEventBasePayload) { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, false); } - const cleanupDropTarget = dropTargetForElements({ - element: el, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") { - return false; - } + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + setShowDropIndicator(false); - if (sourceData.profileId !== spaceData.profileId) { - // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet - return false; - } + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); + } + + function onChange() { + setShowDropIndicator(true); + } - return true; - }, - onDrop: onDrop, - onDragEnter: onChange, - onDrag: onChange, - onDragLeave: () => setShowDropIndicator(false) - }); + return combine( + dropTargetForElements({ + element: el, + canDrop: (args) => canDropElementTabGroup(args.source.data, { profileId: spaceData.profileId }), + onDrop: onDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setShowDropIndicator(false) + }), - return cleanupDropTarget; + dropTargetForExternal({ + element: el, + canDrop: (args) => canDropExternalTabGroup(args.source.types, spaceData.profileId), + onDrop: onExternalDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setShowDropIndicator(false) + }) + ); }, [spaceData.profileId, isSpaceLight, moveTab, biggestIndex, spaceData.id]); return ( diff --git a/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-groups.tsx b/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-groups.tsx index e473b075a..24083762d 100644 --- a/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-groups.tsx +++ b/src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-groups.tsx @@ -10,9 +10,24 @@ import { dropTargetForElements, ElementDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + dropTargetForExternal, + ExternalDropTargetEventBasePayload +} from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { attachClosestEdge, extractClosestEdge, Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import type { Input } from "@atlaskit/pragmatic-drag-and-drop/types"; import { TabData } from "~/types/tabs"; import { DropIndicator } from "@/components/old-browser-ui/sidebar/content/space-sidebar"; +import { + TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop, + makeTabGroupExternalPayload +} from "@/lib/tab-drag-mime"; + +export type { TabGroupSourceData }; const MotionSidebarMenuButton = motion(SidebarMenuButton); @@ -189,15 +204,6 @@ export function SidebarTab({ tab, isFocused }: { tab: TabData; isFocused: boolea ); } -export type TabGroupSourceData = { - type: "tab-group"; - tabGroupId: string; - primaryTabId: number; - profileId: string; - spaceId: string; - position: number; -}; - export function SidebarTabGroups({ tabGroup, isFocused, @@ -217,6 +223,11 @@ export function SidebarTabGroups({ const [closestEdge, setClosestEdge] = useState(null); + // Extract stable primitive for the drag-and-drop effect dependency array. + // tabGroup.tabs is a new array each render, so using primaryTabId avoids + // unnecessary effect re-runs on every tab data update. + const primaryTabId = tabs[0]?.id; + useEffect(() => { const el = ref.current; if (!el) return () => {}; @@ -226,12 +237,25 @@ export function SidebarTabGroups({ setClosestEdge(closestEdge); } - function onDrop(args: ElementDropTargetEventBasePayload) { - const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + function onExternalChange({ self }: ExternalDropTargetEventBasePayload) { + const edge = extractClosestEdge(self.data); + setClosestEdge(edge); + } + + function generateBasicTabGroupSourceData(): TabGroupSourceData { + return { + type: "tab-group", + tabGroupId: tabGroup.id, + primaryTabId, + profileId: tabGroup.profileId, + spaceId: tabGroup.spaceId, + position + }; + } + function handleDrop(sourceData: TabGroupSourceData, closestEdgeOfTarget: Edge | null, isExternal: boolean) { setClosestEdge(null); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; let newPos: number | undefined = undefined; @@ -242,75 +266,87 @@ export function SidebarTabGroups({ newPos = position + 0.5; } - if (sourceData.spaceId !== tabGroup.spaceId) { + if (sourceData.spaceId !== tabGroup.spaceId || isExternal) { if (sourceData.profileId !== tabGroup.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet } else { - // move tab to new space - flow.tabs.moveTabToWindowSpace(sourceTabId, tabGroup.spaceId, newPos); + flow.tabs.moveTabToWindowSpace( + sourceTabId, + tabGroup.spaceId, + newPos, + isExternal ? sourceData.dragToken : undefined + ); } } else if (newPos !== undefined) { moveTab(sourceTabId, newPos); } } - const draggableCleanup = draggable({ - element: el, - getInitialData: () => { - const data: TabGroupSourceData = { - type: "tab-group", - tabGroupId: tabGroup.id, - primaryTabId: tabGroup.tabs[0].id, - profileId: tabGroup.profileId, - spaceId: tabGroup.spaceId, - position: position - }; - return data; - } - }); - - const cleanupDropTarget = dropTargetForElements({ - element: el, - getData: ({ input, element }) => { - // this will 'attach' the closest edge to your `data` object - return attachClosestEdge( - {}, - { - input, - element, - // you can specify what edges you want to allow the user to be closest to - allowedEdges: ["top", "bottom"] - } - ); - }, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") { - return false; - } - - if (sourceData.tabGroupId === tabGroup.id) { - return false; - } + function onDrop(args: ElementDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, closestEdgeOfTarget, false); + } - if (sourceData.profileId !== tabGroup.profileId) { - // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet - return false; - } + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + setClosestEdge(null); - return true; - }, - onDrop: onDrop, - onDragEnter: onChange, - onDrag: onChange, - onDragLeave: () => setClosestEdge(null) - }); + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, closestEdgeOfTarget, true); + } - return () => { - draggableCleanup(); - cleanupDropTarget(); - }; - }, [moveTab, tabGroup.id, position, tabGroup.tabs, tabGroup.spaceId, tabGroup.profileId]); + const edgeData = ({ input, element }: { input: Input; element: Element }) => + attachClosestEdge( + {}, + { + input, + element, + allowedEdges: ["top", "bottom"] + } + ); + + return combine( + draggable({ + element: el, + getInitialData: () => generateBasicTabGroupSourceData(), + getInitialDataForExternal: () => { + const dragToken = crypto.randomUUID(); + flow.tabs.registerDragToken(dragToken, primaryTabId); + const data: TabGroupSourceData = { + ...generateBasicTabGroupSourceData(), + dragToken + }; + return makeTabGroupExternalPayload(data); + } + }), + + dropTargetForElements({ + element: el, + getData: ({ input, element }) => edgeData({ input, element }), + canDrop: (args) => + canDropElementTabGroup(args.source.data, { + profileId: tabGroup.profileId, + excludeTabGroupId: tabGroup.id + }), + onDrop: onDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setClosestEdge(null) + }), + + dropTargetForExternal({ + element: el, + getData: ({ input, element }) => edgeData({ input, element }), + canDrop: (args) => canDropExternalTabGroup(args.source.types, tabGroup.profileId), + onDrop: onExternalDrop, + onDragEnter: onExternalChange, + onDrag: onExternalChange, + onDragLeave: () => setClosestEdge(null) + }) + ); + }, [moveTab, tabGroup.id, position, primaryTabId, tabGroup.spaceId, tabGroup.profileId]); return ( <> diff --git a/src/renderer/src/components/old-browser-ui/sidebar/spaces-switcher.tsx b/src/renderer/src/components/old-browser-ui/sidebar/spaces-switcher.tsx index 5282b8653..e536440b9 100644 --- a/src/renderer/src/components/old-browser-ui/sidebar/spaces-switcher.tsx +++ b/src/renderer/src/components/old-browser-ui/sidebar/spaces-switcher.tsx @@ -5,8 +5,15 @@ import { useSpaces } from "@/components/providers/spaces-provider"; import { SIDEBAR_HOVER_COLOR, SIDEBAR_HOVER_COLOR_PLAIN } from "@/components/old-browser-ui/browser-sidebar"; import { SpaceIcon } from "@/lib/phosphor-icons"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { TabGroupSourceData } from "@/components/old-browser-ui/sidebar/content/sidebar-tab-groups"; +import { + type TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop +} from "@/lib/tab-drag-mime"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; type SpaceButtonProps = { space: Space; @@ -48,7 +55,6 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { if (!draggingTimeoutRef.current) { draggingTimeoutRef.current = setTimeout(() => { - console.log("clicked"); onClickRef.current(); removeDraggingTimeout(); }, 100); @@ -60,35 +66,57 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { removeDraggingTimeout(); } - return dropTargetForElements({ - element, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") return false; - - const sourceProfileId = sourceData.profileId; - const targetProfileId = space.profileId; - - // TODO: @MOVE_TABS_BETWEEN_PROFILES Does not support moving tabs between profiles - if (sourceProfileId !== targetProfileId) return false; - - // Don't allow dropping on the space the tab is already in - if (sourceData.spaceId === space.id) return false; - - return true; - }, - onDragEnter: startDragging, - onDrag: startDragging, - onDragLeave: stopDragging, - onDrop: (args) => { - stopDragging(); - - // Move the tab to this space (no specific position — append to end) - const sourceData = args.source.data as TabGroupSourceData; - const sourceTabId = sourceData.primaryTabId; - flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { + stopDragging(); + + // Validate profile compatibility + if (sourceData.profileId !== space.profileId) { + // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet + return; + } + + // For external (cross-window) drops, always move via IPC even if same space + if (!isExternal && sourceData.spaceId === space.id) { + return; } - }); + + // Move the tab to this space (no specific position — append to end) + const sourceTabId = sourceData.primaryTabId; + flow.tabs.moveTabToWindowSpace(sourceTabId, space.id, undefined, sourceData.dragToken); + } + + return combine( + dropTargetForElements({ + element, + canDrop: (args) => + canDropElementTabGroup(args.source.data, { + profileId: space.profileId, + excludeSpaceId: space.id + }), + onDragEnter: startDragging, + onDrag: startDragging, + onDragLeave: stopDragging, + onDrop: (args) => { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, false); + } + }), + + dropTargetForExternal({ + element, + canDrop: (args) => canDropExternalTabGroup(args.source.types, space.profileId), + onDragEnter: startDragging, + onDrag: startDragging, + onDragLeave: stopDragging, + onDrop: (args) => { + stopDragging(); + + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); + } + }) + ); }, [onClick, removeDraggingTimeout, space.profileId, space.id]); return ( diff --git a/src/renderer/src/lib/tab-drag-mime.ts b/src/renderer/src/lib/tab-drag-mime.ts new file mode 100644 index 000000000..b60cd11a7 --- /dev/null +++ b/src/renderer/src/lib/tab-drag-mime.ts @@ -0,0 +1,89 @@ +// Shared MIME type constants for cross-window tab drag-and-drop. +// The payload is serialized as JSON under TAB_GROUP_MIME_TYPE. +// An additional empty-value entry keyed by TAB_GROUP_PROFILE_MIME_PREFIX + profileId +// is registered so that external drop targets can check profile compatibility +// during the drag phase (MIME type *names* are visible; values are not). + +export const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; +export const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; + +// --- Shared type --- // + +export type TabGroupSourceData = { + type: "tab-group"; + tabGroupId: string; + primaryTabId: number; + profileId: string; + spaceId: string; + position: number; + dragToken?: string; +}; + +// --- Shared helpers --- // + +/** + * Returns true if the external drag source carries a tab-group payload + * that is compatible with the given profile. + * + * Safe to call inside `canDrop` — only inspects MIME type *names*, not values, + * which is the only information the browser exposes during hover. + */ +export function canDropExternalTabGroup(types: string[], profileId: string): boolean { + return types.includes(TAB_GROUP_MIME_TYPE) && types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + profileId); +} + +/** + * Returns true if an element drag source is a tab-group compatible with the given target. + * + * @param data - `args.source.data` from a `dropTargetForElements` callback + * @param profileId - required: profile ID the drop target belongs to + * @param excludeTabGroupId - optional: reject drops from this specific tab group (prevents self-reorder) + * @param excludeSpaceId - optional: reject drops from this specific space (prevents no-op moves) + */ +export function canDropElementTabGroup( + data: unknown, + options: { + profileId: string; + excludeTabGroupId?: string; + excludeSpaceId?: string; + } +): boolean { + const sourceData = data as TabGroupSourceData; + if (sourceData.type !== "tab-group") return false; + if (options.excludeTabGroupId !== undefined && sourceData.tabGroupId === options.excludeTabGroupId) return false; + if (sourceData.profileId !== options.profileId) return false; + if (options.excludeSpaceId !== undefined && sourceData.spaceId === options.excludeSpaceId) return false; + return true; +} + +/** + * Parses and validates an external tab-group drop payload. + * Returns the `TabGroupSourceData` if the payload is well-formed and contains a drag token; + * returns `null` if the MIME type is missing, the JSON is malformed, or the token is absent. + */ +export function parseExternalTabGroupDrop(source: { + getStringData(type: string): string | null; +}): TabGroupSourceData | null { + const raw = source.getStringData(TAB_GROUP_MIME_TYPE); + if (!raw) return null; + try { + const data = JSON.parse(raw) as TabGroupSourceData; + if (!data.dragToken) return null; + return data; + } catch { + return null; + } +} + +/** + * Builds the external DataTransfer payload for a tab-group drag. + * Encodes the full source data under the standard MIME type, and adds + * an empty sentinel entry keyed by the profile-specific MIME name so + * drop targets can check profile compatibility during `canDrop`. + */ +export function makeTabGroupExternalPayload(data: TabGroupSourceData): Record { + return { + [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), + [TAB_GROUP_PROFILE_MIME_PREFIX + data.profileId]: "" + }; +} diff --git a/src/shared/flow/interfaces/browser/tabs.ts b/src/shared/flow/interfaces/browser/tabs.ts index 86358d7ac..4ab7b2bb5 100644 --- a/src/shared/flow/interfaces/browser/tabs.ts +++ b/src/shared/flow/interfaces/browser/tabs.ts @@ -74,8 +74,19 @@ export interface FlowTabsAPI { * @param tabId The id of the tab to move * @param spaceId The id of the space to move the tab to * @param newPosition The new position of the tab + * @param dragToken One-use token authenticating a cross-window external drop */ - moveTabToWindowSpace: (tabId: number, spaceId: string, newPosition?: number) => Promise; + moveTabToWindowSpace: (tabId: number, spaceId: string, newPosition?: number, dragToken?: string) => Promise; + + /** + * Register a one-use drag token for a cross-window tab drag. + * Must be called at the start of an external drag (getInitialDataForExternal). + * The token is tied to the specific tab and is consumed on the first valid + * call to moveTabToWindowSpace, preventing replay. + * @param token Randomly generated token for this drag + * @param tabId The id of the tab being dragged + */ + registerDragToken: (token: string, tabId: number) => void; /** * Move multiple tabs to a new space in one operation