Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive multi-window functionality to the Markdown editor, allowing users to drag tabs out to create new windows and merge them back. The implementation spans main process window management, renderer process UI interactions, and IPC communication between processes.
Changes:
- Introduced
windowManager.tsto centrally manage editor windows, tab drag-follow mechanics, and merge preview system - Extended tab management with tear-off capabilities, cross-window file deduplication, and single-tab window dragging
- Refactored window state tracking from global variables to per-window maps for multi-window support
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/shared/types/tearoff.ts | New shared type definitions for cross-process tab data transfer |
| src/types/tab.ts | Added isMergePreview flag for temporary preview tabs |
| src/main/windowManager.ts | New window manager with tab tear-off, merge preview, and window tracking |
| src/main/ipcBridge.ts | Refactored IPC handlers for multi-window support with per-window state tracking |
| src/main/index.ts | Integrated window manager tracking for main window |
| src/renderer/hooks/useTab.ts | Added tear-off initialization, merge preview, and single-tab drag handlers |
| src/renderer/hooks/useFile.ts | Integrated cross-window file deduplication checks |
| src/renderer/components/workspace/TabBar.vue | Implemented drag detection, tear-off triggering, and visual feedback |
| src/renderer/global.d.ts | Added type definitions for new IPC methods |
| src/preload.ts | Exposed new IPC methods for tab operations |
| lang/index.json | Added test localization entry (appears to be temporary) |
| lang/index.js | Minor comment updates |
Comments suppressed due to low confidence (5)
src/main/windowManager.ts:296
- The
startWindowDragfunction doesn't clear the global state variableswindowDragSourceIdandwindowDragTabDatabefore starting a new drag (line 294 only stops the interval). IfstartWindowDragis called multiple times in rapid succession or if the previous drag didn't properly clean up, these variables may retain stale data. WhilestopWindowDrag()is called first, it doesn't reset the source ID and tab data. Consider resetting these instopWindowDrag()or at the start ofstartWindowDrag().
stopWindowDrag();
windowDragSourceId = win.id;
windowDragTabData = tabData;
src/renderer/hooks/useTab.ts:106
- There is a potential race condition in the tear-off initialization logic. The
initFromTearOfffunction runs asynchronously (line 83) while the default tab is synchronously added (line 99). If the initialization completes quickly, it replacestabs.valuewith a new array (line 61), which might conflict with theonOpenFileAtLaunchcallback (line 102) that checks for and potentially removes the default tab. This could lead to unexpected behavior depending on timing. Consider adding synchronization logic or checking if tear-off data exists before creating the default tab.
// 立即发起初始化请求(不阻塞模块加载)
_tearOffInitPromise = initFromTearOff().then(() => {
_tearOffInitPromise = null;
});
// 先同步创建默认 Tab(确保 UI 立即可用),tear-off 初始化成功后会替换它
const defaultTab: Tab = {
id: defaultTabUUid,
name: defaultName,
filePath: null,
content: "",
originalContent: "",
isModified: false,
scrollRatio: 0,
readOnly: false,
isNewlyLoaded: true,
};
tabs.value.push(defaultTab);
activeTabId.value = defaultTab.id;
scheduleNewlyLoadedCleanup(defaultTabUUid);
window.electronAPI?.onOpenFileAtLaunch((_payload) => {
if (tabs.value.length === 1 && tabs.value[0].id === defaultTabUUid && !tabs.value[0].isModified) {
tabs.value = [];
}
});
lang/index.json:3451
- The sample markdown content in the localization entry contains a spelling error: "Hello Word!" should be "Hello World!" (line 3451).
"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",
src/renderer/components/workspace/TabBar.vue:179
- The promise chain at line 177 lacks error handling. If
getWindowBounds()fails or rejects, the error will be silently swallowed. While tear-off detection will gracefully fail due to the null check at line 87, it would be better to add.catch()to handle and log any errors for debugging purposes.
window.electronAPI.getWindowBounds().then((bounds) => {
cachedBounds = bounds;
});
src/renderer/hooks/useTab.ts:43
- The
TearOffTabDatatype is used in the functioninitFromTearOffat line 43, but this type is not imported. While it's defined inglobal.d.tsas a global type alias, it would be better to explicitly import it for clarity and maintainability. Consider addingimport type { TearOffTabData } from "@/shared/types/tearoff"at the top of the file.
const tabData: TearOffTabData | null = await window.electronAPI?.getInitialTabData();
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // ─── 合并预览(拖拽悬停即合并,离开撤销)───────────────── | ||
|
|
||
| const mergePreviewTargets = new Map<number, BrowserWindow | null>(); |
There was a problem hiding this comment.
The mergePreviewTargets map is not cleaned up when a window closes. If a window that is either a source or target of a merge preview is closed, stale entries will remain in this map, potentially causing memory leaks. Consider adding cleanup logic in the trackWindow function's 'closed' event handler to remove any entries where the closed window is involved (either as a key in the map or as a value).
| if (process.env.VITE_DEV_SERVER_URL) { | ||
| if (fastCreate) { | ||
| win.loadURL(process.env.VITE_DEV_SERVER_URL); | ||
| } else { | ||
| await win.loadURL(process.env.VITE_DEV_SERVER_URL); | ||
| } | ||
| } else { | ||
| if (fastCreate) { | ||
| win.loadFile(indexPath); | ||
| } else { | ||
| await win.loadFile(indexPath); | ||
| } | ||
| } |
There was a problem hiding this comment.
The createEditorWindow function does not handle potential errors from win.loadURL() or win.loadFile() in fastCreate mode (lines 196, 202). If the page fails to load, the window will be shown but empty/broken, and no error will be reported to the caller. Consider wrapping these calls in try-catch blocks and handling errors appropriately, or at minimum logging the failure.
| 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.
The offset calculation logic at lines 160-163 has a potential issue. The condition if (clientX && clientY) will be false when either value is 0, which is a valid screen coordinate (e.g., top-left corner of a monitor). This means that if the user starts dragging from exactly x=0 or y=0, the offsets won't be calculated, defaulting to 0. Consider changing the condition to check for undefined/null explicitly: if (clientX !== undefined && clientY !== undefined) or if (clientX != null && clientY != null).
| 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 (clientX != null && clientY != 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": "", | ||
| "en": "", | ||
| "fr": "" |
There was a problem hiding this comment.
The new localization entry "gl01kl7e" (lines 3450-3457) contains test/sample content ("这是一段测试文字", "Hello Word!" with a typo) and only has Chinese translation. This appears to be temporary test data that should either be removed or properly completed with translations for all supported languages (ja, ko, ru, en, fr) before merging.
| }, | |
| "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": "", | |
| "en": "", | |
| "fr": "" |
| window.electronAPI.on("tab:merge-in", handleTabMergeIn); | ||
|
|
||
| // ── Tab 合并预览(悬停即合并,离开撤销)────────────────── | ||
|
|
||
| let mergePreviewState: { | ||
| tabId: string; | ||
| prevActiveId: string | null; | ||
| isExisting: boolean; | ||
| } | null = null; | ||
|
|
||
| function handleTabMergePreview(tabData: TearOffTabData, screenX?: number, screenY?: number) { | ||
| const prevActiveId = activeTabId.value; | ||
|
|
||
| // 若已存在同文件路径的 Tab,直接激活它作为预览目标 | ||
| if (tabData.filePath) { | ||
| const existing = isFileAlreadyOpen(tabData.filePath); | ||
| if (existing) { | ||
| mergePreviewState = { | ||
| tabId: existing.id, | ||
| prevActiveId, | ||
| isExisting: true, | ||
| }; | ||
| switchToTab(existing.id); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // 计算插入位置 | ||
| let insertIndex = tabs.value.length; | ||
| if (screenX !== undefined && screenY !== undefined) { | ||
| // 将屏幕坐标转换为页面内坐标 | ||
| // 注意:window.screenX 是窗口左上角在屏幕的 X,加上边框偏移才是内容区 | ||
| // 简化处理:假设标准边框或无边框,contentX ≈ screenX - window.screenX | ||
| // 更精确的方式难以在纯 IPC 中获取,但如果不考虑标题栏(无框窗口),这样近似可行 | ||
| const clientX = screenX - window.screenX; | ||
|
|
||
| // 获取所有 tab 元素 (排除预览 Tab 自身) | ||
| const tabElements = Array.from(document.querySelectorAll("[data-tab-id]:not(.merge-preview)")); | ||
|
|
||
| // 找到插入点 | ||
| for (let i = 0; i < tabElements.length; i++) { | ||
| const rect = tabElements[i].getBoundingClientRect(); | ||
| const centerX = rect.left + rect.width / 2; | ||
| if (clientX < centerX) { | ||
| insertIndex = i; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 取消旧预览,总是重建以确保正确的插入位置 | ||
| if (mergePreviewState && !mergePreviewState.isExisting) { | ||
| close(mergePreviewState.tabId); | ||
| } | ||
|
|
||
| const tab: Tab = { | ||
| id: randomUUID(), | ||
| name: tabData.name, | ||
| filePath: tabData.filePath, | ||
| content: tabData.content, | ||
| originalContent: tabData.originalContent, | ||
| isModified: tabData.isModified, | ||
| scrollRatio: tabData.scrollRatio ?? 0, | ||
| readOnly: tabData.readOnly, | ||
| isNewlyLoaded: !tabData.isModified, | ||
| isMergePreview: true, | ||
| fileTraits: tabData.fileTraits, | ||
| }; | ||
|
|
||
| // 插入到指定位置 | ||
| tabs.value.splice(insertIndex, 0, tab); | ||
| activeTabId.value = tab.id; | ||
| if (!tabData.isModified) { | ||
| scheduleNewlyLoadedCleanup(tab.id); | ||
| } | ||
|
|
||
| if (tab.filePath) { | ||
| setCurrentMarkdownFilePath(tab.filePath); | ||
| } | ||
|
|
||
| emitter.emit("tab:switch", tab); | ||
|
|
||
| mergePreviewState = { | ||
| tabId: tab.id, | ||
| prevActiveId, | ||
| isExisting: false, | ||
| }; | ||
| } | ||
|
|
||
| function handleTabMergePreviewCancel() { | ||
| if (!mergePreviewState) return; | ||
| const { tabId, prevActiveId, isExisting } = mergePreviewState; | ||
| mergePreviewState = null; | ||
|
|
||
| if (!isExisting) { | ||
| close(tabId); | ||
| } | ||
|
|
||
| if (prevActiveId && tabs.value.find((tab) => tab.id === prevActiveId)) { | ||
| switchToTab(prevActiveId); | ||
| } | ||
| } | ||
|
|
||
| function handleTabMergePreviewFinalize() { | ||
| if (!mergePreviewState) return; | ||
| const { tabId, isExisting } = mergePreviewState; | ||
| mergePreviewState = null; | ||
|
|
||
| if (isExisting) return; | ||
| const tab = tabs.value.find((t) => t.id === tabId); | ||
| if (tab) { | ||
| tab.isMergePreview = false; | ||
| } | ||
| } | ||
|
|
||
| window.electronAPI.on("tab:merge-preview", handleTabMergePreview); | ||
| window.electronAPI.on("tab:merge-preview-cancel", handleTabMergePreviewCancel); | ||
| window.electronAPI.on("tab:merge-preview-finalize", handleTabMergePreviewFinalize); | ||
|
|
||
| // ── 跨窗口文件去重 ────────────────────────────────────── | ||
|
|
||
| /** 主进程通知激活指定文件的 Tab */ | ||
| window.electronAPI.on("tab:activate-file", (filePath: string) => { |
There was a problem hiding this comment.
Event listeners are registered at module load time (lines 616, 731-733, 738) but are never cleaned up. In a single-page application, if this module is ever re-imported or if the component using it is destroyed and recreated, these listeners will accumulate, causing memory leaks and potentially triggering handlers multiple times. Consider implementing a cleanup mechanism or using a Vue lifecycle hook to remove these listeners when appropriate.
| ipcMain.handle( | ||
| "tab:tear-off-start", | ||
| async ( | ||
| event, | ||
| tabData: TearOffTabData, | ||
| screenX: number, | ||
| screenY: number, | ||
| offsetX: number, | ||
| offsetY: number | ||
| ): Promise<boolean> => { | ||
| try { | ||
| const sourceWin = BrowserWindow.fromWebContents(event.sender); | ||
| startDragFollow(tabData, screenX, screenY, offsetX, offsetY, sourceWin); | ||
| return true; | ||
| } catch (error) { | ||
| console.error("[tab:tear-off-start] 创建窗口失败:", error); | ||
| return false; | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| // ── Tab 拖拽分离:完成跟随(松手时判断合并或保留)── | ||
| ipcMain.handle( | ||
| "tab:tear-off-end", | ||
| async ( | ||
| event, | ||
| screenX: number, | ||
| screenY: number | ||
| ): Promise<{ action: "created" | "merged" | "failed" }> => { | ||
| try { | ||
| const sourceWin = BrowserWindow.fromWebContents(event.sender); | ||
| const result = await finalizeDragFollow(screenX, screenY, sourceWin); | ||
| // tear-off 完成后重新聚焦源窗口,避免需要额外点击才能交互 | ||
| if (result.action === "created" && sourceWin && !sourceWin.isDestroyed()) { | ||
| sourceWin.focus(); | ||
| } | ||
| return { action: result.action }; | ||
| } catch (error) { | ||
| console.error("[tab:tear-off-end] 操作失败:", error); | ||
| return { action: "failed" }; | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| // ── Tab 拖拽分离:取消跟随(指针回到窗口内时取消分离)── | ||
| ipcMain.handle("tab:tear-off-cancel", async (): Promise<boolean> => { | ||
| try { | ||
| await cancelDragFollow(); | ||
| return true; | ||
| } catch (error) { | ||
| console.error("[tab:tear-off-cancel] 取消失败:", error); | ||
| return false; | ||
| } | ||
| }); |
There was a problem hiding this comment.
The IPC handlers for tab tear-off operations (lines 480-533) lack input validation. Parameters like screenX, screenY, offsetX, offsetY should be validated to ensure they are finite numbers and within reasonable bounds. Additionally, tabData should be validated to ensure it contains required fields with correct types before being processed. This is especially important since these values come from the renderer process and could potentially be malicious or malformed.
| // 如果在等待期间已被取消 | ||
| if (!dragFollowReady) return; | ||
|
|
||
| let prevCX = -1, | ||
| prevCY = -1; | ||
| const interval = setInterval(() => { | ||
| if (!win || win.isDestroyed()) { | ||
| cleanupDragFollow(); | ||
| return; | ||
| } | ||
| const cursor = screen.getCursorScreenPoint(); | ||
| if (cursor.x === prevCX && cursor.y === prevCY) return; | ||
| prevCX = cursor.x; | ||
| prevCY = cursor.y; | ||
| win.setPosition(Math.round(cursor.x - offsetX), Math.round(cursor.y - offsetY)); | ||
|
|
||
| if (dragFollowState) { | ||
| const sourceId = dragFollowState.sourceWinId; | ||
| if (sourceId) { | ||
| const target = updateMergePreview(sourceId, tabData, cursor.x, cursor.y, [win]); | ||
| if (target && !dragFollowState.hiddenForPreview) { | ||
| win.hide(); | ||
| dragFollowState.hiddenForPreview = true; | ||
| } else if (!target && dragFollowState.hiddenForPreview) { | ||
| win.showInactive(); | ||
| dragFollowState.hiddenForPreview = false; | ||
| } | ||
| } | ||
| } | ||
| }, 16); | ||
|
|
||
| dragFollowState = { | ||
| interval, | ||
| window: win, | ||
| tabData, | ||
| sourceWinId: sourceWin?.id ?? null, | ||
| hiddenForPreview: false, | ||
| }; | ||
| })(); |
There was a problem hiding this comment.
In the startDragFollow function, there's a race condition check at line 381 (if (!dragFollowReady) return), but dragFollowState is set at line 411-417 after this check. If cancelDragFollow is called between line 381 and 411, it will set dragFollowReady = null but dragFollowState will still be set afterwards, leading to an inconsistent state where the state exists but the promise doesn't. Consider checking dragFollowReady again before setting dragFollowState, or restructuring the logic to be more atomic.
| @@ -610,11 +792,12 @@ export function registerGlobalIpcHandlers() { | |||
| persistent: true, | |||
| }); | |||
|
|
|||
| // 设置文件变化监听 | |||
| // 设置文件变化监听 —— 广播到所有编辑器窗口 | |||
| watcher.on("change", (filePath) => { | |||
| const mainWindow = BrowserWindow.getAllWindows()[0]; | |||
| if (mainWindow && !mainWindow.isDestroyed()) { | |||
| mainWindow.webContents.send("file:changed", filePath); | |||
| for (const editorWin of getEditorWindows()) { | |||
| if (!editorWin.isDestroyed()) { | |||
| editorWin.webContents.send("file:changed", filePath); | |||
| } | |||
| } | |||
| }); | |||
| } | |||
| @@ -633,26 +816,32 @@ export function registerGlobalIpcHandlers() { | |||
| }); | |||
There was a problem hiding this comment.
The file watcher logic at lines 773-816 has a potential issue: when a window closes, its files remain in watchedFiles and continue being watched. Over time, especially with multiple windows opening and closing, the watcher will accumulate files that are no longer open in any window. Consider tracking which files are open in which windows and only unwatching files when they're no longer open in any window. This could be done by integrating with the windowOpenFiles cleanup in windowManager.ts.
| @@ -230,14 +281,25 @@ async function createTabFromFile( | |||
| // 打开文件 | |||
| async function openFile(filePath: string): Promise<Tab | null> { | |||
| try { | |||
| // 检查文件是否已经在某个tab中打开 | |||
| // 检查文件是否已经在当前窗口中打开 | |||
| const existingTab = isFileAlreadyOpen(filePath); | |||
| if (existingTab) { | |||
| // 如果文件已打开,直接切换到该tab | |||
| await switchToTab(existingTab.id); | |||
| return existingTab; | |||
| } | |||
|
|
|||
| // 检查文件是否在其他窗口中打开 | |||
| try { | |||
| const result = await window.electronAPI.focusFileIfOpen(filePath); | |||
| if (result.found) { | |||
| // 其他窗口已打开该文件并已聚焦,当前窗口无需操作 | |||
| return null; | |||
| } | |||
| } catch { | |||
| // 跨窗口检查失败不影响正常打开 | |||
| } | |||
|
|
|||
| // 使用统一的文件服务读取和处理文件 | |||
| const fileContent = await readAndProcessFile({ filePath }); | |||
| if (!fileContent) { | |||
| @@ -454,6 +516,267 @@ function reorderTabs(fromIndex: number, toIndex: number) { | |||
| tabs.value.splice(toIndex, 0, movedTab); | |||
| } | |||
|
|
|||
| // ── Tab 拖拽分离 ────────────────────────────────────────── | |||
|
|
|||
| /** 获取指定 Tab 的完整数据,用于跨窗口传递 */ | |||
| function getTabDataForTearOff(tabId: string): TearOffTabData | null { | |||
| const tab = tabs.value.find((t) => t.id === tabId); | |||
| if (!tab) return null; | |||
|
|
|||
| return { | |||
| id: tab.id, | |||
| name: tab.name, | |||
| filePath: tab.filePath, | |||
| content: tab.content, | |||
| originalContent: tab.originalContent, | |||
| isModified: tab.isModified, | |||
| scrollRatio: tab.scrollRatio ?? 0, | |||
| readOnly: tab.readOnly, | |||
| fileTraits: tab.fileTraits, | |||
| }; | |||
| } | |||
|
|
|||
| /** | |||
| * 开始拖拽分离:立即创建新窗口并跟随光标 | |||
| * 由 TabBar 的 pointermove 在指针离开窗口时调用(fire-and-forget) | |||
| */ | |||
| function startTearOff( | |||
| tabId: string, | |||
| screenX: number, | |||
| screenY: number, | |||
| offsetX: number, | |||
| offsetY: number | |||
| ): void { | |||
| const tabData = getTabDataForTearOff(tabId); | |||
| if (!tabData) return; | |||
| window.electronAPI.tearOffTabStart(tabData, screenX, screenY, offsetX, offsetY); | |||
| } | |||
|
|
|||
| /** | |||
| * 取消拖拽分离:指针回到源窗口时调用,关闭已创建的跟随窗口 | |||
| */ | |||
| function cancelTearOff(): void { | |||
| window.electronAPI.tearOffTabCancel(); | |||
| } | |||
|
|
|||
| /** | |||
| * 完成拖拽分离:停止跟随、判断合并或保留新窗口、从源窗口移除 Tab | |||
| * 由 TabBar 的 SortableJS onEnd(鼠标松开)时调用 | |||
| */ | |||
| async function endTearOff(tabId: string, screenX: number, screenY: number): Promise<boolean> { | |||
| try { | |||
| const result = await window.electronAPI.tearOffTabEnd(screenX, screenY); | |||
| if (result.action === "failed") return false; | |||
|
|
|||
| // 成功创建新窗口或合并后,从当前窗口移除该 Tab | |||
| const isLastTab = tabs.value.length === 1; | |||
| if (isLastTab) { | |||
| window.electronAPI.closeDiscard(); | |||
| } else { | |||
| close(tabId); | |||
| } | |||
|
|
|||
| return true; | |||
| } catch (error) { | |||
| console.error("[useTab] Tab 拖拽分离失败:", error); | |||
| return false; | |||
| } | |||
| } | |||
|
|
|||
| // ── Tab 合并接收 ────────────────────────────────────────── | |||
|
|
|||
| /** 监听来自其他窗口的 Tab 合并请求 */ | |||
| function handleTabMergeIn(tabData: TearOffTabData) { | |||
| const tab: Tab = { | |||
| id: randomUUID(), // 生成新 ID,避免跨窗口冲突 | |||
| name: tabData.name, | |||
| filePath: tabData.filePath, | |||
| content: tabData.content, | |||
| originalContent: tabData.originalContent, | |||
| isModified: tabData.isModified, | |||
| scrollRatio: tabData.scrollRatio ?? 0, | |||
| readOnly: tabData.readOnly, | |||
| isNewlyLoaded: !tabData.isModified, | |||
| fileTraits: tabData.fileTraits, | |||
| }; | |||
|
|
|||
| tabs.value.push(tab); | |||
| activeTabId.value = tab.id; | |||
| if (!tabData.isModified) { | |||
| scheduleNewlyLoadedCleanup(tab.id); | |||
| } | |||
|
|
|||
| if (tab.filePath) { | |||
| setCurrentMarkdownFilePath(tab.filePath); | |||
| } | |||
|
|
|||
| // 通知 useContent 同步内容 | |||
| emitter.emit("tab:switch", tab); | |||
| } | |||
| window.electronAPI.on("tab:merge-in", handleTabMergeIn); | |||
|
|
|||
| // ── Tab 合并预览(悬停即合并,离开撤销)────────────────── | |||
|
|
|||
| let mergePreviewState: { | |||
| tabId: string; | |||
| prevActiveId: string | null; | |||
| isExisting: boolean; | |||
| } | null = null; | |||
|
|
|||
| function handleTabMergePreview(tabData: TearOffTabData, screenX?: number, screenY?: number) { | |||
| const prevActiveId = activeTabId.value; | |||
|
|
|||
| // 若已存在同文件路径的 Tab,直接激活它作为预览目标 | |||
| if (tabData.filePath) { | |||
| const existing = isFileAlreadyOpen(tabData.filePath); | |||
| if (existing) { | |||
| mergePreviewState = { | |||
| tabId: existing.id, | |||
| prevActiveId, | |||
| isExisting: true, | |||
| }; | |||
| switchToTab(existing.id); | |||
| return; | |||
| } | |||
| } | |||
|
|
|||
| // 计算插入位置 | |||
| let insertIndex = tabs.value.length; | |||
| if (screenX !== undefined && screenY !== undefined) { | |||
| // 将屏幕坐标转换为页面内坐标 | |||
| // 注意:window.screenX 是窗口左上角在屏幕的 X,加上边框偏移才是内容区 | |||
| // 简化处理:假设标准边框或无边框,contentX ≈ screenX - window.screenX | |||
| // 更精确的方式难以在纯 IPC 中获取,但如果不考虑标题栏(无框窗口),这样近似可行 | |||
| const clientX = screenX - window.screenX; | |||
|
|
|||
| // 获取所有 tab 元素 (排除预览 Tab 自身) | |||
| const tabElements = Array.from(document.querySelectorAll("[data-tab-id]:not(.merge-preview)")); | |||
|
|
|||
| // 找到插入点 | |||
| for (let i = 0; i < tabElements.length; i++) { | |||
| const rect = tabElements[i].getBoundingClientRect(); | |||
| const centerX = rect.left + rect.width / 2; | |||
| if (clientX < centerX) { | |||
| insertIndex = i; | |||
| break; | |||
| } | |||
| } | |||
| } | |||
|
|
|||
| // 取消旧预览,总是重建以确保正确的插入位置 | |||
| if (mergePreviewState && !mergePreviewState.isExisting) { | |||
| close(mergePreviewState.tabId); | |||
| } | |||
|
|
|||
| const tab: Tab = { | |||
| id: randomUUID(), | |||
| name: tabData.name, | |||
| filePath: tabData.filePath, | |||
| content: tabData.content, | |||
| originalContent: tabData.originalContent, | |||
| isModified: tabData.isModified, | |||
| scrollRatio: tabData.scrollRatio ?? 0, | |||
| readOnly: tabData.readOnly, | |||
| isNewlyLoaded: !tabData.isModified, | |||
| isMergePreview: true, | |||
| fileTraits: tabData.fileTraits, | |||
| }; | |||
There was a problem hiding this comment.
There is significant code duplication between initFromTearOff (lines 48-59), handleTabMergeIn (lines 590-601), and handleTabMergePreview (lines 671-683). All three create a Tab object from TearOffTabData with identical logic. Consider extracting this into a shared helper function like createTabFromTearOffData(tabData: TearOffTabData): Tab to improve maintainability and reduce the risk of inconsistencies.
| targetWin.close(); | ||
| cleanupWindowState(winId); | ||
| // 如果是最后一个窗口(非 macOS),退出应用 | ||
| const remaining = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()); | ||
| if (remaining.length === 0 && process.platform !== "darwin") { | ||
| app.quit(); | ||
| } |
There was a problem hiding this comment.
The cleanupWindowState function is called after closing the window on line 102, but the cleanup happens after the window is already closed. If the window close event triggers additional IPC calls or operations, those might reference the already-destroyed window with a dangling ID in windowSaveState or windowClosingSet. Consider moving cleanupWindowState(winId) to be registered as a window 'closed' event listener in the window tracking system instead, similar to how it's done in windowManager.ts lines 103-108.
| targetWin.close(); | |
| cleanupWindowState(winId); | |
| // 如果是最后一个窗口(非 macOS),退出应用 | |
| const remaining = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()); | |
| if (remaining.length === 0 && process.platform !== "darwin") { | |
| app.quit(); | |
| } | |
| targetWin.once("closed", () => { | |
| cleanupWindowState(winId); | |
| // 如果是最后一个窗口(非 macOS),退出应用 | |
| const remaining = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()); | |
| if (remaining.length === 0 && process.platform !== "darwin") { | |
| app.quit(); | |
| } | |
| }); | |
| targetWin.close(); |
🎉新增多窗口功能,可以让 Tab 独立新的窗口,也可以合并 Tab 窗口