Skip to content

feat: cross-window tab drag-and-drop#221

Open
iamEvanYT wants to merge 10 commits intomainfrom
opencode/cross-window-tab-dnd
Open

feat: cross-window tab drag-and-drop#221
iamEvanYT wants to merge 10 commits intomainfrom
opencode/cross-window-tab-dnd

Conversation

@iamEvanYT
Copy link
Copy Markdown
Member

@iamEvanYT iamEvanYT commented Mar 2, 2026

Summary

Enables dragging tabs between different Flow browser windows using @atlaskit/pragmatic-drag-and-drop's external/native drag adapter.

  • IPC fix: The tabs:move-tab-to-window-space handler now captures the source window ID before the move so position normalization targets the correct window during cross-window transfers.
  • New browser UI: Added getInitialDataForExternal on draggable() to serialize tab data to a custom MIME type (application/x-flow-tab-group), and dropTargetForExternal on tab groups, the empty-area drop target, and space switcher buttons.
  • Old browser UI: Mirrored all changes from the new UI to the parallel old-UI components.
  • Same-space cross-window fix: Drop handlers now pass an isExternal flag so that cross-window drops to the same space correctly call moveTabToWindowSpace instead of just reordering locally.
  • Profile filtering via MIME type: A profile-specific MIME type (application/x-flow-tab-group-profile-{id}) is registered alongside the payload during drag. External canDrop checks for this type to reject incompatible profiles during drag — preventing drop indicators, space switches, and drops for tabs from different profiles.

How it works

  1. When a drag starts, getInitialDataForExternal writes TabGroupSourceData JSON to the native dataTransfer using a custom MIME type — this data crosses Electron window boundaries via the OS drag system.
  2. A second MIME type encoding the source profile ID is also registered. Since MIME type names (but not payloads) are visible during drag, this lets drop targets filter by profile before the actual drop.
  3. dropTargetForExternal on each drop target listens for drags arriving from outside the current window's JS context.
  4. canDrop checks for the profile-specific MIME type, blocking indicators and interactions for incompatible profiles.
  5. Drop handlers use an isExternal flag to ensure cross-window drops always call moveTabToWindowSpace (even for same-space moves between windows).
  6. The existing moveTabToWindowSpace IPC call handles the actual cross-window move since tab IDs are globally stable.

Changed files

File Change
src/main/ipc/browser/tabs.ts Fix source-window position normalization for cross-window moves
browser-ui/tab-group.tsx Add external drag/drop with profile MIME type, isExternal flag in drop handler
browser-ui/tab-drop-target.tsx Add external drop with profile MIME filtering, isExternal flag
browser-ui/space-switcher.tsx Add external drop with profile MIME filtering for hover-to-switch + drop
old-browser-ui/sidebar-tab-groups.tsx Mirror of tab-group.tsx changes
old-browser-ui/sidebar-tab-drop-target.tsx Mirror of tab-drop-target.tsx changes
old-browser-ui/spaces-switcher.tsx Mirror of space-switcher.tsx changes

…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.
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.
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.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 2, 2026

Build artifacts for all platforms are ready! 🚀

Download the artifacts for:

One-line installer (Unstable):
bunx flow-debug-build --open 22693835762

(execution 22693835762 / attempt 1)

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 2, 2026

Greptile Summary

This PR adds cross-window tab drag-and-drop to Flow Browser by layering dropTargetForExternal onto each existing dropTargetForElements call and introducing a one-use credential system to authenticate cross-window IPC moves. The approach is architecturally sound: MIME type names (visible during hover) carry profile identity for canDrop filtering, while the full payload (only available on actual drop) carries the cross-window credential; the main process consumes the credential on the first valid moveTabToWindowSpace call, preventing replay. Previously flagged issues — MIME constant duplication, canDrop missing the payload MIME check, same-space cross-window drops silently failing in space-switcher, and duplicated source-data construction — have all been resolved.

Remaining concerns:

  • drag-token.ts never clears a stale entry from a cancelled drag; it persists until the next drag replaces it. A short TTL or dragend cleanup would make the guarantee explicit.
  • The drag:register-token IPC handler ignores event.sender, so any trusted browser-UI window can register a credential for a tab it does not own. Adding a window-ownership check would tighten the security model.
  • tab-drop-target.tsx (new UI) and sidebar-tab-drop-target.tsx (old UI) forward the cross-window credential unconditionally to moveTabToWindowSpace, unlike the more explicit pattern used in tab-group.tsx / sidebar-tab-groups.tsx which only forwards it when isExternal is true.
  • stopDragging() is called twice in the external onDrop handler of both space-switcher components (harmless redundancy).

