From b116335f9dbde4f483c0b2b8e7bfca5d321c5dfc Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Thu, 12 Mar 2026 17:52:53 -0500 Subject: Add bidirectional file sync, OT system, comments, and real-time collaboration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement full Overleaf integration with Socket.IO v0.9 real-time sync: - FileSyncBridge for bidirectional temp dir ↔ Overleaf sync via chokidar + diff-match-patch - OT state machine, transform functions, and CM6 adapter for collaborative editing - Comment system with highlights, tooltips, and review panel - Project list, file tree management, and socket-based compilation - 3-layer loop prevention (write guards, content equality, debounce) Co-Authored-By: Claude Opus 4.6 --- src/renderer/src/App.css | 681 +++++++++++++++++++++++ src/renderer/src/App.tsx | 368 ++++++------ src/renderer/src/components/Editor.tsx | 264 +++++++-- src/renderer/src/components/FileTree.tsx | 371 ++++++++---- src/renderer/src/components/OverleafConnect.tsx | 171 ------ src/renderer/src/components/PdfViewer.tsx | 34 +- src/renderer/src/components/ProjectList.tsx | 284 ++++++++++ src/renderer/src/components/ReviewPanel.tsx | 309 ++++++++++ src/renderer/src/components/StatusBar.tsx | 20 +- src/renderer/src/components/Terminal.tsx | 7 +- src/renderer/src/components/Toolbar.tsx | 64 +-- src/renderer/src/extensions/addCommentTooltip.ts | 97 ++++ src/renderer/src/extensions/commentHighlights.ts | 227 ++++++++ src/renderer/src/extensions/otSyncExtension.ts | 39 ++ src/renderer/src/ot/cmAdapter.ts | 70 +++ src/renderer/src/ot/otClient.ts | 135 +++++ src/renderer/src/ot/overleafSync.ts | 147 +++++ src/renderer/src/ot/transform.ts | 174 ++++++ src/renderer/src/ot/types.ts | 53 ++ src/renderer/src/stores/appStore.ts | 136 ++++- 20 files changed, 3072 insertions(+), 579 deletions(-) delete mode 100644 src/renderer/src/components/OverleafConnect.tsx create mode 100644 src/renderer/src/components/ProjectList.tsx create mode 100644 src/renderer/src/components/ReviewPanel.tsx create mode 100644 src/renderer/src/extensions/addCommentTooltip.ts create mode 100644 src/renderer/src/extensions/commentHighlights.ts create mode 100644 src/renderer/src/extensions/otSyncExtension.ts create mode 100644 src/renderer/src/ot/cmAdapter.ts create mode 100644 src/renderer/src/ot/otClient.ts create mode 100644 src/renderer/src/ot/overleafSync.ts create mode 100644 src/renderer/src/ot/transform.ts create mode 100644 src/renderer/src/ot/types.ts (limited to 'src/renderer') diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index a0fb0f9..9ecaefc 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -151,6 +151,7 @@ html, body, #root { .main-content { flex: 1; overflow: hidden; + display: flex; } /* ── Resize Handles ──────────────────────────────────────────── */ @@ -402,6 +403,19 @@ html, body, #root { margin: 4px 0; } +.main-doc-badge { + display: inline-block; + font-size: 9px; + padding: 1px 4px; + margin-left: 6px; + border-radius: 3px; + background: var(--accent); + color: white; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.5px; +} + /* ── Modal ────────────────────────────────────────────────────── */ .modal-overlay { @@ -718,6 +732,337 @@ html, body, #root { white-space: pre-wrap; } +/* ── Projects Page ──────────────────────────────────────────── */ + +.projects-page { + height: 100%; + background: var(--bg-primary); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.projects-drag-bar { + height: 40px; + -webkit-app-region: drag; + flex-shrink: 0; +} + +.projects-container { + flex: 1; + max-width: 860px; + width: 100%; + margin: 0 auto; + padding: 0 32px 32px; + overflow-y: auto; +} + +.projects-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.projects-header h1 { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); +} + +.projects-header-actions { + display: flex; + gap: 8px; +} + +.projects-toolbar { + display: flex; + gap: 8px; + margin-bottom: 16px; + align-items: center; +} + +.projects-search { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-primary); + font-size: 13px; + color: var(--text-primary); + font-family: var(--font-sans); + outline: none; +} +.projects-search:focus { + border-color: var(--accent); +} + +.btn-sm { + padding: 6px 14px; + font-size: 12px; +} + +.projects-list { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.projects-table-header { + display: flex; + align-items: center; + padding: 8px 16px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + user-select: none; +} +.projects-table-header span { + cursor: pointer; +} +.projects-table-header span:hover { + color: var(--text-primary); +} + +.projects-col-name { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} +.projects-col-owner { + width: 140px; + flex-shrink: 0; + font-size: 12px; + color: var(--text-muted); +} +.projects-col-updated { + width: 160px; + flex-shrink: 0; + font-size: 12px; + color: var(--text-muted); + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.projects-item { + display: flex; + align-items: center; + padding: 10px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.projects-item:last-child { + border-bottom: none; +} +.projects-item:hover { + background: var(--bg-secondary); +} + +.projects-item .projects-col-name { + display: flex; + align-items: center; + gap: 8px; +} + +.projects-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.projects-access-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: var(--bg-tertiary, #3a3730); + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + +.projects-date { + font-size: 12px; + color: var(--text-secondary); +} + +.projects-updated-by { + font-size: 10px; + color: var(--text-muted); +} + +.projects-empty { + padding: 40px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.projects-busy { + display: flex; + flex-direction: column; + align-items: center; + padding: 60px 0; + gap: 16px; +} + +/* ── Overleaf Socket / Project Picker ────────────────────────── */ + +.overleaf-dialog-wide { + width: 560px; +} + +.overleaf-back { + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + margin-right: 8px; +} +.overleaf-back:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.overleaf-mode-cards { + display: flex; + gap: 16px; + padding: 16px 0; +} + +.overleaf-mode-card { + flex: 1; + padding: 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; + text-align: center; +} +.overleaf-mode-card:hover { + border-color: var(--accent); + background: var(--bg-secondary); + box-shadow: var(--shadow-sm); +} + +.overleaf-mode-icon { + font-size: 28px; + margin-bottom: 8px; +} + +.overleaf-mode-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.overleaf-mode-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +.overleaf-project-search { + display: flex; + gap: 8px; + margin-bottom: 12px; +} +.overleaf-project-search .modal-input { + flex: 1; +} + +.overleaf-project-list { + max-height: 350px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.overleaf-project-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.overleaf-project-item:last-child { + border-bottom: none; +} +.overleaf-project-item:hover { + background: var(--bg-secondary); +} + +.overleaf-project-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.overleaf-project-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + display: flex; + gap: 8px; +} + +.overleaf-project-loading { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +/* ── Connection Dot ─────────────────────────────────────────── */ + +.connection-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} +.connection-dot-green { + background: var(--success); + box-shadow: 0 0 4px rgba(91, 138, 60, 0.4); +} +.connection-dot-yellow { + background: var(--warning); + box-shadow: 0 0 4px rgba(184, 134, 11, 0.4); + animation: pulse 1.5s ease-in-out infinite; +} +.connection-dot-red { + background: var(--danger); + box-shadow: 0 0 4px rgba(199, 86, 67, 0.4); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.status-connection { + display: flex; + align-items: center; + font-size: 11px; + color: var(--text-muted); +} + /* ── Editor ──────────────────────────────────────────────────── */ .editor-panel { @@ -725,6 +1070,7 @@ html, body, #root { display: flex; flex-direction: column; background: var(--bg-primary); + position: relative; } .editor-empty { @@ -1078,6 +1424,341 @@ html, body, #root { border-radius: var(--radius-sm); } +/* ── Review Panel ────────────────────────────────────────────── */ + +.review-sidebar { + width: 280px; + min-width: 280px; + height: 100%; + border-left: 1px solid var(--border); +} + +.review-panel { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-primary); +} + +.review-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 36px; + padding: 0 10px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.review-header-actions { + display: flex; + gap: 2px; +} + +.review-threads { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.review-empty { + padding: 24px 16px; + text-align: center; + color: var(--text-muted); + font-size: 12px; +} + +.review-login { + padding: 24px 16px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.review-login p { + color: var(--text-muted); + font-size: 12px; +} + +.review-error { + padding: 8px 10px; + background: #FFF0EE; + color: var(--danger); + font-size: 11px; + border-bottom: 1px solid var(--border); +} + +.review-thread { + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + background: var(--bg-secondary); + overflow: hidden; +} + +.review-thread-resolved { + opacity: 0.6; +} +.review-thread-highlighted { + border-color: rgba(243, 177, 17, 0.8); + box-shadow: 0 0 0 1px rgba(243, 177, 17, 0.3), 0 1px 4px rgba(243, 177, 17, 0.15); +} + +.review-context { + padding: 6px 8px; + background: #f0ead6; + border-radius: 4px; + margin: 6px 8px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + border-left: 3px solid #b8a070; +} +.review-context:hover { + background: #e8e0c8; +} +.review-context-file { + font-size: 11px; + font-weight: 600; + color: #666; +} +.review-context-text { + font-size: 12px; + color: #8a7050; + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-message { + padding: 8px 10px; +} +.review-message + .review-message { + border-top: 1px solid var(--border); +} +.review-message-first { + background: var(--bg-primary); +} + +.review-message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 3px; +} + +.review-user { + font-size: 11px; + font-weight: 600; + color: var(--accent-blue); + font-family: var(--font-mono); +} + +.review-time { + font-size: 10px; + color: var(--text-muted); +} + +.review-message-content { + font-size: 12px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} + +.review-thread-actions { + display: flex; + gap: 4px; + padding: 4px 8px; + border-top: 1px solid var(--border); + background: var(--bg-tertiary); +} + +.review-action-btn { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + font-family: var(--font-sans); +} +.review-action-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} +.review-action-delete:hover { + color: var(--danger); +} +.review-message-actions-inline { + display: flex; + align-items: center; + gap: 2px; +} +.review-msg-action { + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + padding: 0 3px; + font-size: 12px; + opacity: 0; + transition: opacity 0.15s; + line-height: 1; +} +.review-message:hover .review-msg-action { + opacity: 0.6; +} +.review-msg-action:hover { + opacity: 1 !important; + color: var(--text-primary); +} +.review-msg-delete:hover { + color: var(--danger) !important; +} +.review-edit-inline { + display: flex; + gap: 4px; + margin-top: 4px; +} + +.review-reply { + display: flex; + padding: 6px 8px; + gap: 4px; + border-top: 1px solid var(--border); +} + +.review-reply-input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px 8px; + font-size: 12px; + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + outline: none; +} +.review-reply-input:focus { + border-color: var(--accent-blue); +} + +.review-reply-send { + border: none; + background: var(--accent); + color: var(--bg-primary); + font-size: 11px; + padding: 4px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-family: var(--font-sans); +} +.review-reply-send:hover { + background: var(--accent-hover); +} + +.review-section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + padding: 8px 4px 4px; +} + +/* ── Add Comment Overlay ─────────────────────────────────────── */ +.add-comment-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 100; +} +.add-comment-card { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + width: 320px; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); +} +.add-comment-quote { + font-size: 12px; + color: #8a7050; + font-style: italic; + padding: 6px 8px; + background: #f0ead6; + border-left: 3px solid #b8a070; + border-radius: 4px; + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.add-comment-input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-size: 13px; + font-family: var(--font-sans); + background: var(--bg-secondary); + color: var(--text-primary); + outline: none; + resize: none; + box-sizing: border-box; +} +.add-comment-input:focus { + border-color: var(--accent-blue); +} +.add-comment-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + margin-top: 8px; +} +.add-comment-cancel { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 12px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-family: var(--font-sans); +} +.add-comment-cancel:hover { + background: var(--bg-hover); +} +.add-comment-submit { + border: none; + background: var(--accent); + color: white; + font-size: 12px; + padding: 4px 14px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-family: var(--font-sans); +} +.add-comment-submit:hover { + background: var(--accent-hover); +} +.add-comment-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ── Terminal ────────────────────────────────────────────────── */ .terminal-panel { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a809ffe..455213b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,66 +1,96 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, Component, type ReactNode } from 'react' import { PanelGroup, Panel, PanelResizeHandle } from 'react-resizable-panels' import { useAppStore } from './stores/appStore' -import { showConfirm } from './hooks/useModal' import ModalProvider from './components/ModalProvider' -import OverleafConnect from './components/OverleafConnect' +import ProjectList from './components/ProjectList' import Toolbar from './components/Toolbar' import FileTree from './components/FileTree' import Editor from './components/Editor' import PdfViewer from './components/PdfViewer' import Terminal from './components/Terminal' +import ReviewPanel from './components/ReviewPanel' import StatusBar from './components/StatusBar' +import type { OverleafDocSync } from './ot/overleafSync' + +export const activeDocSyncs = new Map() + +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { return { error } } + render() { + if (this.state.error) { + return ( +
+

