Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ export default defineConfig({
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
migrationV2: resolve(__dirname, 'src/renderer/migrationV2.html')
migrationV2: resolve(__dirname, 'src/renderer/migrationV2.html'),
detachedWindow: resolve(__dirname, 'src/renderer/detachedWindow.html')
},
onwarn(warning, warn) {
if (warning.code === 'COMMONJS_VARIABLE_IN_ESM') return
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/IpcChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ export enum IpcChannel {
Windows_MaximizedChanged = 'window:maximized-changed',
Windows_NavigateToAbout = 'window:navigate-to-about',

// Tab
Tab_Attach = 'tab:attach',
Tab_Detach = 'tab:detach',
Tab_DragStart = 'tab:drag-start',
Tab_GetDragData = 'tab:get-drag-data',
Tab_DragEnd = 'tab:drag-end',

KnowledgeBase_Create = 'knowledge-base:create',
KnowledgeBase_Reset = 'knowledge-base:reset',
KnowledgeBase_Delete = 'knowledge-base:delete',
Expand Down
4 changes: 2 additions & 2 deletions packages/shared/data/cache/cacheSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,11 @@ export const DefaultSharedCache: SharedCacheSchema = {
* This ensures type safety and prevents key conflicts
*/
export type RendererPersistCacheSchema = {
'ui.tab.state': CacheValueTypes.TabsState
'ui.tab.pinned_tabs': CacheValueTypes.Tab[]
}

export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
'ui.tab.state': { tabs: [], activeTabId: '' }
'ui.tab.pinned_tabs': []
}

// ============================================================================
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { isDev, isLinux, isWin } from './constant'

import process from 'node:process'

import './services/DetachedWindowManager'
import { registerIpc } from './ipc'
import { agentService } from './services/agents'
import { apiServerService } from './services/ApiServerService'
Expand Down
6 changes: 3 additions & 3 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,9 +739,9 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App)
mainWindow.unmaximize()
})

ipcMain.handle(IpcChannel.Windows_Close, () => {
checkMainWindow()
mainWindow.close()
ipcMain.handle(IpcChannel.Windows_Close, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.close()
})

ipcMain.handle(IpcChannel.Windows_IsMaximized, () => {
Expand Down
206 changes: 206 additions & 0 deletions src/main/services/DetachedWindowManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { is } from '@electron-toolkit/utils'
import { titleBarOverlayDark, titleBarOverlayLight } from '@main/config'
import { isMac } from '@main/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow, ipcMain, nativeImage, nativeTheme, shell } from 'electron'
import * as fs from 'fs'
import * as os from 'os'
import { join } from 'path'

import icon from '../../../build/icon.png?asset'
import { loggerService } from './LoggerService'
import { windowService } from './WindowService'

const logger = loggerService.withContext('DetachedWindowManager')

interface TabDragData {
id: string
url: string
title: string
type: string
isPinned?: boolean
}

export class DetachedWindowManager {
private windows: Map<string, BrowserWindow> = new Map()

// Tab 拖拽缓存
private dragCache: Map<string, TabDragData> = new Map()
private tempDir: string

constructor() {
// 初始化临时目录
this.tempDir = join(os.tmpdir(), 'cherry-studio-drag')
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
this.registerIpc()
}

private registerIpc() {
ipcMain.on(IpcChannel.Tab_Detach, (_, payload) => {
this.createWindow(payload)
})

ipcMain.handle(IpcChannel.Tab_Attach, (_, payload) => {
const mainWindow = windowService.getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IpcChannel.Tab_Attach, payload)
}
})

// Tab 拖拽开始
ipcMain.on(IpcChannel.Tab_DragStart, (event, tabData: TabDragData) => {
const dragId = `${Date.now()}-${tabData.id}`
logger.info('Tab drag start', { dragId, tabId: tabData.id })

// 1. 缓存 Tab 数据
this.dragCache.set(dragId, tabData)

// 2. 创建临时文件(startDrag 需要真实文件路径)
const tempFile = join(this.tempDir, `tab-${dragId}.json`)
fs.writeFileSync(tempFile, JSON.stringify({ dragId, tabId: tabData.id }))

// 3. 创建 Ghost Image(Tab 预览图)
const dragIcon = this.createTabIcon()

// 4. 调用 startDrag - 操作系统接管拖拽
event.sender.startDrag({
file: tempFile,
icon: dragIcon
})

// 5. 广播给其他窗口(用于显示 Drop 指示器)
BrowserWindow.getAllWindows().forEach((win) => {
if (win.webContents.id !== event.sender.id && !win.isDestroyed()) {
win.webContents.send(IpcChannel.Tab_DragStart, { dragId, tab: tabData })
}
})
})

// 获取拖拽数据
ipcMain.handle(IpcChannel.Tab_GetDragData, (_, dragId: string) => {
const data = this.dragCache.get(dragId)
logger.info('Get drag data', { dragId, found: !!data })
return data || null
})

// 拖拽结束
ipcMain.on(IpcChannel.Tab_DragEnd, (_, dragId: string) => {
logger.info('Tab drag end', { dragId })

// 清理缓存
this.dragCache.delete(dragId)

// 清理临时文件
const tempFile = join(this.tempDir, `tab-${dragId}.json`)
if (fs.existsSync(tempFile)) {
try {
fs.unlinkSync(tempFile)
} catch (err) {
logger.error('Failed to delete temp file', { tempFile, error: err })
}
}

// 通知所有窗口退出接收模式
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send(IpcChannel.Tab_DragEnd)
}
})
})
}