Confidence Score: 4/5

  • This PR is safe to merge; the cross-window drag feature works correctly and previously reported bugs are resolved, with only minor security hygiene and style inconsistencies remaining.
  • The core token-based authentication model is sound, MIME-type profile filtering works correctly, and the isExternal flag properly handles same-space cross-window drops in all six drop target components. The remaining issues are: an uncleared stale token on drag cancellation (low exploitability), a missing window-ownership check in the token registration IPC handler (low risk within the trusted renderer model), and a minor credential-forwarding inconsistency in the two tab-drop-target components.
  • src/main/ipc/app/drag-token.ts (stale token cleanup) and src/main/ipc/browser/tabs.ts (tab ownership validation in drag:register-token).

Important Files Changed

Filename Overview
src/main/ipc/app/drag-token.ts New one-use drag token module; design is sound but stale tokens from cancelled drags are never cleaned up — a minor security hygiene concern worth addressing.
src/main/ipc/browser/tabs.ts IPC handler correctly captures source window/space before move for position normalization; token registration handler does not verify tab ownership against the sending window, which is low-risk but worth tightening.
src/renderer/src/lib/tab-drag-mime.ts Clean shared module centralizing MIME constants, types, and helpers; canDropExternalTabGroup correctly checks both the payload MIME and the profile-specific sentinel MIME, and parseExternalTabGroupDrop safely validates the token is present before returning data.
src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx External drag/drop wiring is correct; getInitialDataForExternal registers the token before embedding it in the payload, canDrop delegates to shared helper, and handleDrop explicitly passes undefined for internal drops — a good defensive pattern.
src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx External drop support added correctly; handleDrop passes the drag credential unconditionally rather than only for external drops (unlike tab-group.tsx), which is safe today but inconsistent.
src/renderer/src/components/browser-ui/browser-sidebar/_components/space-switcher.tsx External drop support is well-structured; profile filtering via canDropExternalTabGroup is correct, and the isExternal guard properly forces moveTabToWindowSpace even for same-space cross-window drops. Minor: stopDragging() is called twice for external drops.
src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-groups.tsx Faithful mirror of tab-group.tsx changes; isExternal flag, shared MIME helpers, and generateBasicTabGroupSourceData helper are all correctly applied.
src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-drop-target.tsx Mirror of tab-drop-target.tsx; same unconditional credential-forwarding inconsistency relative to sidebar-tab-groups.tsx.
src/renderer/src/components/old-browser-ui/sidebar/spaces-switcher.tsx Mirror of space-switcher.tsx; isExternal guard and profile validation are correctly implemented. Same minor redundant stopDragging() call in external drop handler.
src/preload/index.ts registerDragToken correctly exposed as fire-and-forget via ipcRenderer.send; moveTabToWindowSpace correctly accepts an optional credential parameter; both are gated behind the "browser" permission.
src/shared/flow/interfaces/browser/tabs.ts Type definitions updated to include registerDragToken and the optional credential parameter on moveTabToWindowSpace; documentation comments are clear and accurate.

Sequence Diagram

sequenceDiagram
    participant RendererA as Source Window (Renderer A)
    participant MainProc as Main Process
    participant OS as OS Drag System
    participant RendererB as Drop Window (Renderer B)

    RendererA->>MainProc: drag:register-token(token, tabId) [ipcRenderer.send]
    Note over MainProc: activeToken = { token, tabId }

    RendererA->>OS: getInitialDataForExternal()<br/>DataTransfer: { payload+token, profile-MIME }

    OS-->>RendererB: dragenter / drag events<br/>(MIME type names visible, values hidden)

    RendererB->>RendererB: canDrop: canDropExternalTabGroup()<br/>checks TAB_GROUP_MIME_TYPE +<br/>profile-specific MIME name

    OS-->>RendererB: drop event<br/>(MIME values now readable)

    RendererB->>RendererB: parseExternalTabGroupDrop()<br/>parse JSON, verify token present

    RendererB->>MainProc: tabs:move-tab-to-window-space(tabId, spaceId, pos, token) [ipcRenderer.invoke]

    MainProc->>MainProc: validateAndConsumeToken(token, tabId)<br/>clears activeToken on success

    MainProc->>MainProc: tab.setSpace(spaceId)<br/>tab.setWindow(targetWindow)<br/>normalizePositions(source + target)

    MainProc-->>RendererA: tabs:on-data-changed (structural refresh)
    MainProc-->>RendererB: tabs:on-data-changed (structural refresh)