Render Error

+

{this.state.error.message}

+
{this.state.error.stack}
+
+ ) + } + return this.props.children + } +} export default function App() { const { - projectPath, - setProjectPath, - setFiles, + screen, + setScreen, + setStatusMessage, showTerminal, showFileTree, - setIsGitRepo, - setGitStatus, - setStatusMessage + showReviewPanel, } = useAppStore() - const refreshFiles = useCallback(async () => { - if (!projectPath) return - const files = await window.api.readDir(projectPath) - setFiles(files) - }, [projectPath, setFiles]) + const [checkingSession, setCheckingSession] = useState(true) - // Load project + // Check session on startup useEffect(() => { - if (!projectPath) return + window.api.overleafHasWebSession().then(({ loggedIn }) => { + setScreen(loggedIn ? 'projects' : 'login') + setCheckingSession(false) + }) + }, [setScreen]) - refreshFiles() - window.api.watchStart(projectPath) + // OT event listeners (always active when in editor) + useEffect(() => { + if (screen !== 'editor') return - // Check git status - window.api.gitStatus(projectPath).then(({ isGit, status }) => { - setIsGitRepo(isGit) - setGitStatus(status) + const unsubRemoteOp = window.api.onOtRemoteOp((data) => { + const sync = activeDocSyncs.get(data.docId) + if (sync) sync.onRemoteOps(data.ops as any, data.version) }) - // Auto-detect main document if not set - if (!useAppStore.getState().mainDocument) { - window.api.findMainTex(projectPath).then((mainTex) => { - if (mainTex) { - useAppStore.getState().setMainDocument(mainTex) - setStatusMessage(`Main document: ${mainTex.split('/').pop()}`) - } - }) - } + const unsubAck = window.api.onOtAck((data) => { + const sync = activeDocSyncs.get(data.docId) + if (sync) sync.onAck() + }) - const unsub = window.api.onWatchChange(() => { - refreshFiles() + const unsubState = window.api.onOtConnectionState((state) => { + useAppStore.getState().setConnectionState(state as any) + if (state === 'reconnecting') setStatusMessage('Reconnecting...') + else if (state === 'connected') setStatusMessage('Connected') + else if (state === 'disconnected') setStatusMessage('Disconnected') + }) + + const unsubRejoined = window.api.onOtDocRejoined((data) => { + const sync = activeDocSyncs.get(data.docId) + if (sync) sync.reset(data.version, data.content) + }) + + // Listen for external edits from file sync bridge (disk changes) + const unsubExternalEdit = window.api.onSyncExternalEdit((data) => { + const sync = activeDocSyncs.get(data.docId) + if (sync) sync.replaceContent(data.content) }) return () => { - unsub() - window.api.watchStop() + unsubRemoteOp() + unsubAck() + unsubState() + unsubRejoined() + unsubExternalEdit() } - }, [projectPath, refreshFiles, setIsGitRepo, setGitStatus]) + }, [screen, setStatusMessage]) // Compile log listener useEffect(() => { @@ -73,11 +103,8 @@ export default function App() { // Keyboard shortcuts useEffect(() => { const handler = (e: KeyboardEvent) => { + if (screen !== 'editor') return if (e.metaKey || e.ctrlKey) { - if (e.key === 's') { - e.preventDefault() - handleSave() - } if (e.key === 'b') { e.preventDefault() handleCompile() @@ -90,159 +117,170 @@ export default function App() { } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, []) - - const handleSave = async () => { - const { activeTab, fileContents } = useAppStore.getState() - if (!activeTab || !fileContents[activeTab]) return - await window.api.writeFile(activeTab, fileContents[activeTab]) - useAppStore.getState().markModified(activeTab, false) - setStatusMessage('Saved') - } + }, [screen]) const handleCompile = async () => { - const { activeTab, mainDocument } = useAppStore.getState() - const target = mainDocument || activeTab - if (!target || !target.endsWith('.tex')) return - - useAppStore.getState().setCompiling(true) - useAppStore.getState().clearCompileLog() - setStatusMessage('Compiling...') - - const result = await window.api.compile(target) as { - success: boolean; log: string; missingPackages?: string[] + const state = useAppStore.getState() + const mainDoc = state.mainDocument || state.overleafProject?.rootDocId + if (!mainDoc) { + setStatusMessage('No main document set') + return } + const relPath = state.docPathMap[mainDoc] || mainDoc + state.setCompiling(true) + state.clearCompileLog() + setStatusMessage('Compiling...') - console.log('[compile] result.success:', result.success, 'log length:', result.log?.length, 'missingPkgs:', result.missingPackages) + const result = await window.api.overleafSocketCompile(relPath) - // Ensure compile log is populated (fallback if streaming events missed) const storeLog = useAppStore.getState().compileLog - console.log('[compile] storeLog length:', storeLog?.length) if (!storeLog && result.log) { useAppStore.getState().appendCompileLog(result.log) } + if (result.pdfPath) { + useAppStore.getState().setPdfPath(result.pdfPath) + } + useAppStore.getState().setCompiling(false) + setStatusMessage(result.success ? 'Compiled successfully' : 'Compilation had errors — check Log tab') + } - // Always try to load PDF BEFORE setting compiling=false - const pdfPath = await window.api.getPdfPath(target) - console.log('[compile] checking pdfPath:', pdfPath) - try { - const s = await window.api.fileStat(pdfPath) - console.log('[compile] PDF exists, size:', s.size) - useAppStore.getState().setPdfPath(pdfPath) - } catch (err) { - console.log('[compile] PDF not found:', err) + const handleLogin = async () => { + const result = await window.api.overleafWebLogin() + if (result.success) { + setScreen('projects') } + } - // Now signal compilation done - useAppStore.getState().setCompiling(false) + const handleOpenProject = async (pid: string) => { + setScreen('editor') - // Missing packages detected — offer to install - if (result.missingPackages && result.missingPackages.length > 0) { - const pkgs = result.missingPackages - const ok = await showConfirm( - 'Missing LaTeX Packages', - `The following packages are needed:\n\n${pkgs.join(', ')}\n\nInstall them now? (may require your password in terminal)`, - ) - if (ok) { - setStatusMessage(`Installing ${pkgs.join(', ')}...`) - const installResult = await window.api.installTexPackages(pkgs) - if (installResult.success) { - setStatusMessage('Packages installed. Recompiling...') - handleCompile() - return - } else if (installResult.message === 'need_sudo') { - setStatusMessage('Need sudo — installing via terminal...') - useAppStore.getState().showTerminal || useAppStore.getState().toggleTerminal() - await window.api.ptyWrite(`sudo tlmgr install ${pkgs.join(' ')}\n`) - setStatusMessage('Enter your password in terminal, then recompile with Cmd+B') - return - } else { - setStatusMessage('Package install failed') + // Auto-open root doc + const store = useAppStore.getState() + const rootDocId = store.overleafProject?.rootDocId + if (rootDocId) { + const relPath = store.docPathMap[rootDocId] + if (relPath) { + setStatusMessage('Opening root document...') + const result = await window.api.otJoinDoc(rootDocId) + if (result.success && result.content !== undefined) { + const fileName = relPath.split('/').pop() || relPath + useAppStore.getState().setFileContent(relPath, result.content) + useAppStore.getState().openFile(relPath, fileName) + useAppStore.getState().setMainDocument(rootDocId) + if (result.version !== undefined) { + useAppStore.getState().setDocVersion(rootDocId, result.version) + } + if (result.ranges?.comments) { + const contexts: Record = {} + for (const c of result.ranges.comments) { + if (c.op?.t) { + contexts[c.op.t] = { file: relPath, text: c.op.c || '', pos: c.op.p || 0 } + } + } + useAppStore.getState().setCommentContexts(contexts) + } + setStatusMessage(`${store.overleafProject?.name || 'Project'}`) } } } - - if (result.success) { - setStatusMessage('Compiled successfully') - } else { - setStatusMessage('Compilation had errors — check Log tab') - } } - const [showOverleaf, setShowOverleaf] = useState(false) + const handleBackToProjects = async () => { + await window.api.otDisconnect() + activeDocSyncs.forEach((s) => s.destroy()) + activeDocSyncs.clear() + useAppStore.getState().resetEditorState() + setScreen('projects') + } - const handleOpenProject = async () => { - const path = await window.api.openProject() - if (path) setProjectPath(path) + if (checkingSession) { + return ( +
+
+
+
+
+
+ ) } - return ( - <> - - {showOverleaf && ( - { - setShowOverleaf(false) - setProjectPath(path) - }} - onCancel={() => setShowOverleaf(false)} - /> - )} - {!projectPath ? ( + // Login screen + if (screen === 'login') { + return ( + <> +

ClaudeTeX

-

LaTeX editor with AI and Overleaf sync

- -
- ) : ( -
- -
- - {showFileTree && ( - <> - - - - - - )} - - - - - - - - - - - - - - {showTerminal && ( - <> - - - - - - )} - - - -
- + + ) + } + + // Project list screen + if (screen === 'projects') { + return ( + <> + + + + ) + } + + // Editor screen + return ( + + +
+ +
+ + {showFileTree && ( + <> + + + + + + )} + + + + + + + + + + + + + + {showTerminal && ( + <> + + + + + + )} + + + + {showReviewPanel && ( +
+ +
+ )}
- )} - + +
+
) } diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index 30a1e8b..e381802 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection } from '@codemirror/view' import { EditorState } from '@codemirror/state' import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' @@ -7,46 +7,47 @@ import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete' import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' import { stex } from '@codemirror/legacy-modes/mode/stex' import { useAppStore } from '../stores/appStore' +import { + commentHighlights, + commentRangesField, + setCommentRangesEffect, + highlightThreadEffect, + type CommentRange, +} from '../extensions/commentHighlights' +import { addCommentTooltip, setAddCommentCallback } from '../extensions/addCommentTooltip' +import { otSyncExtension, remoteUpdateAnnotation } from '../extensions/otSyncExtension' +import { OverleafDocSync } from '../ot/overleafSync' +import { activeDocSyncs } from '../App' -// Cosmic Latte light theme const cosmicLatteTheme = EditorView.theme({ - '&': { - height: '100%', - fontSize: '13.5px', - backgroundColor: '#FFF8E7' - }, + '&': { height: '100%', fontSize: '13.5px', backgroundColor: '#FFF8E7' }, '.cm-content': { caretColor: '#3B3228', fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace', - color: '#3B3228', - padding: '8px 0' + color: '#3B3228', padding: '8px 0' }, '.cm-cursor': { borderLeftColor: '#3B3228' }, '.cm-activeLine': { backgroundColor: '#F5EDD6' }, '.cm-activeLineGutter': { backgroundColor: '#F5EDD6' }, '.cm-selectionBackground, ::selection': { backgroundColor: '#B8D4E3 !important' }, '.cm-gutters': { - backgroundColor: '#F5EDD6', - color: '#A09880', - border: 'none', - borderRight: '1px solid #D6CEBC', - paddingRight: '8px' + backgroundColor: '#F5EDD6', color: '#A09880', border: 'none', + borderRight: '1px solid #D6CEBC', paddingRight: '8px' }, '.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' }, '.cm-foldGutter': { width: '16px' }, '.cm-matchingBracket': { backgroundColor: '#D4C9A8', outline: 'none' }, - // LaTeX syntax colors — warm earthy palette on Cosmic Latte - '.cm-keyword': { color: '#8B2252' }, // commands: \begin, \section - '.cm-atom': { color: '#B8860B' }, // constants - '.cm-string': { color: '#5B8A3C' }, // strings / text args - '.cm-comment': { color: '#A09880', fontStyle: 'italic' }, // % comments - '.cm-bracket': { color: '#4A6FA5' }, // braces {} - '.cm-tag': { color: '#8B2252' }, // LaTeX tags - '.cm-builtin': { color: '#6B5B3E' }, // builtins - '.ͼ5': { color: '#8B2252' }, // keywords like \begin - '.ͼ6': { color: '#4A6FA5' }, // braces/brackets - '.ͼ7': { color: '#5B8A3C' }, // strings - '.ͼ8': { color: '#A09880' }, // comments + '.cm-keyword': { color: '#8B2252' }, + '.cm-atom': { color: '#B8860B' }, + '.cm-string': { color: '#5B8A3C' }, + '.cm-comment': { color: '#A09880', fontStyle: 'italic' }, + '.cm-bracket': { color: '#4A6FA5' }, + '.cm-tag': { color: '#8B2252' }, + '.cm-builtin': { color: '#6B5B3E' }, + '.ͼ5': { color: '#8B2252' }, + '.ͼ6': { color: '#4A6FA5' }, + '.ͼ7': { color: '#5B8A3C' }, + '.ͼ8': { color: '#A09880' }, }, { dark: false }) export default function Editor() { @@ -55,20 +56,73 @@ export default function Editor() { const { activeTab, fileContents, openTabs, setFileContent, markModified } = useAppStore() const pendingGoTo = useAppStore((s) => s.pendingGoTo) + const commentContexts = useAppStore((s) => s.commentContexts) + const hoveredThreadId = useAppStore((s) => s.hoveredThreadId) + const overleafProjectId = useAppStore((s) => s.overleafProjectId) + const pathDocMap = useAppStore((s) => s.pathDocMap) + const docVersions = useAppStore((s) => s.docVersions) const content = activeTab ? fileContents[activeTab] ?? '' : '' + const docSyncRef = useRef(null) + + // Add comment state + const [newComment, setNewComment] = useState<{ from: number; to: number; text: string } | null>(null) + const [commentInput, setCommentInput] = useState('') + const [submittingComment, setSubmittingComment] = useState(false) + + // Get docId for current file + const getDocIdForFile = useCallback(() => { + if (!activeTab) return null + return pathDocMap[activeTab] || null + }, [activeTab, pathDocMap]) + + // Set up the add-comment callback + useEffect(() => { + setAddCommentCallback((from, to, text) => { + setNewComment({ from, to, text }) + setCommentInput('') + }) + return () => setAddCommentCallback(null) + }, []) + + const handleSubmitComment = useCallback(async () => { + if (!newComment || !commentInput.trim() || !overleafProjectId) return + const docId = getDocIdForFile() + if (!docId) return + setSubmittingComment(true) + const result = await window.api.overleafAddComment( + overleafProjectId, docId, newComment.from, newComment.text, commentInput.trim() + ) + setSubmittingComment(false) + if (result.success) { + setNewComment(null) + setCommentInput('') + } + }, [newComment, commentInput, overleafProjectId, getDocIdForFile]) - // Handle goTo when file is already open (no editor recreation needed) + // Handle goTo when file is already open useEffect(() => { if (!pendingGoTo || !viewRef.current) return if (activeTab !== pendingGoTo.file) return const view = viewRef.current - const lineNum = Math.min(pendingGoTo.line, view.state.doc.lines) - const lineInfo = view.state.doc.line(lineNum) - view.dispatch({ - selection: { anchor: lineInfo.from }, - effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) - }) + if (pendingGoTo.pos !== undefined) { + const docLen = view.state.doc.length + const from = Math.min(pendingGoTo.pos, docLen) + const to = pendingGoTo.highlight + ? Math.min(from + pendingGoTo.highlight.length, docLen) + : from + view.dispatch({ + selection: { anchor: from, head: to }, + effects: EditorView.scrollIntoView(from, { y: 'center' }) + }) + } else if (pendingGoTo.line) { + const lineNum = Math.min(pendingGoTo.line, view.state.doc.lines) + const lineInfo = view.state.doc.line(lineNum) + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) + }) + } view.focus() useAppStore.getState().setPendingGoTo(null) }, [pendingGoTo]) @@ -83,12 +137,48 @@ export default function Editor() { const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged && activeTab) { + const isRemote = update.transactions.some(tr => tr.annotation(remoteUpdateAnnotation)) const newContent = update.state.doc.toString() setFileContent(activeTab, newContent) - markModified(activeTab, true) + if (!isRemote) { + markModified(activeTab, true) + } + // Notify bridge of content change (both local and remote) for disk sync + const docId = pathDocMap[activeTab] + if (docId) { + window.api.syncContentChanged(docId, newContent) + } + } + if (update.selectionSet) { + const ranges = update.state.field(commentRangesField) + const cursorPos = update.state.selection.main.head + let found: string | null = null + for (const r of ranges) { + if (cursorPos >= r.from && cursorPos <= r.to) { + found = r.threadId + break + } + } + const store = useAppStore.getState() + if (found !== store.focusedThreadId) { + store.setFocusedThreadId(found) + } } }) + // Set up OT sync + let otExt: any[] = [] + if (activeTab) { + const docId = pathDocMap[activeTab] + const version = docId ? docVersions[docId] : undefined + if (docId && version !== undefined) { + const docSync = new OverleafDocSync(docId, version) + docSyncRef.current = docSync + activeDocSyncs.set(docId, docSync) + otExt = [otSyncExtension(docSync)] + } + } + const state = EditorState.create({ doc: content, extensions: [ @@ -113,45 +203,86 @@ export default function Editor() { ]), cosmicLatteTheme, updateListener, - EditorView.lineWrapping + EditorView.lineWrapping, + commentHighlights(), + overleafProjectId ? addCommentTooltip() : [], + ...otExt, ] }) - const view = new EditorView({ - state, - parent: editorRef.current - }) + const view = new EditorView({ state, parent: editorRef.current }) viewRef.current = view - // Apply pending navigation (from log click) + if (docSyncRef.current) { + docSyncRef.current.setView(view) + } + + // Apply pending navigation const goTo = useAppStore.getState().pendingGoTo - if (goTo && goTo.file === activeTab && goTo.line) { + if (goTo && goTo.file === activeTab && (goTo.line || goTo.pos !== undefined)) { requestAnimationFrame(() => { - const lineNum = Math.min(goTo.line, view.state.doc.lines) - const lineInfo = view.state.doc.line(lineNum) - view.dispatch({ - selection: { anchor: lineInfo.from }, - effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) - }) + if (goTo.pos !== undefined) { + const docLen = view.state.doc.length + const from = Math.min(goTo.pos, docLen) + const to = goTo.highlight ? Math.min(from + goTo.highlight.length, docLen) : from + view.dispatch({ + selection: { anchor: from, head: to }, + effects: EditorView.scrollIntoView(from, { y: 'center' }) + }) + } else if (goTo.line) { + const lineNum = Math.min(goTo.line, view.state.doc.lines) + const lineInfo = view.state.doc.line(lineNum) + view.dispatch({ + selection: { anchor: lineInfo.from }, + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'center' }) + }) + } view.focus() useAppStore.getState().setPendingGoTo(null) }) } return () => { + if (docSyncRef.current) { + const docId = pathDocMap[activeTab!] + if (docId) activeDocSyncs.delete(docId) + docSyncRef.current.destroy() + docSyncRef.current = null + } viewRef.current?.destroy() viewRef.current = null } - }, [activeTab]) // Re-create when tab changes + }, [activeTab]) + + // Sync comment ranges to CodeMirror + useEffect(() => { + if (!viewRef.current || !activeTab) return + const ranges: CommentRange[] = [] + for (const [threadId, ctx] of Object.entries(commentContexts)) { + if (ctx.file === activeTab && ctx.text) { + ranges.push({ + threadId, + from: ctx.pos, + to: ctx.pos + ctx.text.length, + text: ctx.text, + }) + } + } + viewRef.current.dispatch({ effects: setCommentRangesEffect.of(ranges) }) + }, [commentContexts, activeTab]) + + // Sync hover state + useEffect(() => { + if (!viewRef.current) return + viewRef.current.dispatch({ effects: highlightThreadEffect.of(hoveredThreadId) }) + }, [hoveredThreadId]) if (!activeTab) { return (

Open a file to start editing

-

- Cmd+S Save · Cmd+B Compile · Cmd+` Terminal -

+

Cmd+B Compile · Cmd+` Terminal

) @@ -183,6 +314,37 @@ export default function Editor() { ))}
+ {newComment && ( +
+
+
+ “{newComment.text.length > 60 ? newComment.text.slice(0, 60) + '...' : newComment.text}” +
+