summaryrefslogtreecommitdiff
path: root/src/renderer
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-08-18 20:24:40 +0800
committerhaoyuren <13851610112@163.com>2026-08-18 20:24:40 +0800
commitec8214a4d111d92ce7c44de29d1706f241aa9514 (patch)
treed5ffce9791bb94b4a4336ae26d091ba2e010d364 /src/renderer
parentdf3092328865949c088eed321e6675e3b7758c36 (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.css111
-rw-r--r--src/renderer/src/App.tsx167
-rw-r--r--src/renderer/src/components/ProjectList.tsx39
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">