Loading

Last reviewed commit: d161adf

Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 files reviewed, 7 comments

Edit Code Review Agent Settings | Greptile

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.
@iamEvanYT
Copy link
Copy Markdown
Member Author

@greptile review

- 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
@iamEvanYT
Copy link
Copy Markdown
Member Author

@greptile review

…ed 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
@iamEvanYT
Copy link
Copy Markdown
Member Author

@greptile review

@iamEvanYT
Copy link
Copy Markdown
Member Author

@greptile review

Comment on lines +22 to +24
export function registerToken(token: string, tabId: number): void {
activeToken = { token, tabId };
}
Copy link
Copy Markdown

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, registerToken is called immediately. If the user cancels the drag (presses Escape, releases outside a valid target, etc.), the token is never consumed and activeToken keeps the stale entry indefinitely. The stale token is only evicted if the user starts a new drag (which replaces activeToken) or completes a valid drop.

In practice the stale token cannot be replayed by the renderer — getInitialDataForExternal is 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 on dragend or a short TTL to make the guarantee explicit:

// Option: auto-expire the token after a safe window (e.g. 30 s)
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;

export function registerToken(token: string, tabId: number): void {
  if (expiryTimeout) clearTimeout(expiryTimeout);
  activeToken = { token, tabId };
  expiryTimeout = setTimeout(() => { activeToken = null; }, 30_000);
}

export function validateAndConsumeToken(token: string, tabId: number): boolean {
  if (!activeToken) return false;
  if (activeToken.token !== token || activeToken.tabId !== tabId) return false;
  if (expiryTimeout) { clearTimeout(expiryTimeout); expiryTimeout = null; }
  activeToken = null;
  return true;
}

Comment on lines +339 to +341
ipcMain.on("drag:register-token", (_event, token: string, tabId: number) => {
registerToken(token, tabId);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drag:register-token does not verify tab ownership

The handler accepts any (token, tabId) pair without checking that tabId is a tab actually owned by the sending window. Any renderer context holding "browser" permission (i.e. any trusted browser-UI window) can pre-register a token for a tab in a different window, then immediately call moveTabToWindowSpace with that token to move the tab without a real drag event.

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);
});

Comment on lines +116 to +122
onDrop: (args) => {
stopDragging();

const sourceData = parseExternalTabGroupDrop(args.source);
if (!sourceData) return;
handleDrop(sourceData, true);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stopDragging() called twice for external drops

stopDragging() is called on line 117 before handleDrop is invoked, and then again as the very first statement inside handleDrop (line 75). The double-call is harmless (idempotent), but it's inconsistent with the internal onDrop path (which calls handleDrop directly without a preceding stopDragging()). Removing the redundant call makes the two paths symmetrical:

Suggested change
onDrop: (args) => {
stopDragging();
const sourceData = parseExternalTabGroupDrop(args.source);
if (!sourceData) return;
handleDrop(sourceData, true);
}
onDrop: (args) => {
const sourceData = parseExternalTabGroupDrop(args.source);
if (!sourceData) return;
handleDrop(sourceData, true);
}

The same pattern occurs in src/renderer/src/components/old-browser-ui/sidebar/spaces-switcher.tsx at lines 111–117.

Comment on lines +49 to 50
flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos, sourceData.dragToken);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passes drag credential unconditionally; inconsistent with tab-group.tsx

handleDrop always passes the drag credential from sourceData to moveTabToWindowSpace, for both internal and external drops. This is safe today because getInitialData (used for internal drags) does not populate the credential field, leaving it as undefined, and the IPC handler skips validation when no credential is supplied.

tab-group.tsx takes the more explicit approach, passing undefined for internal drops and only forwarding the credential when isExternal is true. Aligning this file with that pattern makes the design intent self-documenting and prevents a future regression if internal drag payloads ever accidentally gain a credential field.

The same concern applies to src/renderer/src/components/old-browser-ui/sidebar/content/sidebar-tab-drop-target.tsx at line 50.

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!

@iamEvanYT iamEvanYT force-pushed the main branch 2 times, most recently from ce3f35d to b2cbe93 Compare March 22, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant