Conversation
There was a problem hiding this comment.
Pull request overview
本 PR 为编辑器引入“多窗口”能力:支持将 Tab 拖拽分离为独立窗口,并支持拖拽到其他窗口进行合并(含悬停预览),同时增加跨窗口文件去重与多窗口下的主进程窗口管理。
Changes:
- 新增主进程
windowManager,集中管理编辑器窗口、tear-off 创建/跟随、合并预览与落点合并逻辑 - 渲染进程增加 Tab 拖拽出窗/合并预览的 UI 与状态管理,并支持新窗口从主进程注入初始 Tab 数据
- 增加跨窗口“同文件已打开则聚焦”的去重逻辑,并补齐对应 preload / global typings
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/tab.ts | 为 Tab 增加合并预览标记字段 isMergePreview |
| src/shared/types/tearoff.ts | 新增跨窗口传输的 Tab 数据结构与 FileTraitsDTO |
| src/renderer/hooks/useTab.ts | 新窗口初始化接收 tear-off 数据;新增 tear-off/合并预览/单 Tab 窗口拖拽相关方法与事件监听 |
| src/renderer/hooks/useFile.ts | 打开文件时增加跨窗口去重聚焦逻辑 |
| src/renderer/global.d.ts | 补充 tear-off 相关 electronAPI typings,并改为从 shared types 导入 DTO |
| src/renderer/components/workspace/TabBar.vue | 增加拖拽出窗检测、单 Tab 窗口拖拽、Sortable fallback 样式与合并预览样式 |
| src/preload.ts | 暴露 tear-off、跨窗口聚焦、窗口拖拽相关 IPC API |
| src/main/windowManager.ts | 新增:窗口追踪、拖拽跟随窗口、合并预览/合并、跨窗口文件索引等主进程能力 |
| src/main/ipcBridge.ts | 新增/调整 IPC:tear-off、跨窗口聚焦、窗口边界获取、窗口拖拽、广播文件变化;并将部分逻辑改为基于 sender 路由 |
| src/main/index.ts | 主窗口纳入 windowManager 追踪 |
| lang/index.json | 新增一条 i18n 文案(当前看起来像测试内容) |
| lang/index.js | 注释文案更新 |
Comments suppressed due to low confidence (1)
src/main/ipcBridge.ts:782
- The file watcher diffing uses the current window’s
filePathsagainst a single globalwatchedFilesset. In a multi-window app, when another window reports its list, this will incorrectly unwatch files still open in other windows. Track watched files per-window (or ref-count), then watch the union across all windows.
// 先差异对比
const newFiles = filePaths.filter((filePath) => !watchedFiles.has(filePath));
const removedFiles = Array.from(watchedFiles).filter(
(filePath) => !filePaths.includes(filePath)
);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // ── 窗口初始化逻辑 ───────────────────────────────────────── | ||
| // 如果是由 Tab 拖拽分离创建的新窗口,使用传入的 Tab 数据替换默认 Tab | ||
| let _tearOffInitPromise: Promise<void> | null = null; |
There was a problem hiding this comment.
_tearOffInitPromise is assigned but never read. With noUnusedLocals: true, this will fail TypeScript compilation. Consider removing the variable entirely, or actually use it (e.g., to prevent duplicate init calls / allow awaiting initialization).
| let _tearOffInitPromise: Promise<void> | null = null; |
| // 所有 on 类型监听 —— 使用 event.sender 自动路由到正确窗口 | ||
| export function registerIpcOnHandlers(win: Electron.BrowserWindow) { | ||
| ipcMain.on("set-title", (_event, filePath: string | null) => { | ||
| ipcMain.on("set-title", (event, filePath: string | null) => { | ||
| const targetWin = BrowserWindow.fromWebContents(event.sender); | ||
| if (!targetWin) return; |
There was a problem hiding this comment.
registerIpcOnHandlers registers multiple ipcMain.on(...) listeners every time it’s called, but they’re global and never removed. If createWindow() runs more than once (macOS activate / reopening), handlers will be duplicated and fire multiple times. Add a one-time registration guard or move these listeners into a single global registration function.
| const clientX = event.originalEvent.clientX ?? event.originalEvent.touches?.[0]?.clientX ?? 0; | ||
| const clientY = event.originalEvent.clientY ?? event.originalEvent.touches?.[0]?.clientY ?? 0; | ||
| if (clientX && clientY) { |
There was a problem hiding this comment.
Offset calculation skips when either clientX or clientY is 0 because of the truthy check. This can leave initialOffsetX/Y as 0 even when the user clicked within the tab, causing the tear-off window to jump. Use an explicit !== undefined / Number.isFinite check instead of truthiness.
| const clientX = event.originalEvent.clientX ?? event.originalEvent.touches?.[0]?.clientX ?? 0; | |
| const clientY = event.originalEvent.clientY ?? event.originalEvent.touches?.[0]?.clientY ?? 0; | |
| if (clientX && clientY) { | |
| const clientX = event.originalEvent.clientX ?? event.originalEvent.touches?.[0]?.clientX; | |
| const clientY = event.originalEvent.clientY ?? event.originalEvent.touches?.[0]?.clientY; | |
| if (typeof clientX === "number" && typeof clientY === "number") { |
| try { | ||
| const crossResult = await window.electronAPI.focusFileIfOpen(result.filePath); | ||
| if (crossResult.found) return; | ||
| } catch {} |
There was a problem hiding this comment.
The empty catch {} swallows failures from focusFileIfOpen completely, which makes cross-window open issues hard to diagnose. Consider at least logging at debug level, or add a short comment explaining why ignoring the error is safe.
| } catch {} | |
| } catch (error) { | |
| // Focusing the file in another window is a best-effort operation; if it fails, | |
| // we fall back to opening the file in the current window. | |
| console.debug("focusFileIfOpen failed, opening file in current window instead:", error); | |
| } |
| window.electronAPI.getWindowBounds().then((bounds) => { | ||
| cachedBounds = bounds; | ||
| }); |
There was a problem hiding this comment.
getWindowBounds() is invoked without a .catch(...). If the IPC handler is unavailable or errors, this can produce an unhandled promise rejection and break subsequent drag logic. Please handle failures (e.g., set cachedBounds = null and proceed without tear-off detection).
| window.electronAPI.getWindowBounds().then((bounds) => { | |
| cachedBounds = bounds; | |
| }); | |
| window.electronAPI | |
| .getWindowBounds() | |
| .then((bounds) => { | |
| cachedBounds = bounds; | |
| }) | |
| .catch(() => { | |
| // 若 IPC 调用失败,则禁用本次 tear-off 检测 | |
| cachedBounds = null; | |
| }); |
| "gl01kl7e": { | ||
| "zh-cn": "\n<br />\n\n# MilkUp\n\n<br />\n\n> 这是一段测试文字\n\n<br />\n\n***\n\n<br />\n\n* item1\n* item2\n* item3\n\n代码:\n\n```JavaScript\nconst text = \"Hello Word!\"\nconsole.log(text)\n```\n\n<br />\n\n| 1 | 2 | 3 |\n| :- | :- | :- |\n| 1 | 2 | 3 |\n| 1 | 2 | 3 |\n\n\n", | ||
| "ja": "", | ||
| "ko": "", | ||
| "ru": "", |
There was a problem hiding this comment.
This new i18n entry looks like test/demo content (e.g., “这是一段测试文字”) and the key doesn’t appear to be referenced elsewhere. Since it’s unrelated to the multi-window feature, please remove it or document where it’s used to avoid shipping stray content in production builds.
| "fr": "" | ||
| }, | ||
| "gl01kl7e": { | ||
| "zh-cn": "\n<br />\n\n# MilkUp\n\n<br />\n\n> 这是一段测试文字\n\n<br />\n\n***\n\n<br />\n\n* item1\n* item2\n* item3\n\n代码:\n\n```JavaScript\nconst text = \"Hello Word!\"\nconsole.log(text)\n```\n\n<br />\n\n| 1 | 2 | 3 |\n| :- | :- | :- |\n| 1 | 2 | 3 |\n| 1 | 2 | 3 |\n\n\n", |
There was a problem hiding this comment.
Typo in the sample code string: “Hello Word!” should be “Hello World!”.
| "zh-cn": "\n<br />\n\n# MilkUp\n\n<br />\n\n> 这是一段测试文字\n\n<br />\n\n***\n\n<br />\n\n* item1\n* item2\n* item3\n\n代码:\n\n```JavaScript\nconst text = \"Hello Word!\"\nconsole.log(text)\n```\n\n<br />\n\n| 1 | 2 | 3 |\n| :- | :- | :- |\n| 1 | 2 | 3 |\n| 1 | 2 | 3 |\n\n\n", | |
| "zh-cn": "\n<br />\n\n# MilkUp\n\n<br />\n\n> 这是一段测试文字\n\n<br />\n\n***\n\n<br />\n\n* item1\n* item2\n* item3\n\n代码:\n\n```JavaScript\nconst text = \"Hello World!\"\nconsole.log(text)\n```\n\n<br />\n\n| 1 | 2 | 3 |\n| :- | :- | :- |\n| 1 | 2 | 3 |\n| 1 | 2 | 3 |\n\n\n", |
| tearOffTabStart: ( | ||
| tabData: any, | ||
| screenX: number, |
There was a problem hiding this comment.
tabData is typed as any here, which defeats the point of adding TearOffTabData/FileTraitsDTO types elsewhere. Consider typing these parameters as TearOffTabData to keep IPC payloads type-safe end-to-end.

🎉新增多窗口功能,可以让 Tab 独立新的窗口,也可以合并 Tab 窗口