From ec8214a4d111d92ce7c44de29d1706f241aa9514 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Tue, 18 Aug 2026 20:24:40 +0800 Subject: Add browser-style project tabs: persistent home + one tab per project The project list is now a persistent home tab; each opened project runs in its own WebContentsView tab below a 38px tab strip (hidden when only home is open). Background tabs stay fully live (sync, collab, terminals). Main process: per-project singletons (socket, sync bridge, compilation manager, MCP state, compile watcher) become per-tab ProjectSession objects keyed by webContents.id; PTYs are scoped per webContents. Hardened against races found in review: concurrent ot:connect dedupe, teardown/reopen serialization on the shared sync dir, destroyed-window guards on all bridge sends, real logout that closes project tabs. Renderer: tabs strip in the home renderer (wt-* classes, distinct from the editor's .tab-bar file tabs), project tabs boot from ?projectId= and connect straight into the editor; Back closes the tab. Co-Authored-By: Claude Fable 5 --- src/main/fileSyncBridge.ts | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) (limited to 'src/main/fileSyncBridge.ts') diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts index ae22eb8..08bf69e 100644 --- a/src/main/fileSyncBridge.ts +++ b/src/main/fileSyncBridge.ts @@ -10,7 +10,7 @@ import { createHash } from 'crypto' import * as chokidar from 'chokidar' import { diff_match_patch } from 'diff-match-patch' import { net } from 'electron' -import type { BrowserWindow } from 'electron' +import type { WebContents } from 'electron' import type { OverleafSocket } from './overleafSocket' import { OtClient } from './otClient' import type { OtOp } from './otTypes' @@ -87,7 +87,7 @@ export class FileSyncBridge { private pathFileRefMap: Record // relPath → fileRefId private folderPathMap: Record // folderId → relDirPath (no trailing slash) private pathFolderMap: Record // relDirPath (no trailing slash) → folderId - private mainWindow: BrowserWindow + private target: WebContents // the owning tab's webContents private projectId: string private cookie: string private csrfToken: string @@ -103,7 +103,7 @@ export class FileSyncBridge { docPathMap: Record, pathDocMap: Record, fileRefs: Array<{ id: string; path: string }>, - mainWindow: BrowserWindow, + target: WebContents, projectId: string, cookie: string, csrfToken: string, @@ -114,7 +114,7 @@ export class FileSyncBridge { this.tmpDir = tmpDir this.docPathMap = docPathMap this.pathDocMap = pathDocMap - this.mainWindow = mainWindow + this.target = target this.projectId = projectId this.cookie = cookie this.csrfToken = csrfToken @@ -818,7 +818,7 @@ export class FileSyncBridge { // lastKnownContent go stale or we'll re-detect the same "change" indefinitely. this.lastKnownContent.set(relPath, newContent) bridgeLog(`[FileSyncBridge] → sending sync:externalEdit to renderer for ${relPath}`) - this.mainWindow.webContents.send('sync:externalEdit', { docId, content: newContent, baseContent: lastKnown ?? '' }) + this.sendToWindow('sync:externalEdit', { docId, content: newContent, baseContent: lastKnown ?? '' }) } else { // Doc NOT open in editor → bridge handles OT directly const oldContent = lastKnown ?? '' @@ -1168,7 +1168,7 @@ export class FileSyncBridge { name: string, parentFolderId?: string ): void { - this.mainWindow.webContents.send('sync:entityCreated', { + this.sendToWindow('sync:entityCreated', { kind, entityId, relPath, @@ -1178,7 +1178,7 @@ export class FileSyncBridge { } private notifyEntityRemoved(kind: 'doc' | 'file' | 'folder', entityId: string, relPath: string): void { - this.mainWindow.webContents.send('sync:entityRemoved', { kind, entityId, relPath }) + this.sendToWindow('sync:entityRemoved', { kind, entityId, relPath }) } private notifyEntityRenamed( @@ -1188,7 +1188,7 @@ export class FileSyncBridge { newPath: string, newName: string ): void { - this.mainWindow.webContents.send('sync:entityRenamed', { + this.sendToWindow('sync:entityRenamed', { kind, entityId, oldPath, @@ -1204,7 +1204,7 @@ export class FileSyncBridge { newPath: string, parentFolderId: string ): void { - this.mainWindow.webContents.send('sync:entityMoved', { + this.sendToWindow('sync:entityMoved', { kind, entityId, oldPath, @@ -1584,8 +1584,18 @@ export class FileSyncBridge { } private notifySyncStatus(relPath: string, status: 'retrying' | 'synced' | 'failed', attempts?: number): void { - if (this.mainWindow.isDestroyed() || this.mainWindow.webContents.isDestroyed()) return - this.mainWindow.webContents.send('sync:fileStatus', { relPath, status, attempts }) + this.sendToWindow('sync:fileStatus', { relPath, status, attempts }) + } + + /** + * Send to the owning tab, guarded — the bridge can outlive its webContents + * briefly (a debounced disk change firing during tab close), and an + * unguarded send to destroyed webContents throws inside an unawaited async + * path, which would take down the whole main process. + */ + private sendToWindow(channel: string, payload: unknown): void { + if (this.target.isDestroyed()) return + this.target.send(channel, payload) } /** Create a text doc on Overleaf and sync its content */ -- cgit v1.2.3