-
Notifications
You must be signed in to change notification settings - Fork 56
feat: cross-window tab drag-and-drop #221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fea9d88
78de3d2
5456b6a
0f3ce90
ba91f47
f1a17d4
801b1bc
7f488b4
fe9a779
d161adf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // One-use drag token for cross-window tab drag-and-drop authentication. | ||
| // | ||
| // When an external drag starts, the source renderer generates a random token | ||
| // and registers it here via IPC alongside the tab ID being dragged. Only one | ||
| // token is active at a time — registering a new one replaces any previous one. | ||
| // | ||
| // When the drop target calls tabs:move-tab-to-window-space with a dragToken, | ||
| // validateAndConsumeToken is called to verify the token matches the registered | ||
| // tab and then immediately clears it so it cannot be reused. | ||
|
|
||
| interface ActiveDragToken { | ||
| token: string; | ||
| tabId: number; | ||
| } | ||
|
|
||
| let activeToken: ActiveDragToken | null = null; | ||
|
|
||
| /** | ||
| * Registers a new one-use drag token tied to a specific tab. | ||
| * Any previously registered token is discarded. | ||
| */ | ||
| export function registerToken(token: string, tabId: number): void { | ||
| activeToken = { token, tabId }; | ||
| } | ||
|
|
||
| /** | ||
| * Validates the provided token against the active token and, if valid, | ||
| * consumes it so it cannot be used again. Returns true only if the token | ||
| * matches and is bound to the expected tab ID. | ||
| */ | ||
| export function validateAndConsumeToken(token: string, tabId: number): boolean { | ||
| if (!activeToken) return false; | ||
| if (activeToken.token !== token || activeToken.tabId !== tabId) return false; | ||
| activeToken = null; | ||
| return true; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import { tabsController } from "@/controllers/tabs-controller"; | |
| import { serializeTabForRenderer, serializeTabGroupForRenderer } from "@/saving/tabs/serialization"; | ||
| import { recentlyClosedManager } from "@/saving/tabs/recently-closed"; | ||
| import { GlanceTabGroup } from "@/controllers/tabs-controller/tab-groups/glance"; | ||
| import { registerToken, validateAndConsumeToken } from "@/ipc/app/drag-token"; | ||
|
|
||
| /** | ||
| * Attempts to restore a tab's group membership after it has been recreated. | ||
|
|
@@ -335,46 +336,63 @@ ipcMain.handle("tabs:move-tab", async (event, tabId: number, newPosition: number | |
| return true; | ||
| }); | ||
|
|
||
| ipcMain.handle("tabs:move-tab-to-window-space", async (event, tabId: number, spaceId: string, newPosition?: number) => { | ||
| const webContents = event.sender; | ||
| const window = browserWindowsController.getWindowFromWebContents(webContents); | ||
| if (!window) return false; | ||
| ipcMain.on("drag:register-token", (_event, token: string, tabId: number) => { | ||
| registerToken(token, tabId); | ||
| }); | ||
|
Comment on lines
+339
to
+341
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The handler accepts any This is currently low-risk because all browser-UI windows are equally trusted, but tightening the check makes the security model explicit: ipcMain.on("drag:register-token", (event, token: string, tabId: number) => {
const webContents = event.sender;
const senderWindow = browserWindowsController.getWindowFromWebContents(webContents);
if (!senderWindow) return;
// Ensure the tab being registered belongs to the sender's window
const tab = tabsController.getTabById(tabId);
if (!tab || tab.getWindow()?.id !== senderWindow.id) return;
registerToken(token, tabId);
}); |
||
|
|
||
| const tab = tabsController.getTabById(tabId); | ||
| if (!tab) return false; | ||
| ipcMain.handle( | ||
| "tabs:move-tab-to-window-space", | ||
| async (event, tabId: number, spaceId: string, newPosition?: number, dragToken?: string) => { | ||
| const webContents = event.sender; | ||
| const window = browserWindowsController.getWindowFromWebContents(webContents); | ||
| if (!window) return false; | ||
|
|
||
| const space = await spacesController.get(spaceId); | ||
| if (!space) return false; | ||
| const tab = tabsController.getTabById(tabId); | ||
| if (!tab) return false; | ||
|
|
||
| // Capture source space before move (for normalizing after) | ||
| const sourceSpaceId = tab.spaceId; | ||
| const space = await spacesController.get(spaceId); | ||
| if (!space) return false; | ||
|
|
||
| // Collect all tabs to move (includes tab group members) | ||
| let targetTabs: Tab[] = [tab]; | ||
| const tabGroup = tabsController.getTabGroupByTabId(tab.id); | ||
| if (tabGroup) { | ||
| targetTabs = tabGroup.tabs; | ||
| } | ||
| // For external cross-window drops a one-use drag token is required. | ||
| // Validate and consume it before proceeding so it cannot be replayed. | ||
| if (dragToken !== undefined) { | ||
| if (!validateAndConsumeToken(dragToken, tabId)) return false; | ||
| } | ||
|
|
||
| // Move all tabs in the group to the new space | ||
| for (const targetTab of targetTabs) { | ||
| targetTab.setSpace(spaceId); | ||
| targetTab.setWindow(window); | ||
| // Capture source location before move (for normalizing after) | ||
| const sourceSpaceId = tab.spaceId; | ||
| const sourceWindowId = tab.getWindow()?.id; | ||
|
|
||
| if (newPosition !== undefined) { | ||
| targetTab.updateStateProperty("position", newPosition); | ||
| // Collect all tabs to move (includes tab group members) | ||
| let targetTabs: Tab[] = [tab]; | ||
| const tabGroup = tabsController.getTabGroupByTabId(tab.id); | ||
| if (tabGroup) { | ||
| targetTabs = tabGroup.tabs; | ||
| } | ||
| } | ||
|
|
||
| // Normalize positions in both source and target spaces | ||
| tabsController.normalizePositions(window.id, spaceId); | ||
| if (sourceSpaceId !== spaceId) { | ||
| tabsController.normalizePositions(window.id, sourceSpaceId); | ||
| } | ||
| // Move all tabs in the group to the new space | ||
| for (const targetTab of targetTabs) { | ||
| targetTab.setSpace(spaceId); | ||
| targetTab.setWindow(window); | ||
|
|
||
| tabsController.setActiveTab(tab); | ||
| return true; | ||
| }); | ||
| if (newPosition !== undefined) { | ||
| targetTab.updateStateProperty("position", newPosition); | ||
| } | ||
| } | ||
|
|
||
| // Normalize positions in the target space | ||
| tabsController.normalizePositions(window.id, spaceId); | ||
|
|
||
| // Normalize positions in the source space (may be in a different window for cross-window moves) | ||
| if (sourceSpaceId !== spaceId || sourceWindowId !== window.id) { | ||
| const normalizeWindowId = sourceWindowId ?? window.id; | ||
| tabsController.normalizePositions(normalizeWindowId, sourceSpaceId); | ||
| } | ||
|
|
||
| tabsController.setActiveTab(tab); | ||
| return true; | ||
| } | ||
| ); | ||
|
|
||
| ipcMain.on("tabs:show-context-menu", (event, tabId: number) => { | ||
| const webContents = event.sender; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,8 +3,15 @@ import { cn } from "@/lib/utils"; | |||||||||||||||||||||||||
| import { useSpaces } from "@/components/providers/spaces-provider"; | ||||||||||||||||||||||||||
| import { SpaceIcon } from "@/lib/phosphor-icons"; | ||||||||||||||||||||||||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||||||||||||||||||||||||
| import type { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group"; | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| type TabGroupSourceData, | ||||||||||||||||||||||||||
| canDropExternalTabGroup, | ||||||||||||||||||||||||||
| canDropElementTabGroup, | ||||||||||||||||||||||||||
| parseExternalTabGroupDrop | ||||||||||||||||||||||||||
| } from "@/lib/tab-drag-mime"; | ||||||||||||||||||||||||||
| import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; | ||||||||||||||||||||||||||
| import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; | ||||||||||||||||||||||||||
| import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; | ||||||||||||||||||||||||||
| import { AnimatePresence, motion } from "motion/react"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Layout constants (px) | ||||||||||||||||||||||||||
|
|
@@ -64,35 +71,57 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { | |||||||||||||||||||||||||
| removeDraggingTimeout(); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return dropTargetForElements({ | ||||||||||||||||||||||||||
| element, | ||||||||||||||||||||||||||
| canDrop: (args) => { | ||||||||||||||||||||||||||
| const sourceData = args.source.data as TabGroupSourceData; | ||||||||||||||||||||||||||
| if (sourceData.type !== "tab-group") return false; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const sourceProfileId = sourceData.profileId; | ||||||||||||||||||||||||||
| const targetProfileId = space.profileId; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Does not support moving tabs between profiles | ||||||||||||||||||||||||||
| if (sourceProfileId !== targetProfileId) return false; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Don't allow dropping on the space the tab is already in | ||||||||||||||||||||||||||
| if (sourceData.spaceId === space.id) return false; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| onDragEnter: startDragging, | ||||||||||||||||||||||||||
| onDrag: startDragging, | ||||||||||||||||||||||||||
| onDragLeave: stopDragging, | ||||||||||||||||||||||||||
| onDrop: (args) => { | ||||||||||||||||||||||||||
| stopDragging(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Move the tab to this space (no specific position — append to end) | ||||||||||||||||||||||||||
| const sourceData = args.source.data as TabGroupSourceData; | ||||||||||||||||||||||||||
| const sourceTabId = sourceData.primaryTabId; | ||||||||||||||||||||||||||
| flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); | ||||||||||||||||||||||||||
| function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { | ||||||||||||||||||||||||||
| stopDragging(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Validate profile compatibility | ||||||||||||||||||||||||||
| if (sourceData.profileId !== space.profileId) { | ||||||||||||||||||||||||||
| // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet | ||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // For external (cross-window) drops, always move via IPC even if same space | ||||||||||||||||||||||||||
| if (!isExternal && sourceData.spaceId === space.id) { | ||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Move the tab to this space (no specific position — append to end) | ||||||||||||||||||||||||||
| const sourceTabId = sourceData.primaryTabId; | ||||||||||||||||||||||||||
| flow.tabs.moveTabToWindowSpace(sourceTabId, space.id, undefined, sourceData.dragToken); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return combine( | ||||||||||||||||||||||||||
| dropTargetForElements({ | ||||||||||||||||||||||||||
| element, | ||||||||||||||||||||||||||
| canDrop: (args) => | ||||||||||||||||||||||||||
| canDropElementTabGroup(args.source.data, { | ||||||||||||||||||||||||||
| profileId: space.profileId, | ||||||||||||||||||||||||||
| excludeSpaceId: space.id | ||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||
| onDragEnter: startDragging, | ||||||||||||||||||||||||||
| onDrag: startDragging, | ||||||||||||||||||||||||||
| onDragLeave: stopDragging, | ||||||||||||||||||||||||||
| onDrop: (args) => { | ||||||||||||||||||||||||||
| const sourceData = args.source.data as TabGroupSourceData; | ||||||||||||||||||||||||||
| handleDrop(sourceData, false); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| dropTargetForExternal({ | ||||||||||||||||||||||||||
| element, | ||||||||||||||||||||||||||
| canDrop: (args) => canDropExternalTabGroup(args.source.types, space.profileId), | ||||||||||||||||||||||||||
| onDragEnter: startDragging, | ||||||||||||||||||||||||||
| onDrag: startDragging, | ||||||||||||||||||||||||||
| onDragLeave: stopDragging, | ||||||||||||||||||||||||||
| onDrop: (args) => { | ||||||||||||||||||||||||||
| stopDragging(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const sourceData = parseExternalTabGroupDrop(args.source); | ||||||||||||||||||||||||||
| if (!sourceData) return; | ||||||||||||||||||||||||||
| handleDrop(sourceData, true); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+116
to
+122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The same pattern occurs in |
||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| }, [onClick, removeDraggingTimeout, space.profileId, space.id]); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const showIcon = !compact || isHovered || isActive; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,21 @@ | ||
| 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"; | ||
| import { | ||
| dropTargetForElements, | ||
| ElementDropTargetEventBasePayload | ||
| } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; | ||
| import { | ||
| dropTargetForExternal, | ||
| ExternalDropTargetEventBasePayload | ||
| } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"; | ||
| import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; | ||
|
|
||
| type TabDropTargetProps = { | ||
| spaceData: Space; | ||
|
|
@@ -26,49 +36,59 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } | |
| const el = dropTargetRef.current; | ||
| if (!el) return () => {}; | ||
|
|
||
| function onDrop(args: ElementDropTargetEventBasePayload) { | ||
| function handleDrop(sourceData: TabGroupSourceData, isExternal: boolean) { | ||
| setShowDropIndicator(false); | ||
|
|
||
| const sourceData = args.source.data as TabGroupSourceData; | ||
| const sourceTabId = sourceData.primaryTabId; | ||
|
|
||
| const newPos = biggestIndex + 1; | ||
|
|
||
| if (sourceData.spaceId !== spaceData.id) { | ||
| if (sourceData.spaceId !== spaceData.id || isExternal) { | ||
| if (sourceData.profileId !== spaceData.profileId) { | ||
| // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet | ||
| } else { | ||
| flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos); | ||
| flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos, sourceData.dragToken); | ||
| } | ||
|
Comment on lines
+49
to
50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Passes drag credential unconditionally; inconsistent with
The same concern applies to Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| } else { | ||
| moveTab(sourceTabId, newPos); | ||
| } | ||
| } | ||
|
|
||
| function onDrop(args: ElementDropTargetEventBasePayload) { | ||
| const sourceData = args.source.data as TabGroupSourceData; | ||
| handleDrop(sourceData, false); | ||
| } | ||
|
|
||
| function onExternalDrop(args: ExternalDropTargetEventBasePayload) { | ||
| setShowDropIndicator(false); | ||
|
|
||
| const sourceData = parseExternalTabGroupDrop(args.source); | ||
| if (!sourceData) return; | ||
| handleDrop(sourceData, true); | ||
| } | ||
|
|
||
| function onChange() { | ||
| setShowDropIndicator(true); | ||
| } | ||
|
|
||
| const cleanupDropTarget = dropTargetForElements({ | ||
| element: el, | ||
| canDrop: (args) => { | ||
| const sourceData = args.source.data as TabGroupSourceData; | ||
| if (sourceData.type !== "tab-group") { | ||
| return false; | ||
| } | ||
| if (sourceData.profileId !== spaceData.profileId) { | ||
| // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet | ||
| return false; | ||
| } | ||
| return true; | ||
| }, | ||
| onDrop: onDrop, | ||
| onDragEnter: onChange, | ||
| onDrag: onChange, | ||
| onDragLeave: () => setShowDropIndicator(false) | ||
| }); | ||
| return combine( | ||
| dropTargetForElements({ | ||
| element: el, | ||
| canDrop: (args) => canDropElementTabGroup(args.source.data, { profileId: spaceData.profileId }), | ||
| onDrop: onDrop, | ||
| onDragEnter: onChange, | ||
| onDrag: onChange, | ||
| onDragLeave: () => setShowDropIndicator(false) | ||
| }), | ||
|
|
||
| return cleanupDropTarget; | ||
| dropTargetForExternal({ | ||
| element: el, | ||
| canDrop: (args) => canDropExternalTabGroup(args.source.types, spaceData.profileId), | ||
| onDrop: onExternalDrop, | ||
| onDragEnter: onChange, | ||
| onDrag: onChange, | ||
| onDragLeave: () => setShowDropIndicator(false) | ||
| }) | ||
| ); | ||
| }, [spaceData.profileId, isSpaceLight, moveTab, biggestIndex, spaceData.id]); | ||
|
|
||
| return ( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stale token persists after a cancelled drag
When a drag is initiated,
registerTokenis called immediately. If the user cancels the drag (presses Escape, releases outside a valid target, etc.), the token is never consumed andactiveTokenkeeps the stale entry indefinitely. The stale token is only evicted if the user starts a new drag (which replacesactiveToken) or completes a valid drop.In practice the stale token cannot be replayed by the renderer —
getInitialDataForExternalis a fire-and-forget closure and never stores the token in component state, so the renderer has no reference to it after the callback returns. The risk is low, but consider adding a cleanup ondragendor a short TTL to make the guarantee explicit: