diff options
| author | haoyuren <13851610112@163.com> | 2026-08-18 20:24:40 +0800 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-08-18 20:24:40 +0800 |
| commit | ec8214a4d111d92ce7c44de29d1706f241aa9514 (patch) | |
| tree | d5ffce9791bb94b4a4336ae26d091ba2e010d364 /src/renderer | |
| parent | df3092328865949c088eed321e6675e3b7758c36 (diff) | |
Add browser-style project tabs: persistent home + one tab per projectv0.6.0
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 <noreply@anthropic.com>
Diffstat (limited to 'src/renderer')
| -rw-r--r-- | src/renderer/src/App.css | 111 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 167 | ||||
| -rw-r--r-- | src/renderer/src/components/ProjectList.tsx | 39 |
3 files changed, 279 insertions, 38 deletions
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<string, OverleafDocSync>() // Global remote cursor state — shared between App and Editor export const remoteCursors = new Map<string, RemoteCursor & { docId: string }>() +// 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 ( + <div className={`wt-bar${isMac ? ' wt-bar-mac' : ''}`}> + <button + className={`wt-tab wt-home${active === 'home' ? ' active' : ''}`} + onClick={() => window.api.tabsActivate('home')} + title="Projects" + > + <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M3 10.5 12 3l9 7.5" /> + <path d="M5 9.5V21h14V9.5" /> + </svg> + <span className="wt-tab-title">Projects</span> + </button> + {tabs.map((t) => ( + <div + key={t.id} + className={`wt-tab${active === t.id ? ' active' : ''}`} + onClick={() => window.api.tabsActivate(t.id)} + > + <span className="wt-tab-title">{t.title}</span> + <button + className="wt-tab-close" + title="Close project" + onClick={(ev) => { + ev.stopPropagation() + window.api.tabsClose(t.id) + }} + > + × + </button> + </div> + ))} + </div> + ) +} + 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() { <div className="welcome-drag-bar" /> <div className="welcome-content"> <div className="overleaf-spinner" /> + {initialProjectId && <p style={{ marginTop: 16 }}>Opening project...</p>} + </div> + </div> + ) + } + + // Project window failed to connect — offer retry or close + if (initialProjectId && connectError && screen !== 'editor') { + return ( + <div className="welcome-screen"> + <div className="welcome-drag-bar" /> + <div className="welcome-content"> + <h2>Could not open project</h2> + <p style={{ color: '#a33', maxWidth: 480, wordBreak: 'break-word' }}>{connectError}</p> + <div style={{ display: 'flex', gap: 12, marginTop: 16 }}> + <button + className="btn btn-primary" + onClick={async () => { + setCheckingSession(true) + await connectAndOpen(initialProjectId) + setCheckingSession(false) + }} + > + Retry + </button> + <button className="btn" onClick={() => window.api.closeWindow()}> + Close Tab + </button> + </div> </div> </div> ) @@ -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 ( <> <ModalProvider /> - <ProjectList onOpenProject={handleOpenProject} /> + <div className={`home-root${showTabBar ? ' with-tab-bar' : ''}`}> + {showTabBar && <TabBar tabs={tabStrip.tabs} active={tabStrip.active} />} + <div className="home-content"> + <ProjectList /> + </div> + </div> </> ) } 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<OverleafProject[]>([]) const [tags, setTags] = useState<Tag[]>([]) 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) { /> </span> <span className="pl-col-name"> - <button className="pl-project-link" onClick={() => handleOpen(p.id)}>{p.name}</button> + <button className="pl-project-link" onClick={() => handleOpen(p.id, p.name)}>{p.name}</button> <span className="pl-row-tags"> {projectTags(p).map((tag) => ( <span key={tag._id} className="pl-chip"> |
