From fea9d88e87e6b9a6def5ade9b93f115cd3dbbec5 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 2 Mar 2026 16:40:25 +0000 Subject: [PATCH 01/10] fix: use source window ID for position normalization in cross-window tab moves The tabs:move-tab-to-window-space IPC handler previously normalized positions in the source space using the target window ID. For cross-window moves, the source space lives in a different window, so we now capture sourceWindowId before the move and use it for the source normalization. --- src/main/ipc/browser/tabs.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/browser/tabs.ts b/src/main/ipc/browser/tabs.ts index fb850c530..cc5530c97 100644 --- a/src/main/ipc/browser/tabs.ts +++ b/src/main/ipc/browser/tabs.ts @@ -346,8 +346,9 @@ ipcMain.handle("tabs:move-tab-to-window-space", async (event, tabId: number, spa const space = await spacesController.get(spaceId); if (!space) return false; - // Capture source space before move (for normalizing after) + // Capture source location before move (for normalizing after) const sourceSpaceId = tab.spaceId; + const sourceWindowId = tab.getWindow()?.id; // Collect all tabs to move (includes tab group members) let targetTabs: Tab[] = [tab]; @@ -366,10 +367,13 @@ ipcMain.handle("tabs:move-tab-to-window-space", async (event, tabId: number, spa } } - // Normalize positions in both source and target spaces + // Normalize positions in the target space tabsController.normalizePositions(window.id, spaceId); - if (sourceSpaceId !== spaceId) { - tabsController.normalizePositions(window.id, sourceSpaceId); + + // 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); From 78de3d2ce89803925b43a4d9e56bd212990e8284 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 2 Mar 2026 16:40:35 +0000 Subject: [PATCH 02/10] feat: add cross-window tab drag-and-drop to new browser UI Use pragmatic-dnd's external adapter to handle drags arriving from other Electron windows via the native OS drag system: - tab-group.tsx: add getInitialDataForExternal on draggable() to serialize TabGroupSourceData to a custom MIME type, add dropTargetForExternal to accept external drops, refactor drop logic into shared handleDrop. - tab-drop-target.tsx: add dropTargetForExternal alongside existing dropTargetForElements, refactor into shared handleDrop. - space-switcher.tsx: add dropTargetForExternal to SpaceButton so tabs from other windows can be dropped onto space icons. All registrations use combine() for cleanup. canDrop for external drops checks MIME type presence only (HTML5 DnD security restriction); profile compatibility is verified in onDrop when data becomes available. --- .../_components/space-switcher.tsx | 91 +++++++---- .../_components/tab-drop-target.tsx | 82 +++++++--- .../browser-sidebar/_components/tab-group.tsx | 154 ++++++++++++------ 3 files changed, 225 insertions(+), 102 deletions(-) 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..427c71530 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 @@ -5,8 +5,13 @@ 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 { 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"; +// MIME type for cross-window tab drag data +const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; + // Layout constants (px) const ICON_SIZE = 28; // size-7 const DOT_SIZE = 20; // size-5 @@ -64,35 +69,63 @@ 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) { + stopDragging(); + // Move the tab to this space (no specific position — append to end) + const sourceTabId = sourceData.primaryTabId; + flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); + } + + return combine( + 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) => { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData); + } + }), + + dropTargetForExternal({ + element, + canDrop: (args) => { + return args.source.types.includes(TAB_GROUP_MIME_TYPE); + }, + onDragEnter: startDragging, + onDrag: startDragging, + onDragLeave: stopDragging, + onDrop: (args) => { + stopDragging(); + + const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); + if (!raw) return; + + try { + const sourceData = JSON.parse(raw) as TabGroupSourceData; + handleDrop(sourceData); + } catch { + // Invalid data from external source + } + } + }) + ); }, [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..979583e2c 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 @@ -6,6 +6,14 @@ 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"; + +// MIME type for cross-window tab drag data +const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; type TabDropTargetProps = { spaceData: Space; @@ -26,12 +34,10 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } const el = dropTargetRef.current; if (!el) return () => {}; - function onDrop(args: ElementDropTargetEventBasePayload) { + function handleDrop(sourceData: TabGroupSourceData) { setShowDropIndicator(false); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; - const newPos = biggestIndex + 1; if (sourceData.spaceId !== spaceData.id) { @@ -45,30 +51,60 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } } } + function onDrop(args: ElementDropTargetEventBasePayload) { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData); + } + + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + setShowDropIndicator(false); + + const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); + if (!raw) return; + + try { + const sourceData = JSON.parse(raw) as TabGroupSourceData; + handleDrop(sourceData); + } catch { + // Invalid data from external source + } + } + 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 cleanupDropTarget; + return combine( + 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) + }), + + dropTargetForExternal({ + element: el, + canDrop: (args) => { + return args.source.types.includes(TAB_GROUP_MIME_TYPE); + }, + 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..455be4d3e 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,9 +9,18 @@ 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"; +// MIME type for cross-window tab drag data +const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; + // --- Types --- // export type TabGroupSourceData = { @@ -207,11 +216,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) { setClosestEdge(null); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; let newPos: number | undefined = undefined; @@ -233,56 +245,98 @@ export const TabGroup = memo( } } - 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; + function onDrop(args: ElementDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, closestEdgeOfTarget); + } + + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + setClosestEdge(null); + + const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); + if (!raw) return; + + try { + const sourceData = JSON.parse(raw) as TabGroupSourceData; + handleDrop(sourceData, closestEdgeOfTarget); + } catch { + // Invalid data from external source } - }); - - 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; + } + + const edgeData = ({ input, element }: { input: Input; element: Element }) => + attachClosestEdge( + {}, + { + input, + element, + allowedEdges: ["top", "bottom"] } - if (sourceData.profileId !== tabGroup.profileId) { - return false; + ); + + return combine( + 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; + }, + getInitialDataForExternal: () => { + const data: TabGroupSourceData = { + type: "tab-group", + tabGroupId: tabGroup.id, + primaryTabId: primaryTabId, + profileId: tabGroup.profileId, + spaceId: tabGroup.spaceId, + position: position + }; + return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(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) => { + const sourceData = args.source.data as TabGroupSourceData; + if (sourceData.type !== "tab-group") { + return false; + } + if (sourceData.tabGroupId === tabGroup.id) { + return false; + } + if (sourceData.profileId !== tabGroup.profileId) { + return false; + } + return true; + }, + onDrop: onDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setClosestEdge(null) + }), + + dropTargetForExternal({ + element: el, + getData: ({ input, element }) => edgeData({ input, element }), + canDrop: (args) => { + return args.source.types.includes(TAB_GROUP_MIME_TYPE); + }, + onDrop: onExternalDrop, + onDragEnter: onExternalChange, + onDrag: onExternalChange, + onDragLeave: () => setClosestEdge(null) + }) + ); }, [moveTab, tabGroup.id, position, primaryTabId, tabGroup.spaceId, tabGroup.profileId]); return ( From 5456b6adec79185cb339141be15dc560511c57e1 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 2 Mar 2026 16:40:41 +0000 Subject: [PATCH 03/10] feat: add cross-window tab drag-and-drop to old browser UI Mirror the new browser UI cross-window DnD changes for the old UI: - sidebar-tab-groups.tsx: add getInitialDataForExternal, dropTargetForExternal, shared handleDrop, and combine() cleanup. - sidebar-tab-drop-target.tsx: add dropTargetForExternal with shared handleDrop. - spaces-switcher.tsx: add dropTargetForExternal to SpaceButton. Also removes a stray console.log('clicked') from spaces-switcher.tsx. --- .../content/sidebar-tab-drop-target.tsx | 80 ++++++--- .../sidebar/content/sidebar-tab-groups.tsx | 157 ++++++++++++------ .../sidebar/spaces-switcher.tsx | 92 ++++++---- 3 files changed, 224 insertions(+), 105 deletions(-) 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..7447bd8ba 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 @@ -6,6 +6,14 @@ 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"; + +// MIME type for cross-window tab drag data +const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; type SidebarTabDropTargetProps = { spaceData: Space; @@ -26,12 +34,10 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest const el = dropTargetRef.current; if (!el) return () => {}; - function onDrop(args: ElementDropTargetEventBasePayload) { + function handleDrop(sourceData: TabGroupSourceData) { setShowDropIndicator(false); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; - const newPos = biggestIndex + 1; if (sourceData.spaceId !== spaceData.id) { @@ -46,32 +52,62 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest } } + function onDrop(args: ElementDropTargetEventBasePayload) { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData); + } + + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + setShowDropIndicator(false); + + const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); + if (!raw) return; + + try { + const sourceData = JSON.parse(raw) as TabGroupSourceData; + handleDrop(sourceData); + } catch { + // Invalid data from external source + } + } + 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; - } + return combine( + 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; - } + 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 true; + }, + onDrop: onDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setShowDropIndicator(false) + }), - return cleanupDropTarget; + dropTargetForExternal({ + element: el, + canDrop: (args) => { + return args.source.types.includes(TAB_GROUP_MIME_TYPE); + }, + 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..c38a56d98 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,7 +10,13 @@ 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"; @@ -198,6 +204,9 @@ export type TabGroupSourceData = { position: number; }; +// MIME type for cross-window tab drag data +const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; + export function SidebarTabGroups({ tabGroup, isFocused, @@ -226,12 +235,14 @@ 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 handleDrop(sourceData: TabGroupSourceData, closestEdgeOfTarget: Edge | null) { setClosestEdge(null); - const sourceData = args.source.data as TabGroupSourceData; const sourceTabId = sourceData.primaryTabId; let newPos: number | undefined = undefined; @@ -254,62 +265,102 @@ export function SidebarTabGroups({ } } - 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; + function onDrop(args: ElementDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData, closestEdgeOfTarget); + } + + function onExternalDrop(args: ExternalDropTargetEventBasePayload) { + const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); + setClosestEdge(null); + + const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); + if (!raw) return; + + try { + const sourceData = JSON.parse(raw) as TabGroupSourceData; + handleDrop(sourceData, closestEdgeOfTarget); + } catch { + // Invalid data from external source } - }); - - 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; + const edgeData = ({ input, element }: { input: Input; element: Element }) => + attachClosestEdge( + {}, + { + input, + element, + allowedEdges: ["top", "bottom"] } - - if (sourceData.profileId !== tabGroup.profileId) { - // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet - return false; + ); + + return combine( + 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; + }, + getInitialDataForExternal: () => { + const data: TabGroupSourceData = { + type: "tab-group", + tabGroupId: tabGroup.id, + primaryTabId: tabGroup.tabs[0].id, + profileId: tabGroup.profileId, + spaceId: tabGroup.spaceId, + position: position + }; + return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(data) }; } + }), + + dropTargetForElements({ + element: el, + getData: ({ input, element }) => edgeData({ input, element }), + canDrop: (args) => { + const sourceData = args.source.data as TabGroupSourceData; + if (sourceData.type !== "tab-group") { + return false; + } - return true; - }, - onDrop: onDrop, - onDragEnter: onChange, - onDrag: onChange, - onDragLeave: () => setClosestEdge(null) - }); + if (sourceData.tabGroupId === tabGroup.id) { + return false; + } - return () => { - draggableCleanup(); - cleanupDropTarget(); - }; + if (sourceData.profileId !== tabGroup.profileId) { + // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet + return false; + } + + return true; + }, + onDrop: onDrop, + onDragEnter: onChange, + onDrag: onChange, + onDragLeave: () => setClosestEdge(null) + }), + + dropTargetForExternal({ + element: el, + getData: ({ input, element }) => edgeData({ input, element }), + canDrop: (args) => { + return args.source.types.includes(TAB_GROUP_MIME_TYPE); + }, + onDrop: onExternalDrop, + onDragEnter: onExternalChange, + onDrag: onExternalChange, + onDragLeave: () => setClosestEdge(null) + }) + ); }, [moveTab, tabGroup.id, position, tabGroup.tabs, 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..3d416b89a 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 @@ -7,6 +7,11 @@ 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 { 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"; + +// MIME type for cross-window tab drag data +const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; type SpaceButtonProps = { space: Space; @@ -48,7 +53,6 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { if (!draggingTimeoutRef.current) { draggingTimeoutRef.current = setTimeout(() => { - console.log("clicked"); onClickRef.current(); removeDraggingTimeout(); }, 100); @@ -60,35 +64,63 @@ 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) { + stopDragging(); + // Move the tab to this space (no specific position — append to end) + const sourceTabId = sourceData.primaryTabId; + flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); + } + + return combine( + 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) => { + const sourceData = args.source.data as TabGroupSourceData; + handleDrop(sourceData); + } + }), + + dropTargetForExternal({ + element, + canDrop: (args) => { + return args.source.types.includes(TAB_GROUP_MIME_TYPE); + }, + onDragEnter: startDragging, + onDrag: startDragging, + onDragLeave: stopDragging, + onDrop: (args) => { + stopDragging(); + + const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); + if (!raw) return; + + try { + const sourceData = JSON.parse(raw) as TabGroupSourceData; + handleDrop(sourceData); + } catch { + // Invalid data from external source + } + } + }) + ); }, [onClick, removeDraggingTimeout, space.profileId, space.id]); return ( From 0f3ce90271808c86f506429a1c7e91aabaa0cae3 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 2 Mar 2026 17:06:58 +0000 Subject: [PATCH 04/10] fix: add profile and space validation to space-switcher handleDrop For external drops, canDrop can only check MIME type presence (HTML5 DnD security prevents reading data until drop). The shared handleDrop was missing profile compatibility and same-space guards that internal drops got from canDrop. Without these, an external drop from a different profile or same space would incorrectly proceed to moveTabToWindowSpace. --- .../browser-sidebar/_components/space-switcher.tsx | 12 ++++++++++++ .../old-browser-ui/sidebar/spaces-switcher.tsx | 12 ++++++++++++ 2 files changed, 24 insertions(+) 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 427c71530..1b0a704d5 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 @@ -71,6 +71,18 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { function handleDrop(sourceData: TabGroupSourceData) { stopDragging(); + + // Validate profile compatibility (needed for external drops where canDrop can't read data) + if (sourceData.profileId !== space.profileId) { + // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet + return; + } + + // Don't allow dropping on the space the tab is already in + if (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); 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 3d416b89a..a7869b9dd 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 @@ -66,6 +66,18 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { function handleDrop(sourceData: TabGroupSourceData) { stopDragging(); + + // Validate profile compatibility (needed for external drops where canDrop can't read data) + if (sourceData.profileId !== space.profileId) { + // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet + return; + } + + // Don't allow dropping on the space the tab is already in + if (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); From ba91f473742d30d71e9dd893eccb80f6a9ed8743 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 2 Mar 2026 22:34:40 +0000 Subject: [PATCH 05/10] fix: cross-window tab DnD profile filtering and same-space window moves - Add isExternal flag to drop handlers so cross-window drops to the same space correctly call moveTabToWindowSpace instead of just reordering - Encode profileId into a MIME type (application/x-flow-tab-group-profile-{id}) so external canDrop can reject incompatible profiles during drag, preventing drop indicators and space switches for tabs from different profiles - Restore space-switcher hover-to-switch behavior for same-profile cross-window drags now that profile can be checked via MIME type --- .../_components/space-switcher.tsx | 11 +++++------ .../_components/tab-drop-target.tsx | 11 ++++++----- .../browser-sidebar/_components/tab-group.tsx | 19 +++++++++++++------ .../content/sidebar-tab-drop-target.tsx | 11 ++++++----- .../sidebar/content/sidebar-tab-groups.tsx | 16 ++++++++++------ .../sidebar/spaces-switcher.tsx | 13 ++++++------- 6 files changed, 46 insertions(+), 35 deletions(-) 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 1b0a704d5..11eab89c8 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 @@ -11,6 +11,7 @@ import { AnimatePresence, motion } from "motion/react"; // MIME type for cross-window tab drag data const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; +const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; // Layout constants (px) const ICON_SIZE = 28; // size-7 @@ -72,7 +73,7 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { function handleDrop(sourceData: TabGroupSourceData) { stopDragging(); - // Validate profile compatibility (needed for external drops where canDrop can't read data) + // Validate profile compatibility if (sourceData.profileId !== space.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet return; @@ -95,11 +96,8 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { 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; + if (sourceData.profileId !== space.profileId) return false; // Don't allow dropping on the space the tab is already in if (sourceData.spaceId === space.id) return false; @@ -118,7 +116,8 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { dropTargetForExternal({ element, canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_MIME_TYPE); + // Profile-specific MIME type lets us check compatibility during drag + return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + space.profileId); }, onDragEnter: startDragging, onDrag: startDragging, 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 979583e2c..dd009eb8c 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 @@ -14,6 +14,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; // MIME type for cross-window tab drag data const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; +const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; type TabDropTargetProps = { spaceData: Space; @@ -34,13 +35,13 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } const el = dropTargetRef.current; if (!el) return () => {}; - function handleDrop(sourceData: TabGroupSourceData) { + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { setShowDropIndicator(false); 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 { @@ -53,7 +54,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } function onDrop(args: ElementDropTargetEventBasePayload) { const sourceData = args.source.data as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, false); } function onExternalDrop(args: ExternalDropTargetEventBasePayload) { @@ -64,7 +65,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, true); } catch { // Invalid data from external source } @@ -97,7 +98,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } dropTargetForExternal({ element: el, canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_MIME_TYPE); + return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + spaceData.profileId); }, onDrop: onExternalDrop, onDragEnter: onChange, 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 455be4d3e..0db86d222 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 @@ -20,6 +20,10 @@ import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_componen // MIME type for cross-window tab drag data const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; +// Profile-specific MIME type prefix — used during external drag to check +// profile compatibility without reading the actual payload (which is only +// available on drop). Format: "application/x-flow-tab-group-profile-{profileId}" +const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; // --- Types --- // @@ -221,7 +225,7 @@ export const TabGroup = memo( setClosestEdge(edge); } - function handleDrop(sourceData: TabGroupSourceData, closestEdgeOfTarget: Edge | null) { + function handleDrop(sourceData: TabGroupSourceData, closestEdgeOfTarget: Edge | null, isExternal: boolean) { setClosestEdge(null); const sourceTabId = sourceData.primaryTabId; @@ -234,7 +238,7 @@ 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 { @@ -248,7 +252,7 @@ export const TabGroup = memo( function onDrop(args: ElementDropTargetEventBasePayload) { const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); const sourceData = args.source.data as TabGroupSourceData; - handleDrop(sourceData, closestEdgeOfTarget); + handleDrop(sourceData, closestEdgeOfTarget, false); } function onExternalDrop(args: ExternalDropTargetEventBasePayload) { @@ -260,7 +264,7 @@ export const TabGroup = memo( try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - handleDrop(sourceData, closestEdgeOfTarget); + handleDrop(sourceData, closestEdgeOfTarget, true); } catch { // Invalid data from external source } @@ -299,7 +303,10 @@ export const TabGroup = memo( spaceId: tabGroup.spaceId, position: position }; - return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(data) }; + return { + [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), + [TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId]: "" + }; } }), @@ -329,7 +336,7 @@ export const TabGroup = memo( element: el, getData: ({ input, element }) => edgeData({ input, element }), canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_MIME_TYPE); + return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId); }, onDrop: onExternalDrop, onDragEnter: onExternalChange, 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 7447bd8ba..4e6db8a93 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 @@ -14,6 +14,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; // MIME type for cross-window tab drag data const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; +const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; type SidebarTabDropTargetProps = { spaceData: Space; @@ -34,13 +35,13 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest const el = dropTargetRef.current; if (!el) return () => {}; - function handleDrop(sourceData: TabGroupSourceData) { + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { setShowDropIndicator(false); 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 { @@ -54,7 +55,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest function onDrop(args: ElementDropTargetEventBasePayload) { const sourceData = args.source.data as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, false); } function onExternalDrop(args: ExternalDropTargetEventBasePayload) { @@ -65,7 +66,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, true); } catch { // Invalid data from external source } @@ -100,7 +101,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest dropTargetForExternal({ element: el, canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_MIME_TYPE); + return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + spaceData.profileId); }, onDrop: onExternalDrop, onDragEnter: onChange, 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 c38a56d98..396b5f7b3 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 @@ -206,6 +206,7 @@ export type TabGroupSourceData = { // MIME type for cross-window tab drag data const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; +const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; export function SidebarTabGroups({ tabGroup, @@ -240,7 +241,7 @@ export function SidebarTabGroups({ setClosestEdge(edge); } - function handleDrop(sourceData: TabGroupSourceData, closestEdgeOfTarget: Edge | null) { + function handleDrop(sourceData: TabGroupSourceData, closestEdgeOfTarget: Edge | null, isExternal: boolean) { setClosestEdge(null); const sourceTabId = sourceData.primaryTabId; @@ -253,7 +254,7 @@ 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 { @@ -268,7 +269,7 @@ export function SidebarTabGroups({ function onDrop(args: ElementDropTargetEventBasePayload) { const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); const sourceData = args.source.data as TabGroupSourceData; - handleDrop(sourceData, closestEdgeOfTarget); + handleDrop(sourceData, closestEdgeOfTarget, false); } function onExternalDrop(args: ExternalDropTargetEventBasePayload) { @@ -280,7 +281,7 @@ export function SidebarTabGroups({ try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - handleDrop(sourceData, closestEdgeOfTarget); + handleDrop(sourceData, closestEdgeOfTarget, true); } catch { // Invalid data from external source } @@ -319,7 +320,10 @@ export function SidebarTabGroups({ spaceId: tabGroup.spaceId, position: position }; - return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(data) }; + return { + [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), + [TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId]: "" + }; } }), @@ -353,7 +357,7 @@ export function SidebarTabGroups({ element: el, getData: ({ input, element }) => edgeData({ input, element }), canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_MIME_TYPE); + return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId); }, onDrop: onExternalDrop, onDragEnter: onExternalChange, 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 a7869b9dd..d31a5ac9b 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 @@ -12,6 +12,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; // MIME type for cross-window tab drag data const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; +const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; type SpaceButtonProps = { space: Space; @@ -67,7 +68,7 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { function handleDrop(sourceData: TabGroupSourceData) { stopDragging(); - // Validate profile compatibility (needed for external drops where canDrop can't read data) + // Validate profile compatibility if (sourceData.profileId !== space.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet return; @@ -90,11 +91,8 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { 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; + // Does not support moving tabs between profiles + if (sourceData.profileId !== space.profileId) return false; // Don't allow dropping on the space the tab is already in if (sourceData.spaceId === space.id) return false; @@ -113,7 +111,8 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { dropTargetForExternal({ element, canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_MIME_TYPE); + // Profile-specific MIME type lets us check compatibility during drag + return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + space.profileId); }, onDragEnter: startDragging, onDrag: startDragging, From f1a17d40c8ac5127910e2fd62d86babe3e5b47c2 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 2 Mar 2026 23:03:42 +0000 Subject: [PATCH 06/10] fix: space-switcher same-space cross-window drop bug and extract shared MIME constants - Add isExternal parameter to handleDrop in both space-switcher files so same-space cross-window drops call moveTabToWindowSpace instead of being silently discarded - Extract TAB_GROUP_MIME_TYPE and TAB_GROUP_PROFILE_MIME_PREFIX to a shared module (lib/tab-drag-mime.ts) to eliminate duplication across 6 files --- .../_components/space-switcher.tsx | 15 ++++++--------- .../_components/tab-drop-target.tsx | 5 +---- .../browser-sidebar/_components/tab-group.tsx | 8 +------- .../sidebar/content/sidebar-tab-drop-target.tsx | 5 +---- .../sidebar/content/sidebar-tab-groups.tsx | 5 +---- .../old-browser-ui/sidebar/spaces-switcher.tsx | 15 ++++++--------- src/renderer/src/lib/tab-drag-mime.ts | 8 ++++++++ 7 files changed, 24 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/lib/tab-drag-mime.ts 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 11eab89c8..b032bbb1a 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 @@ -8,10 +8,7 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element 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"; - -// MIME type for cross-window tab drag data -const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; -const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; +import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; // Layout constants (px) const ICON_SIZE = 28; // size-7 @@ -70,7 +67,7 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { removeDraggingTimeout(); } - function handleDrop(sourceData: TabGroupSourceData) { + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { stopDragging(); // Validate profile compatibility @@ -79,8 +76,8 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { return; } - // Don't allow dropping on the space the tab is already in - if (sourceData.spaceId === space.id) { + // For external (cross-window) drops, always move via IPC even if same space + if (!isExternal && sourceData.spaceId === space.id) { return; } @@ -109,7 +106,7 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { onDragLeave: stopDragging, onDrop: (args) => { const sourceData = args.source.data as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, false); } }), @@ -130,7 +127,7 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, true); } catch { // Invalid data from external source } 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 dd009eb8c..b5359657f 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 @@ -11,10 +11,7 @@ import { ExternalDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; - -// MIME type for cross-window tab drag data -const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; -const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; +import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; type TabDropTargetProps = { spaceData: Space; 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 0db86d222..8e77641c5 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 @@ -17,13 +17,7 @@ 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"; - -// MIME type for cross-window tab drag data -const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; -// Profile-specific MIME type prefix — used during external drag to check -// profile compatibility without reading the actual payload (which is only -// available on drop). Format: "application/x-flow-tab-group-profile-{profileId}" -const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; +import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; // --- Types --- // 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 4e6db8a93..d4d68d8da 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 @@ -11,10 +11,7 @@ import { ExternalDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; - -// MIME type for cross-window tab drag data -const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; -const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; +import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; type SidebarTabDropTargetProps = { spaceData: Space; 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 396b5f7b3..5ba664e10 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 @@ -19,6 +19,7 @@ import { attachClosestEdge, extractClosestEdge, Edge } from "@atlaskit/pragmatic 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 { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; const MotionSidebarMenuButton = motion(SidebarMenuButton); @@ -204,10 +205,6 @@ export type TabGroupSourceData = { position: number; }; -// MIME type for cross-window tab drag data -const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; -const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; - export function SidebarTabGroups({ tabGroup, isFocused, 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 d31a5ac9b..4eac6ecc1 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 @@ -9,10 +9,7 @@ import type { TabGroupSourceData } from "@/components/old-browser-ui/sidebar/con 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"; - -// MIME type for cross-window tab drag data -const TAB_GROUP_MIME_TYPE = "application/x-flow-tab-group"; -const TAB_GROUP_PROFILE_MIME_PREFIX = "application/x-flow-tab-group-profile-"; +import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; type SpaceButtonProps = { space: Space; @@ -65,7 +62,7 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { removeDraggingTimeout(); } - function handleDrop(sourceData: TabGroupSourceData) { + function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { stopDragging(); // Validate profile compatibility @@ -74,8 +71,8 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { return; } - // Don't allow dropping on the space the tab is already in - if (sourceData.spaceId === space.id) { + // For external (cross-window) drops, always move via IPC even if same space + if (!isExternal && sourceData.spaceId === space.id) { return; } @@ -104,7 +101,7 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { onDragLeave: stopDragging, onDrop: (args) => { const sourceData = args.source.data as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, false); } }), @@ -125,7 +122,7 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - handleDrop(sourceData); + handleDrop(sourceData, true); } catch { // Invalid data from external source } 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..ece52cae6 --- /dev/null +++ b/src/renderer/src/lib/tab-drag-mime.ts @@ -0,0 +1,8 @@ +// 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-"; From 801b1bc9de2cd9876973888cc246dcd35082f62b Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 4 Mar 2026 14:08:05 +0000 Subject: [PATCH 07/10] feat: add drag token to prevent fake page drags --- src/main/ipc/app/drag-token.ts | 8 +++++++ src/main/ipc/index.ts | 1 + src/preload/index.ts | 7 +++++- .../_components/space-switcher.tsx | 2 ++ .../_components/tab-drop-target.tsx | 2 ++ .../browser-sidebar/_components/tab-group.tsx | 5 ++++- .../content/sidebar-tab-drop-target.tsx | 2 ++ .../sidebar/content/sidebar-tab-groups.tsx | 5 ++++- .../sidebar/spaces-switcher.tsx | 2 ++ src/renderer/src/lib/tab-drag-token.ts | 22 +++++++++++++++++++ src/shared/flow/interfaces/app/app.ts | 6 +++++ 11 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 src/main/ipc/app/drag-token.ts create mode 100644 src/renderer/src/lib/tab-drag-token.ts diff --git a/src/main/ipc/app/drag-token.ts b/src/main/ipc/app/drag-token.ts new file mode 100644 index 000000000..d66ec2ada --- /dev/null +++ b/src/main/ipc/app/drag-token.ts @@ -0,0 +1,8 @@ +import { ipcMain } from "electron"; +import { randomUUID } from "crypto"; + +// Generated once per app session. Included in external drag-and-drop payloads +// so that drop targets can reject spoofed drags from websites or other apps. +const SESSION_DRAG_TOKEN = randomUUID(); + +ipcMain.handle("app:get-drag-token", () => SESSION_DRAG_TOKEN); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 1b34a0ccc..dba582043 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,5 +1,6 @@ // App APIs import "@/ipc/app/app"; +import "@/ipc/app/drag-token"; import "@/ipc/app/extensions"; import "@/ipc/app/updates"; import "@/ipc/app/shortcuts"; diff --git a/src/preload/index.ts b/src/preload/index.ts index 928371af6..074767849 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -658,6 +658,10 @@ const appAPI: FlowAppAPI = { return ipcRenderer.invoke("app:get-default-browser"); }, + getDragToken: async () => { + return ipcRenderer.invoke("app:get-drag-token"); + }, + // Special Exception: This is allowed for all pages everywhere. getPlatform: () => { return process.platform; @@ -840,7 +844,8 @@ const shortcutsAPI: FlowShortcutsAPI = { const flowAPI: typeof flow = { // App APIs app: wrapAPI(appAPI, "app", { - getPlatform: "all" + getPlatform: "all", + getDragToken: "browser" }), windows: wrapAPI(windowsAPI, "app"), extensions: wrapAPI(extensionsAPI, "app"), 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 b032bbb1a..4a8085d79 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 @@ -9,6 +9,7 @@ import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/externa import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { AnimatePresence, motion } from "motion/react"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; +import { getSessionDragToken } from "@/lib/tab-drag-token"; // Layout constants (px) const ICON_SIZE = 28; // size-7 @@ -127,6 +128,7 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { try { const sourceData = JSON.parse(raw) as TabGroupSourceData; + if (sourceData.sessionToken !== getSessionDragToken()) return; handleDrop(sourceData, true); } catch { // Invalid data from external source 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 b5359657f..7b92b1744 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 @@ -12,6 +12,7 @@ import { } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; +import { getSessionDragToken } from "@/lib/tab-drag-token"; type TabDropTargetProps = { spaceData: Space; @@ -62,6 +63,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } try { const sourceData = JSON.parse(raw) as TabGroupSourceData; + if (sourceData.sessionToken !== getSessionDragToken()) return; handleDrop(sourceData, true); } catch { // Invalid data from external source 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 8e77641c5..21605fce4 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 @@ -18,6 +18,7 @@ import { attachClosestEdge, extractClosestEdge, Edge } from "@atlaskit/pragmatic import type { Input } from "@atlaskit/pragmatic-drag-and-drop/types"; import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_components/drop-indicator"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; +import { getSessionDragToken } from "@/lib/tab-drag-token"; // --- Types --- // @@ -28,6 +29,7 @@ export type TabGroupSourceData = { profileId: string; spaceId: string; position: number; + sessionToken?: string; }; // --- SidebarTab (memoized) --- // @@ -295,7 +297,8 @@ export const TabGroup = memo( primaryTabId: primaryTabId, profileId: tabGroup.profileId, spaceId: tabGroup.spaceId, - position: position + position: position, + sessionToken: getSessionDragToken() }; return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), 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 d4d68d8da..4abf38a42 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 @@ -12,6 +12,7 @@ import { } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; +import { getSessionDragToken } from "@/lib/tab-drag-token"; type SidebarTabDropTargetProps = { spaceData: Space; @@ -63,6 +64,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest try { const sourceData = JSON.parse(raw) as TabGroupSourceData; + if (sourceData.sessionToken !== getSessionDragToken()) return; handleDrop(sourceData, true); } catch { // Invalid data from external source 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 5ba664e10..643ae30e3 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 @@ -20,6 +20,7 @@ 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 { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; +import { getSessionDragToken } from "@/lib/tab-drag-token"; const MotionSidebarMenuButton = motion(SidebarMenuButton); @@ -203,6 +204,7 @@ export type TabGroupSourceData = { profileId: string; spaceId: string; position: number; + sessionToken?: string; }; export function SidebarTabGroups({ @@ -315,7 +317,8 @@ export function SidebarTabGroups({ primaryTabId: tabGroup.tabs[0].id, profileId: tabGroup.profileId, spaceId: tabGroup.spaceId, - position: position + position: position, + sessionToken: getSessionDragToken() }; return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), 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 4eac6ecc1..bce6d21f6 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 @@ -10,6 +10,7 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; +import { getSessionDragToken } from "@/lib/tab-drag-token"; type SpaceButtonProps = { space: Space; @@ -122,6 +123,7 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { try { const sourceData = JSON.parse(raw) as TabGroupSourceData; + if (sourceData.sessionToken !== getSessionDragToken()) return; handleDrop(sourceData, true); } catch { // Invalid data from external source diff --git a/src/renderer/src/lib/tab-drag-token.ts b/src/renderer/src/lib/tab-drag-token.ts new file mode 100644 index 000000000..e1ad181ad --- /dev/null +++ b/src/renderer/src/lib/tab-drag-token.ts @@ -0,0 +1,22 @@ +// Eagerly fetch the session drag token from the main process once at module +// load time. Because getInitialDataForExternal() is called synchronously when +// a drag starts, the token must be available synchronously by that point. +// +// In practice the IPC round-trip resolves long before the user can begin +// dragging, so cachedToken will always be set. If for some reason it is not +// yet resolved, getSessionDragToken() returns undefined and the receiving +// window will reject the payload — a safe fail-closed behavior. + +let cachedToken: string | undefined; + +flow.app.getDragToken().then((token) => { + cachedToken = token; +}); + +/** + * Returns the session drag token synchronously once it has been fetched, + * or undefined if the fetch has not yet completed. + */ +export function getSessionDragToken(): string | undefined { + return cachedToken; +} diff --git a/src/shared/flow/interfaces/app/app.ts b/src/shared/flow/interfaces/app/app.ts index 70f0d492c..00d653eff 100644 --- a/src/shared/flow/interfaces/app/app.ts +++ b/src/shared/flow/interfaces/app/app.ts @@ -32,4 +32,10 @@ export interface FlowAppAPI { * Gets the default browser */ getDefaultBrowser: () => Promise; + + /** + * Returns the session-scoped drag token used to authenticate cross-window + * tab drag-and-drop payloads. Only accessible to browser UI pages. + */ + getDragToken: () => Promise; } From 7f488b4b44d984823aba6edd16f94c4fa57abd87 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 4 Mar 2026 22:46:42 +0000 Subject: [PATCH 08/10] feat: better drag tokens --- .../windows-controller/types/browser.ts | 2 + src/main/ipc/app/drag-token.ts | 40 +++++++-- src/main/ipc/browser/tabs.ts | 82 +++++++++++-------- src/main/ipc/index.ts | 1 - src/preload/index.ts | 15 ++-- .../_components/space-switcher.tsx | 5 +- .../_components/tab-drop-target.tsx | 5 +- .../browser-sidebar/_components/tab-group.tsx | 43 +++++----- .../content/sidebar-tab-drop-target.tsx | 5 +- .../sidebar/content/sidebar-tab-groups.tsx | 13 +-- .../sidebar/spaces-switcher.tsx | 5 +- src/renderer/src/lib/tab-drag-token.ts | 22 ----- src/shared/flow/interfaces/app/app.ts | 6 -- src/shared/flow/interfaces/browser/tabs.ts | 13 ++- 14 files changed, 143 insertions(+), 114 deletions(-) delete mode 100644 src/renderer/src/lib/tab-drag-token.ts diff --git a/src/main/controllers/windows-controller/types/browser.ts b/src/main/controllers/windows-controller/types/browser.ts index 68d305240..056e606e8 100644 --- a/src/main/controllers/windows-controller/types/browser.ts +++ b/src/main/controllers/windows-controller/types/browser.ts @@ -85,6 +85,8 @@ export class BrowserWindow extends BaseWindow { backgroundMaterial: "none" // on Windows (Disabled as it interferes with rounded corners) }); + browserWindow.webContents.openDevTools({ mode: "detach" }); + // Wait for default session to be ready sessionsController.whenDefaultSessionReady().then(() => { // Load the correct UI diff --git a/src/main/ipc/app/drag-token.ts b/src/main/ipc/app/drag-token.ts index d66ec2ada..b8556230f 100644 --- a/src/main/ipc/app/drag-token.ts +++ b/src/main/ipc/app/drag-token.ts @@ -1,8 +1,36 @@ -import { ipcMain } from "electron"; -import { randomUUID } from "crypto"; +// 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. -// Generated once per app session. Included in external drag-and-drop payloads -// so that drop targets can reject spoofed drags from websites or other apps. -const SESSION_DRAG_TOKEN = randomUUID(); +interface ActiveDragToken { + token: string; + tabId: number; +} -ipcMain.handle("app:get-drag-token", () => SESSION_DRAG_TOKEN); +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 cc5530c97..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,50 +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 location before move (for normalizing after) - const sourceSpaceId = tab.spaceId; - const sourceWindowId = tab.getWindow()?.id; + 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 the target space - tabsController.normalizePositions(window.id, spaceId); + // Move all tabs in the group to the new space + for (const targetTab of targetTabs) { + targetTab.setSpace(spaceId); + targetTab.setWindow(window); - // 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); - } + if (newPosition !== undefined) { + targetTab.updateStateProperty("position", newPosition); + } + } - tabsController.setActiveTab(tab); - return true; -}); + // 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/main/ipc/index.ts b/src/main/ipc/index.ts index dba582043..1b34a0ccc 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,6 +1,5 @@ // App APIs import "@/ipc/app/app"; -import "@/ipc/app/drag-token"; import "@/ipc/app/extensions"; import "@/ipc/app/updates"; import "@/ipc/app/shortcuts"; diff --git a/src/preload/index.ts b/src/preload/index.ts index 074767849..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. @@ -658,10 +662,6 @@ const appAPI: FlowAppAPI = { return ipcRenderer.invoke("app:get-default-browser"); }, - getDragToken: async () => { - return ipcRenderer.invoke("app:get-drag-token"); - }, - // Special Exception: This is allowed for all pages everywhere. getPlatform: () => { return process.platform; @@ -844,8 +844,7 @@ const shortcutsAPI: FlowShortcutsAPI = { const flowAPI: typeof flow = { // App APIs app: wrapAPI(appAPI, "app", { - getPlatform: "all", - getDragToken: "browser" + getPlatform: "all" }), windows: wrapAPI(windowsAPI, "app"), extensions: wrapAPI(extensionsAPI, "app"), 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 4a8085d79..a815bb9b3 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 @@ -9,7 +9,6 @@ import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/externa import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { AnimatePresence, motion } from "motion/react"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; -import { getSessionDragToken } from "@/lib/tab-drag-token"; // Layout constants (px) const ICON_SIZE = 28; // size-7 @@ -84,7 +83,7 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { // Move the tab to this space (no specific position — append to end) const sourceTabId = sourceData.primaryTabId; - flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); + flow.tabs.moveTabToWindowSpace(sourceTabId, space.id, undefined, sourceData.dragToken); } return combine( @@ -128,7 +127,7 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (sourceData.sessionToken !== getSessionDragToken()) return; + if (!sourceData.dragToken) return; handleDrop(sourceData, true); } catch { // Invalid data from external source 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 7b92b1744..1a90dcda8 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 @@ -12,7 +12,6 @@ import { } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; -import { getSessionDragToken } from "@/lib/tab-drag-token"; type TabDropTargetProps = { spaceData: Space; @@ -43,7 +42,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } 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); @@ -63,7 +62,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (sourceData.sessionToken !== getSessionDragToken()) return; + if (!sourceData.dragToken) return; handleDrop(sourceData, true); } catch { // Invalid data from external source 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 21605fce4..b4b3c6276 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 @@ -18,7 +18,6 @@ import { attachClosestEdge, extractClosestEdge, Edge } from "@atlaskit/pragmatic import type { Input } from "@atlaskit/pragmatic-drag-and-drop/types"; import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_components/drop-indicator"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; -import { getSessionDragToken } from "@/lib/tab-drag-token"; // --- Types --- // @@ -29,7 +28,7 @@ export type TabGroupSourceData = { profileId: string; spaceId: string; position: number; - sessionToken?: string; + dragToken?: string; }; // --- SidebarTab (memoized) --- // @@ -238,7 +237,12 @@ export const TabGroup = memo( 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); @@ -260,6 +264,7 @@ export const TabGroup = memo( try { const sourceData = JSON.parse(raw) as TabGroupSourceData; + if (!sourceData.dragToken) return; handleDrop(sourceData, closestEdgeOfTarget, true); } catch { // Invalid data from external source @@ -276,29 +281,29 @@ export const TabGroup = memo( } ); + function generateBasicTabGroupSourceData(): TabGroupSourceData { + return { + type: "tab-group", + tabGroupId: tabGroup.id, + primaryTabId, + profileId: tabGroup.profileId, + spaceId: tabGroup.spaceId, + position + }; + } + return combine( 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; + return generateBasicTabGroupSourceData(); }, getInitialDataForExternal: () => { + const dragToken = crypto.randomUUID(); + flow.tabs.registerDragToken(dragToken, primaryTabId); const data: TabGroupSourceData = { - type: "tab-group", - tabGroupId: tabGroup.id, - primaryTabId: primaryTabId, - profileId: tabGroup.profileId, - spaceId: tabGroup.spaceId, - position: position, - sessionToken: getSessionDragToken() + ...generateBasicTabGroupSourceData(), + dragToken }; return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), 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 4abf38a42..cc54d7acc 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 @@ -12,7 +12,6 @@ import { } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; -import { getSessionDragToken } from "@/lib/tab-drag-token"; type SidebarTabDropTargetProps = { spaceData: Space; @@ -44,7 +43,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest // 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); @@ -64,7 +63,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (sourceData.sessionToken !== getSessionDragToken()) return; + if (!sourceData.dragToken) return; handleDrop(sourceData, true); } catch { // Invalid data from external source 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 643ae30e3..95f0924bf 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 @@ -20,7 +20,6 @@ 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 { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; -import { getSessionDragToken } from "@/lib/tab-drag-token"; const MotionSidebarMenuButton = motion(SidebarMenuButton); @@ -204,7 +203,7 @@ export type TabGroupSourceData = { profileId: string; spaceId: string; position: number; - sessionToken?: string; + dragToken?: string; }; export function SidebarTabGroups({ @@ -258,7 +257,7 @@ export function SidebarTabGroups({ // 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, sourceData.dragToken); } } else if (newPos !== undefined) { moveTab(sourceTabId, newPos); @@ -280,6 +279,7 @@ export function SidebarTabGroups({ try { const sourceData = JSON.parse(raw) as TabGroupSourceData; + if (!sourceData.dragToken) return; handleDrop(sourceData, closestEdgeOfTarget, true); } catch { // Invalid data from external source @@ -311,14 +311,17 @@ export function SidebarTabGroups({ return data; }, getInitialDataForExternal: () => { + const primaryTabId = tabGroup.tabs[0].id; + const dragToken = crypto.randomUUID(); + flow.tabs.registerDragToken(dragToken, primaryTabId); const data: TabGroupSourceData = { type: "tab-group", tabGroupId: tabGroup.id, - primaryTabId: tabGroup.tabs[0].id, + primaryTabId, profileId: tabGroup.profileId, spaceId: tabGroup.spaceId, position: position, - sessionToken: getSessionDragToken() + dragToken }; return { [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), 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 bce6d21f6..9e233688f 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 @@ -10,7 +10,6 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; -import { getSessionDragToken } from "@/lib/tab-drag-token"; type SpaceButtonProps = { space: Space; @@ -79,7 +78,7 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { // Move the tab to this space (no specific position — append to end) const sourceTabId = sourceData.primaryTabId; - flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); + flow.tabs.moveTabToWindowSpace(sourceTabId, space.id, undefined, sourceData.dragToken); } return combine( @@ -123,7 +122,7 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { try { const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (sourceData.sessionToken !== getSessionDragToken()) return; + if (!sourceData.dragToken) return; handleDrop(sourceData, true); } catch { // Invalid data from external source diff --git a/src/renderer/src/lib/tab-drag-token.ts b/src/renderer/src/lib/tab-drag-token.ts deleted file mode 100644 index e1ad181ad..000000000 --- a/src/renderer/src/lib/tab-drag-token.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Eagerly fetch the session drag token from the main process once at module -// load time. Because getInitialDataForExternal() is called synchronously when -// a drag starts, the token must be available synchronously by that point. -// -// In practice the IPC round-trip resolves long before the user can begin -// dragging, so cachedToken will always be set. If for some reason it is not -// yet resolved, getSessionDragToken() returns undefined and the receiving -// window will reject the payload — a safe fail-closed behavior. - -let cachedToken: string | undefined; - -flow.app.getDragToken().then((token) => { - cachedToken = token; -}); - -/** - * Returns the session drag token synchronously once it has been fetched, - * or undefined if the fetch has not yet completed. - */ -export function getSessionDragToken(): string | undefined { - return cachedToken; -} diff --git a/src/shared/flow/interfaces/app/app.ts b/src/shared/flow/interfaces/app/app.ts index 00d653eff..70f0d492c 100644 --- a/src/shared/flow/interfaces/app/app.ts +++ b/src/shared/flow/interfaces/app/app.ts @@ -32,10 +32,4 @@ export interface FlowAppAPI { * Gets the default browser */ getDefaultBrowser: () => Promise; - - /** - * Returns the session-scoped drag token used to authenticate cross-window - * tab drag-and-drop payloads. Only accessible to browser UI pages. - */ - getDragToken: () => Promise; } 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 From fe9a77966dc0409391dce55ecde1341eff60c739 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 4 Mar 2026 23:09:50 +0000 Subject: [PATCH 09/10] refactor: organisation --- .../_components/space-switcher.tsx | 43 +++---- .../_components/tab-drop-target.tsx | 37 ++---- .../browser-sidebar/_components/tab-group.tsx | 57 +++------ .../content/sidebar-tab-drop-target.tsx | 39 ++----- .../sidebar/content/sidebar-tab-groups.tsx | 110 +++++++----------- .../sidebar/spaces-switcher.tsx | 43 +++---- src/renderer/src/lib/tab-drag-mime.ts | 81 +++++++++++++ 7 files changed, 194 insertions(+), 216 deletions(-) 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 a815bb9b3..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,12 +3,16 @@ 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"; -import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; // Layout constants (px) const ICON_SIZE = 28; // size-7 @@ -89,18 +93,11 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { return combine( dropTargetForElements({ element, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") return false; - - // Does not support moving tabs between profiles - if (sourceData.profileId !== space.profileId) return false; - - // Don't allow dropping on the space the tab is already in - if (sourceData.spaceId === space.id) return false; - - return true; - }, + canDrop: (args) => + canDropElementTabGroup(args.source.data, { + profileId: space.profileId, + excludeSpaceId: space.id + }), onDragEnter: startDragging, onDrag: startDragging, onDragLeave: stopDragging, @@ -112,26 +109,16 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { dropTargetForExternal({ element, - canDrop: (args) => { - // Profile-specific MIME type lets us check compatibility during drag - return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + space.profileId); - }, + canDrop: (args) => canDropExternalTabGroup(args.source.types, space.profileId), onDragEnter: startDragging, onDrag: startDragging, onDragLeave: stopDragging, onDrop: (args) => { stopDragging(); - const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); - if (!raw) return; - - try { - const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (!sourceData.dragToken) return; - handleDrop(sourceData, true); - } catch { - // Invalid data from external source - } + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); } }) ); 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 1a90dcda8..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"; @@ -11,7 +16,6 @@ import { ExternalDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; -import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; type TabDropTargetProps = { spaceData: Space; @@ -57,16 +61,9 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } function onExternalDrop(args: ExternalDropTargetEventBasePayload) { setShowDropIndicator(false); - const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); - if (!raw) return; - - try { - const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (!sourceData.dragToken) return; - handleDrop(sourceData, true); - } catch { - // Invalid data from external source - } + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); } function onChange() { @@ -76,17 +73,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } return combine( 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; - }, + canDrop: (args) => canDropElementTabGroup(args.source.data, { profileId: spaceData.profileId }), onDrop: onDrop, onDragEnter: onChange, onDrag: onChange, @@ -95,9 +82,7 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } dropTargetForExternal({ element: el, - canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + spaceData.profileId); - }, + canDrop: (args) => canDropExternalTabGroup(args.source.types, spaceData.profileId), onDrop: onExternalDrop, onDragEnter: onChange, onDrag: onChange, 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 b4b3c6276..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 @@ -17,19 +17,15 @@ 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 { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; - -// --- Types --- // +import { + TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop, + makeTabGroupExternalPayload +} from "@/lib/tab-drag-mime"; -export type TabGroupSourceData = { - type: "tab-group"; - tabGroupId: string; - primaryTabId: number; - profileId: string; - spaceId: string; - position: number; - dragToken?: string; -}; +export type { TabGroupSourceData }; // --- SidebarTab (memoized) --- // @@ -259,16 +255,9 @@ export const TabGroup = memo( const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); setClosestEdge(null); - const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); - if (!raw) return; - - try { - const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (!sourceData.dragToken) return; - handleDrop(sourceData, closestEdgeOfTarget, true); - } catch { - // Invalid data from external source - } + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, closestEdgeOfTarget, true); } const edgeData = ({ input, element }: { input: Input; element: Element }) => @@ -305,10 +294,7 @@ export const TabGroup = memo( ...generateBasicTabGroupSourceData(), dragToken }; - return { - [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), - [TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId]: "" - }; + return makeTabGroupExternalPayload(data); } }), @@ -316,17 +302,10 @@ export const TabGroup = memo( element: el, getData: ({ input, element }) => edgeData({ input, element }), canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") { - return false; - } - if (sourceData.tabGroupId === tabGroup.id) { - return false; - } - if (sourceData.profileId !== tabGroup.profileId) { - return false; - } - return true; + return canDropElementTabGroup(args.source.data, { + profileId: tabGroup.profileId, + excludeTabGroupId: tabGroup.id + }); }, onDrop: onDrop, onDragEnter: onChange, @@ -337,9 +316,7 @@ export const TabGroup = memo( dropTargetForExternal({ element: el, getData: ({ input, element }) => edgeData({ input, element }), - canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId); - }, + canDrop: (args) => canDropExternalTabGroup(args.source.types, tabGroup.profileId), onDrop: onExternalDrop, onDragEnter: onExternalChange, onDrag: onExternalChange, 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 cc54d7acc..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"; @@ -11,7 +16,6 @@ import { ExternalDropTargetEventBasePayload } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; -import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; type SidebarTabDropTargetProps = { spaceData: Space; @@ -58,16 +62,9 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest function onExternalDrop(args: ExternalDropTargetEventBasePayload) { setShowDropIndicator(false); - const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); - if (!raw) return; - - try { - const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (!sourceData.dragToken) return; - handleDrop(sourceData, true); - } catch { - // Invalid data from external source - } + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); } function onChange() { @@ -77,19 +74,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest return combine( 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; - }, + canDrop: (args) => canDropElementTabGroup(args.source.data, { profileId: spaceData.profileId }), onDrop: onDrop, onDragEnter: onChange, onDrag: onChange, @@ -98,9 +83,7 @@ export function SidebarTabDropTarget({ spaceData, isSpaceLight, moveTab, biggest dropTargetForExternal({ element: el, - canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + spaceData.profileId); - }, + canDrop: (args) => canDropExternalTabGroup(args.source.types, spaceData.profileId), onDrop: onExternalDrop, onDragEnter: onChange, onDrag: onChange, 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 95f0924bf..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 @@ -19,7 +19,15 @@ import { attachClosestEdge, extractClosestEdge, Edge } from "@atlaskit/pragmatic 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 { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; +import { + TabGroupSourceData, + canDropExternalTabGroup, + canDropElementTabGroup, + parseExternalTabGroupDrop, + makeTabGroupExternalPayload +} from "@/lib/tab-drag-mime"; + +export type { TabGroupSourceData }; const MotionSidebarMenuButton = motion(SidebarMenuButton); @@ -196,16 +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; - dragToken?: string; -}; - export function SidebarTabGroups({ tabGroup, isFocused, @@ -225,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 () => {}; @@ -239,6 +242,17 @@ export function SidebarTabGroups({ 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); @@ -256,8 +270,12 @@ export function SidebarTabGroups({ 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, sourceData.dragToken); + flow.tabs.moveTabToWindowSpace( + sourceTabId, + tabGroup.spaceId, + newPos, + isExternal ? sourceData.dragToken : undefined + ); } } else if (newPos !== undefined) { moveTab(sourceTabId, newPos); @@ -274,16 +292,9 @@ export function SidebarTabGroups({ const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); setClosestEdge(null); - const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); - if (!raw) return; - - try { - const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (!sourceData.dragToken) return; - handleDrop(sourceData, closestEdgeOfTarget, true); - } catch { - // Invalid data from external source - } + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, closestEdgeOfTarget, true); } const edgeData = ({ input, element }: { input: Input; element: Element }) => @@ -299,57 +310,26 @@ export function SidebarTabGroups({ return combine( 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; - }, + getInitialData: () => generateBasicTabGroupSourceData(), getInitialDataForExternal: () => { - const primaryTabId = tabGroup.tabs[0].id; const dragToken = crypto.randomUUID(); flow.tabs.registerDragToken(dragToken, primaryTabId); const data: TabGroupSourceData = { - type: "tab-group", - tabGroupId: tabGroup.id, - primaryTabId, - profileId: tabGroup.profileId, - spaceId: tabGroup.spaceId, - position: position, + ...generateBasicTabGroupSourceData(), dragToken }; - return { - [TAB_GROUP_MIME_TYPE]: JSON.stringify(data), - [TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId]: "" - }; + return makeTabGroupExternalPayload(data); } }), dropTargetForElements({ element: el, getData: ({ input, element }) => edgeData({ input, element }), - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") { - return false; - } - - if (sourceData.tabGroupId === tabGroup.id) { - return false; - } - - if (sourceData.profileId !== tabGroup.profileId) { - // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet - return false; - } - - return true; - }, + canDrop: (args) => + canDropElementTabGroup(args.source.data, { + profileId: tabGroup.profileId, + excludeTabGroupId: tabGroup.id + }), onDrop: onDrop, onDragEnter: onChange, onDrag: onChange, @@ -359,16 +339,14 @@ export function SidebarTabGroups({ dropTargetForExternal({ element: el, getData: ({ input, element }) => edgeData({ input, element }), - canDrop: (args) => { - return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + tabGroup.profileId); - }, + canDrop: (args) => canDropExternalTabGroup(args.source.types, tabGroup.profileId), onDrop: onExternalDrop, onDragEnter: onExternalChange, onDrag: onExternalChange, onDragLeave: () => setClosestEdge(null) }) ); - }, [moveTab, tabGroup.id, position, tabGroup.tabs, tabGroup.spaceId, tabGroup.profileId]); + }, [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 9e233688f..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,11 +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"; -import { TAB_GROUP_MIME_TYPE, TAB_GROUP_PROFILE_MIME_PREFIX } from "@/lib/tab-drag-mime"; type SpaceButtonProps = { space: Space; @@ -84,18 +88,11 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { return combine( dropTargetForElements({ element, - canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") return false; - - // Does not support moving tabs between profiles - if (sourceData.profileId !== space.profileId) return false; - - // Don't allow dropping on the space the tab is already in - if (sourceData.spaceId === space.id) return false; - - return true; - }, + canDrop: (args) => + canDropElementTabGroup(args.source.data, { + profileId: space.profileId, + excludeSpaceId: space.id + }), onDragEnter: startDragging, onDrag: startDragging, onDragLeave: stopDragging, @@ -107,26 +104,16 @@ function SpaceButton({ space, isActive }: SpaceButtonProps) { dropTargetForExternal({ element, - canDrop: (args) => { - // Profile-specific MIME type lets us check compatibility during drag - return args.source.types.includes(TAB_GROUP_PROFILE_MIME_PREFIX + space.profileId); - }, + canDrop: (args) => canDropExternalTabGroup(args.source.types, space.profileId), onDragEnter: startDragging, onDrag: startDragging, onDragLeave: stopDragging, onDrop: (args) => { stopDragging(); - const raw = args.source.getStringData(TAB_GROUP_MIME_TYPE); - if (!raw) return; - - try { - const sourceData = JSON.parse(raw) as TabGroupSourceData; - if (!sourceData.dragToken) return; - handleDrop(sourceData, true); - } catch { - // Invalid data from external source - } + const sourceData = parseExternalTabGroupDrop(args.source); + if (!sourceData) return; + handleDrop(sourceData, true); } }) ); diff --git a/src/renderer/src/lib/tab-drag-mime.ts b/src/renderer/src/lib/tab-drag-mime.ts index ece52cae6..b60cd11a7 100644 --- a/src/renderer/src/lib/tab-drag-mime.ts +++ b/src/renderer/src/lib/tab-drag-mime.ts @@ -6,3 +6,84 @@ 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]: "" + }; +} From d161adf2ef16823b942fed512b0775145726a0e1 Mon Sep 17 00:00:00 2001 From: Evan Date: Wed, 4 Mar 2026 23:10:13 +0000 Subject: [PATCH 10/10] fix --- src/main/controllers/windows-controller/types/browser.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/controllers/windows-controller/types/browser.ts b/src/main/controllers/windows-controller/types/browser.ts index 056e606e8..68d305240 100644 --- a/src/main/controllers/windows-controller/types/browser.ts +++ b/src/main/controllers/windows-controller/types/browser.ts @@ -85,8 +85,6 @@ export class BrowserWindow extends BaseWindow { backgroundMaterial: "none" // on Windows (Disabled as it interferes with rounded corners) }); - browserWindow.webContents.openDevTools({ mode: "detach" }); - // Wait for default session to be ready sessionsController.whenDefaultSessionReady().then(() => { // Load the correct UI