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/renderer/src/App.css | 111 ++++++++++++++++++ src/renderer/src/App.tsx | 167 +++++++++++++++++++++++++++- src/renderer/src/components/ProjectList.tsx | 39 ++----- 3 files changed, 279 insertions(+), 38 deletions(-) (limited to 'src/renderer') diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index 69f0139..d790a14 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -84,6 +84,9 @@ html, body, #root { .welcome-content { text-align: center; + display: flex; + flex-direction: column; + align-items: center; } .welcome-logo { @@ -979,6 +982,114 @@ html, body, #root { white-space: pre-wrap; } +/* ── Tab bar (browser-tab model: home + one tab per project) ─── */ +/* Height must equal TAB_BAR_HEIGHT in src/main/index.ts — project + WebContentsViews are laid out directly below this strip. */ + +.home-root { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.home-content { + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* Tab bar replaces the list page's own drag strip */ +.with-tab-bar .projects-drag-bar { + display: none; +} + +/* "wt-" = window tabs — deliberately distinct from the editor's file-tab + classes (.tab-bar/.tab), which must NOT pick up app-region: drag */ +.wt-bar { + height: 38px; + box-sizing: border-box; + display: flex; + align-items: flex-end; + gap: 3px; + padding: 5px 8px 0; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); + -webkit-app-region: drag; + flex-shrink: 0; + overflow: hidden; +} + +/* Leave room for macOS traffic lights */ +.wt-bar-mac { + padding-left: 84px; +} + +.wt-tab { + -webkit-app-region: no-drag; + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + min-width: 0; + max-width: 200px; + border: 1px solid transparent; + border-bottom: none; + border-radius: 7px 7px 0 0; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-family: var(--font-sans); + cursor: pointer; + user-select: none; +} + +.wt-tab:hover { + background: var(--bg-hover); +} + +.wt-tab.active { + background: var(--bg-primary); + border-color: var(--border); + color: var(--text-primary); + /* Blend into the content below by covering the bar's bottom border */ + position: relative; + box-shadow: 0 1px 0 var(--bg-primary); +} + +.wt-home { + flex-shrink: 0; +} + +.wt-tab-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.wt-tab-close { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + font-size: 13px; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.wt-tab-close:hover { + background: var(--bg-active); + color: var(--text-primary); +} + /* ── Projects Page ──────────────────────────────────────────── */ .projects-page { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 13b65fd..3aa68e2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -35,6 +35,51 @@ export const activeDocSyncs = new Map() // Global remote cursor state — shared between App and Editor export const remoteCursors = new Map() +// Set when this window was opened for a specific project (browser-tab model: +// the list window opens each project in its own window via ?projectId=) +const initialProjectId = new URLSearchParams(window.location.search).get('projectId') + +/** Browser-style tab strip: persistent home tab + one tab per open project. + * Rendered by the home renderer only, in the top TAB_BAR_HEIGHT (38px) strip + * that project views never cover. Hidden entirely when no project is open. */ +function TabBar({ tabs, active }: { tabs: Array<{ id: string; title: string }>; active: string }) { + const isMac = navigator.platform.toLowerCase().includes('mac') + return ( +
+ + {tabs.map((t) => ( +
window.api.tabsActivate(t.id)} + > + {t.title} + +
+ ))} +
+ ) +} + class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { state = { error: null as Error | null } static getDerivedStateFromError(error: Error) { return { error } } @@ -65,6 +110,18 @@ export default function App() { } = useAppStore() const [checkingSession, setCheckingSession] = useState(true) + const [connectError, setConnectError] = useState('') + + // Tab strip state, mirrored from main (home renderer only) + const [tabStrip, setTabStrip] = useState<{ tabs: Array<{ id: string; title: string }>; active: string }>({ + tabs: [], + active: 'home' + }) + useEffect(() => { + if (initialProjectId) return // project tabs don't render the strip + window.api.tabsList().then(setTabStrip).catch(() => {}) + return window.api.onTabsChanged(setTabStrip) + }, []) // Prevent Electron from navigating to dropped files useEffect(() => { @@ -77,14 +134,36 @@ export default function App() { } }, []) - // Check session on startup + // Check session on startup. Project windows (?projectId=) connect straight + // into the editor; the list window shows the dashboard. + const startupRanRef = useRef(false) useEffect(() => { - window.api.overleafHasWebSession().then(({ loggedIn }) => { - setScreen(loggedIn ? 'projects' : 'login') + // Once per window — StrictMode double-invokes effects and a second + // ot:connect mid-flight would race the first + if (startupRanRef.current) return + startupRanRef.current = true + window.api.overleafHasWebSession().then(async ({ loggedIn }) => { + if (!loggedIn) { + setScreen('login') + setCheckingSession(false) + return + } + if (initialProjectId) { + await connectAndOpen(initialProjectId) + } else { + setScreen('projects') + } setCheckingSession(false) }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [setScreen]) + // Window title mirrors the open project, like a browser tab + const projectName = useAppStore((s) => s.overleafProject?.name) + useEffect(() => { + document.title = projectName ? `${projectName} — LatteX` : 'LatteX' + }, [projectName]) + // OT event listeners (always active when in editor) useEffect(() => { if (screen !== 'editor') return @@ -389,10 +468,44 @@ export default function App() { const handleLogin = async () => { const result = await window.api.overleafWebLogin() if (result.success) { - setScreen('projects') + if (initialProjectId) { + setCheckingSession(true) + await connectAndOpen(initialProjectId) + setCheckingSession(false) + } else { + setScreen('projects') + } } } + // Connect this window to its project and enter the editor (project windows only) + const connectAndOpen = async (pid: string) => { + setConnectError('') + setStatusMessage('Connecting to project...') + const result = await window.api.otConnect(pid) + if (!result.success) { + if (result.message === 'already_open') { + // Another window owns this project and was focused — close this one + window.api.closeWindow() + return + } + setConnectError(result.message || 'Failed to connect') + return + } + const store = useAppStore.getState() + if (result.files) store.setFiles(result.files as any) + if (result.project) store.setOverleafProject(result.project) + if (result.docPathMap && result.pathDocMap) store.setDocMaps(result.docPathMap, result.pathDocMap) + if (result.fileRefs) store.setFileRefs(result.fileRefs) + if (result.rootFolderId) store.setRootFolderId(result.rootFolderId) + store.setOverleafProjectId(pid) + store.setConnectionState('connected') + if (result.syncDir) store.setSyncDir(result.syncDir) + if (result.cachedPdfPath) store.setPdfPath(result.cachedPdfPath) + setStatusMessage('Connected') + await handleOpenProject(pid) + } + const handleOpenProject = async (pid: string) => { setScreen('editor') @@ -448,6 +561,12 @@ export default function App() { } const handleBackToProjects = async () => { + if (initialProjectId) { + // Project window: closing it is the "back" action — the list window + // stays open, and main tears the session down on 'closed' + window.api.closeWindow() + return + } await window.api.otDisconnect() activeDocSyncs.forEach((s) => s.destroy()) activeDocSyncs.clear() @@ -461,6 +580,35 @@ export default function App() {
+ {initialProjectId &&

Opening project...

} +
+
+ ) + } + + // Project window failed to connect — offer retry or close + if (initialProjectId && connectError && screen !== 'editor') { + return ( +
+
+
+

