Conversation
wxfengg
commented
Feb 28, 2026
- 修复打开文件并独立窗口出去时出现的报错
- 优化 Tab 拖拽分离和合并预览功能,增强用户体验
There was a problem hiding this comment.
Pull request overview
该 PR 聚焦于多窗口场景下的 Tab 拖拽分离/合并预览体验与稳定性:解决跨进程传输序列化报错、改进拖拽分离的源窗口显示逻辑,并让合并预览的插入位置可随光标动态变化。
Changes:
- 拖拽分离时对 Tab 数据做
toRaw处理,并在分离/取消/失败时更完善地切换与恢复源 Tab。 - TabBar 的分离触发从“离开窗口边界”改为“离开 Tab 栏边界”,并新增 ghost 隐藏/恢复逻辑。
- 主进程新增“合并预览位置更新”事件,支持预览 Tab 在目标窗口内随光标调整插入位置;单 Tab 窗口拖拽时用透明化强化预览反馈。
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/renderer/hooks/useTab.ts | toRaw 序列化修复;分离/取消恢复逻辑;新增合并预览插入位置动态更新处理 |
| src/renderer/components/workspace/TabBar.vue | 分离检测改为基于 Tab 栏边界;新增 ghost 隐藏/恢复;调整拖拽结束流程 |
| src/main/windowManager.ts | 同目标窗口下发送 merge-preview-update;单 Tab 拖拽时根据是否命中目标透明化窗口并在 stop 时恢复 |
| src/main/ipcBridge.ts | tear-off 完成后延迟聚焦新窗口以避免源窗口 RAF 刷新被限流 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 获取所有非预览 Tab 元素 | ||
| const tabElements = Array.from( | ||
| document.querySelectorAll("[data-tab-id]:not(.merge-preview)") | ||
| ) as HTMLElement[]; | ||
|
|
||
| // 默认放在末尾 | ||
| let targetIndex = tabs.value.length - 1; | ||
| for (let i = 0; i < tabElements.length; i++) { | ||
| const rect = tabElements[i].getBoundingClientRect(); | ||
| const centerX = rect.left + rect.width / 2; | ||
| if (clientX < centerX) { | ||
| // 找到该元素在 tabs 数组中的真实索引 | ||
| const elTabId = tabElements[i].dataset.tabId; | ||
| const realIndex = tabs.value.findIndex((t) => t.id === elTabId); | ||
| if (realIndex !== -1) { | ||
| // 如果预览 Tab 在目标之前,移除后索引需 -1 | ||
| targetIndex = currentIndex < realIndex ? realIndex - 1 : realIndex; | ||
| } | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // 仅在位置真正变化时才更新,避免不必要的 Vue 响应式开销 | ||
| if (targetIndex !== currentIndex) { | ||
| const [tab] = tabs.value.splice(currentIndex, 1); | ||
| tabs.value.splice(targetIndex, 0, tab); | ||
| } |
There was a problem hiding this comment.
tab:merge-preview-update can arrive at ~60fps (driven by the main-process drag intervals). Each call does querySelectorAll + getBoundingClientRect over all tabs, which forces repeated layout and can become janky with many tabs. Consider throttling to requestAnimationFrame (coalesce multiple updates per frame) and/or caching tab element centers while dragging, updating only when the pointer crosses a boundary.
| /** 恢复 SortableJS ghost 元素(取消 tear-off 时调用) */ | ||
| function showGhost() { | ||
| if (_ghostEl) { | ||
| _ghostEl.style.display = ""; | ||
| _ghostEl = null; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
hideGhost() sets an inline display: none on the element with .ghost. When the drag ends, SortableJS removes the ghostClass but won't restore inline styles, so the tab can remain permanently hidden if the tear-off ultimately fails (since handleDragEnd clears _ghostEl without calling showGhost()). Prefer hiding via a CSS rule keyed off body.tab-torn-off (so visibility auto-restores when the class is removed), or ensure the inline style is always reverted on drag end / tear-off failure.
| /** 恢复 SortableJS ghost 元素(取消 tear-off 时调用) */ | |
| function showGhost() { | |
| if (_ghostEl) { | |
| _ghostEl.style.display = ""; | |
| _ghostEl = null; | |
| } | |
| } | |
| /** 恢复 SortableJS ghost 元素(取消 tear-off 或拖拽结束时调用) */ | |
| function showGhost() { | |
| // 尝试恢复当前 DOM 中的 ghost 元素的 display,即使 _ghostEl 已被其他逻辑清空 | |
| const container = tabContainerRef.value; | |
| if (container) { | |
| const ghostEl = container.querySelector(".ghost") as HTMLElement | null; | |
| if (ghostEl && ghostEl.style.display === "none") { | |
| ghostEl.style.display = ""; | |
| } | |
| } | |
| // 同时处理通过 hideGhost() 缓存过的元素,确保其样式被恢复 | |
| if (_ghostEl) { | |
| _ghostEl.style.display = ""; | |
| _ghostEl = null; | |
| } | |
| } | |
| // 全局监听拖拽结束事件,确保无论 tear-off 是否成功,ghost 样式都会被恢复 | |
| const handleGlobalDragEnd = () => { | |
| showGhost(); | |
| }; | |
| onMounted(() => { | |
| window.addEventListener("dragend", handleGlobalDragEnd); | |
| }); | |
| onUnmounted(() => { | |
| window.removeEventListener("dragend", handleGlobalDragEnd); | |
| }); |
| if (prev?.id === target?.id) { | ||
| // 目标窗口未变,但光标位置变了 → 发送位置更新以动态调整预览 Tab 插入位置 | ||
| if (target && !target.isDestroyed()) { | ||
| target.webContents.send("tab:merge-preview-update", screenX, screenY); | ||
| } | ||
| return target; |
There was a problem hiding this comment.
updateMergePreview now sends tab:merge-preview-update every time the cursor moves while staying over the same target window. Given the 16ms drag intervals, this can create very high-frequency IPC traffic and renderer-side layout work. Consider throttling these updates (e.g., only send at most once per animation frame or every N ms) and/or only sending when the X position actually changes enough to affect the insertion index.
| * 动态更新合并预览 Tab 的插入位置 | ||
| * 由主进程在光标移动时持续发送,实现拖拽悬停时预览 Tab 跟随光标变换顺序 | ||
| */ | ||
| function handleTabMergePreviewUpdate(screenX: number, screenY: number) { |
There was a problem hiding this comment.
handleTabMergePreviewUpdate declares screenY but never uses it. With noUnusedParameters: true in tsconfig, this will fail type-check/build. Either remove the parameter from the function signature (extra IPC args will be ignored), or reference it explicitly if it's intentionally kept.
| function handleTabMergePreviewUpdate(screenX: number, screenY: number) { | |
| function handleTabMergePreviewUpdate(screenX: number) { |