/**
* 创建 Tab 拖拽时的 Ghost Image
* 使用应用图标作为基础,后续可优化为动态生成 Tab 预览
*/
private createTabIcon(): Electron.NativeImage {
try {
// 使用应用图标
const iconImage = nativeImage.createFromPath(icon)
return iconImage.resize({ width: 32, height: 32 })
} catch (err) {
logger.error('Failed to create tab icon', { error: err })
// 返回空图标
return nativeImage.createEmpty()
}
}

public createWindow(payload: any) {
const { id: tabId, url, title, isPinned, type } = payload

// 基础参数构建
const params = new URLSearchParams({
url,
tabId,
title: title || '',
type: type || 'route',
isPinned: String(!!isPinned)
})

const win = new BrowserWindow({
width: 800,
height: 600,
minWidth: 400,
minHeight: 300,
show: false, // Wait for ready-to-show
autoHideMenuBar: true,
title: title || 'Cherry Studio Tab',
icon,
transparent: false,
vibrancy: 'sidebar',
visualEffectState: 'active',
// For Windows and Linux, we use frameless window with custom controls
// For Mac, we keep the native title bar style
...(isMac
? {
titleBarStyle: 'hidden',
titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight,
trafficLightPosition: { x: 8, y: 13 }
}
: {
frame: false // Frameless window for Windows and Linux
}),
backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF',
darkTheme: nativeTheme.shouldUseDarkColors,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false, // 根据需求调整
webviewTag: true,
backgroundThrottling: false
}
})

// 加载 detachedWindow.html
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/detachedWindow.html?${params.toString()}`)
} else {
win.loadFile(join(__dirname, '../renderer/detachedWindow.html'), {
search: params.toString()
})
}

win.on('ready-to-show', () => {
win.show()
})

// 处理窗口打开链接
win.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})

win.on('closed', () => {
this.windows.delete(tabId)
})

this.windows.set(tabId, win)
logger.info(`Created detached window for tab ${tabId}`, payload)

return win
}
}

export const detachedWindowManager = new DetachedWindowManager()
27 changes: 27 additions & 0 deletions src/renderer/detachedWindow.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' 'unsafe-inline' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; media-src 'self' file:; frame-src * file:" />
<title>Cherry Studio - Tab</title>

<style>
html,
body {
margin: 0;
}
</style>
</head>

<body>
<div id="root"></div>
<script>
console.time('init')
</script>
<script type="module" src="/src/init.ts"></script>
<script type="module" src="/src/windows/detachedWindow/entryPoint.tsx"></script>
</body>
</html>
9 changes: 6 additions & 3 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { TabsProvider } from './context/TabsContext'
import { ThemeProvider } from './context/ThemeProvider'

const logger = loggerService.withContext('App.tsx')
Expand Down Expand Up @@ -41,9 +42,11 @@ function App(): React.ReactElement {
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<AppShell />
</TopViewContainer>
<TabsProvider>
<TopViewContainer>
<AppShell />
</TopViewContainer>
</TabsProvider>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
Expand Down
11 changes: 5 additions & 6 deletions src/renderer/src/components/app/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,8 @@ const Sidebar: FC = () => {
const MainMenus: FC = () => {
const { hideMinappPopup } = useMinappPopup()
const { minappShow } = useMinapps()
const { tabs, activeTabId, updateTab } = useTabs()
const { activeTab, updateTab } = useTabs()

// 获取当前 Tab 的 URL 作为 pathname
const activeTab = tabs.find((t) => t.id === activeTabId)
const pathname = activeTab?.url || '/'

const [visibleSidebarIcons] = usePreference('ui.sidebar.icons.visible')
Expand Down Expand Up @@ -162,9 +160,10 @@ const MainMenus: FC = () => {

// 在当前 Tab 内跳转
const to = async (path: string) => {
await modelGenerating()
if (activeTabId) {
updateTab(activeTabId, { url: path, title: getDefaultRouteTitle(path) })
// await modelGenerating()
console.log('activeTabId', activeTab)
if (activeTab?.id) {
updateTab(activeTab.id, { url: path, title: getDefaultRouteTitle(path) })
}
}

Expand Down
Loading