Could not open project

+

{connectError}

+
+ + +
) @@ -497,12 +645,19 @@ export default function App() { ) } - // Project list screen + // Project list screen (home tab). The tab strip only appears once a + // project is open — home alone shows no tab bar. if (screen === 'projects') { + const showTabBar = tabStrip.tabs.length > 0 return ( <> - +
+ {showTabBar && } +
+ +
+
) } diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx index e4cc0b9..bae528d 100644 --- a/src/renderer/src/components/ProjectList.tsx +++ b/src/renderer/src/components/ProjectList.tsx @@ -87,11 +87,7 @@ const ICONS = { // ── Component ─────────────────────────────────────────────────────── -interface Props { - onOpenProject: (projectId: string) => void -} - -export default function ProjectList({ onOpenProject }: Props) { +export default function ProjectList() { const [projects, setProjects] = useState([]) const [tags, setTags] = useState([]) const [loading, setLoading] = useState(true) @@ -380,32 +376,11 @@ export default function ProjectList({ onOpenProject }: Props) { // ── Open project ── - const handleOpen = async (pid: string) => { + // Open the project in a tab (browser-tab model). The tab's own renderer + // does the connecting; if the project is already open, its tab is activated. + const handleOpen = async (pid: string, name?: string) => { setError('') - setBusy(true) - setBusyText('Connecting to project...') - setStatusMessage('Connecting...') - - const result = await window.api.otConnect(pid) - setBusy(false) - - if (result.success) { - const store = useAppStore.getState() - if (result.files) store.setFiles(result.files as any) - if (result.project) store.setOverleafProject(result.project) - if (result.docPathMap && result.pathDocMap) store.setDocMaps(result.docPathMap, result.pathDocMap) - if (result.fileRefs) store.setFileRefs(result.fileRefs) - if (result.rootFolderId) store.setRootFolderId(result.rootFolderId) - store.setOverleafProjectId(pid) - store.setConnectionState('connected') - if (result.syncDir) store.setSyncDir(result.syncDir) - if (result.cachedPdfPath) store.setPdfPath(result.cachedPdfPath) - setStatusMessage('Connected') - onOpenProject(pid) - } else { - setStatusMessage('Connection failed') - setError(result.message || 'Failed to connect') - } + await window.api.openProjectTab(pid, name) } // ── Modal actions ── @@ -526,7 +501,7 @@ export default function ProjectList({ onOpenProject }: Props) { } const handleLogout = async () => { - await window.api.otDisconnect() + await window.api.overleafLogout() useAppStore.getState().resetEditorState() useAppStore.getState().setScreen('login') } @@ -923,7 +898,7 @@ export default function ProjectList({ onOpenProject }: Props) { /> - + {projectTags(p).map((tag) => ( -- cgit v1.2.3