diff options
Diffstat (limited to 'src/renderer')
20 files changed, 3072 insertions, 579 deletions
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<string, OverleafDocSync>() + +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 ( + <div style={{ padding: 40, color: '#c00', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}> + <h2>Render Error</h2> + <p>{this.state.error.message}</p> + <pre>{this.state.error.stack}</pre> + </div> + ) + } + 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<string, { file: string; text: string; pos: number }> = {} + 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 ( + <div className="welcome-screen"> + <div className="welcome-drag-bar" /> + <div className="welcome-content"> + <div className="overleaf-spinner" /> + </div> + </div> + ) } - return ( - <> - <ModalProvider /> - {showOverleaf && ( - <OverleafConnect - onConnected={(path) => { - setShowOverleaf(false) - setProjectPath(path) - }} - onCancel={() => setShowOverleaf(false)} - /> - )} - {!projectPath ? ( + // Login screen + if (screen === 'login') { + return ( + <> + <ModalProvider /> <div className="welcome-screen"> <div className="welcome-drag-bar" /> <div className="welcome-content"> <h1>ClaudeTeX</h1> - <p>LaTeX editor with AI and Overleaf sync</p> - <button className="btn btn-primary btn-large" onClick={handleOpenProject}> - Open Project - </button> - <button className="btn btn-secondary btn-large" onClick={() => setShowOverleaf(true)}> - Clone from Overleaf + <p>LaTeX editor with real-time Overleaf sync</p> + <button className="btn btn-primary btn-large" onClick={handleLogin}> + Sign in to Overleaf </button> </div> </div> - ) : ( - <div className="app"> - <Toolbar onCompile={handleCompile} onSave={handleSave} onOpenProject={handleOpenProject} /> - <div className="main-content"> - <PanelGroup direction="horizontal"> - {showFileTree && ( - <> - <Panel defaultSize={18} minSize={12} maxSize={35}> - <FileTree /> - </Panel> - <PanelResizeHandle className="resize-handle resize-handle-h" /> - </> - )} - <Panel minSize={30}> - <PanelGroup direction="vertical"> - <Panel defaultSize={showTerminal ? 70 : 100} minSize={30}> - <PanelGroup direction="horizontal"> - <Panel defaultSize={50} minSize={25}> - <Editor /> - </Panel> - <PanelResizeHandle className="resize-handle resize-handle-h" /> - <Panel defaultSize={50} minSize={20}> - <PdfViewer /> - </Panel> - </PanelGroup> - </Panel> - {showTerminal && ( - <> - <PanelResizeHandle className="resize-handle resize-handle-v" /> - <Panel defaultSize={30} minSize={15} maxSize={60}> - <Terminal /> - </Panel> - </> - )} - </PanelGroup> - </Panel> - </PanelGroup> - </div> - <StatusBar /> + </> + ) + } + + // Project list screen + if (screen === 'projects') { + return ( + <> + <ModalProvider /> + <ProjectList onOpenProject={handleOpenProject} /> + </> + ) + } + + // Editor screen + return ( + <ErrorBoundary> + <ModalProvider /> + <div className="app"> + <Toolbar onCompile={handleCompile} onBack={handleBackToProjects} /> + <div className="main-content"> + <PanelGroup direction="horizontal"> + {showFileTree && ( + <> + <Panel defaultSize={18} minSize={12} maxSize={35}> + <FileTree /> + </Panel> + <PanelResizeHandle className="resize-handle resize-handle-h" /> + </> + )} + <Panel minSize={30}> + <PanelGroup direction="vertical"> + <Panel defaultSize={showTerminal ? 70 : 100} minSize={30}> + <PanelGroup direction="horizontal"> + <Panel defaultSize={50} minSize={25}> + <Editor /> + </Panel> + <PanelResizeHandle className="resize-handle resize-handle-h" /> + <Panel defaultSize={50} minSize={20}> + <PdfViewer /> + </Panel> + </PanelGroup> + </Panel> + {showTerminal && ( + <> + <PanelResizeHandle className="resize-handle resize-handle-v" /> + <Panel defaultSize={30} minSize={15} maxSize={60}> + <Terminal /> + </Panel> + </> + )} + </PanelGroup> + </Panel> + </PanelGroup> + {showReviewPanel && ( + <div className="review-sidebar"> + <ReviewPanel /> + </div> + )} </div> - )} - </> + <StatusBar /> + </div> + </ErrorBoundary> ) } 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<OverleafDocSync | null>(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 ( <div className="editor-empty"> <div className="editor-empty-content"> <p>Open a file to start editing</p> - <p className="shortcut-hint"> - Cmd+S Save · Cmd+B Compile · Cmd+` Terminal - </p> + <p className="shortcut-hint">Cmd+B Compile · Cmd+` Terminal</p> </div> </div> ) @@ -183,6 +314,37 @@ export default function Editor() { ))} </div> <div ref={editorRef} className="editor-content" /> + {newComment && ( + <div className="add-comment-overlay"> + <div className="add-comment-card"> + <div className="add-comment-quote"> + “{newComment.text.length > 60 ? newComment.text.slice(0, 60) + '...' : newComment.text}” + </div> + <textarea + className="add-comment-input" + value={commentInput} + onChange={(e) => setCommentInput(e.target.value)} + placeholder="Write a comment..." + autoFocus + rows={2} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmitComment() } + if (e.key === 'Escape') setNewComment(null) + }} + /> + <div className="add-comment-actions"> + <button className="add-comment-cancel" onClick={() => setNewComment(null)}>Cancel</button> + <button + className="add-comment-submit" + onClick={handleSubmitComment} + disabled={!commentInput.trim() || submittingComment} + > + {submittingComment ? 'Sending...' : 'Comment'} + </button> + </div> + </div> + </div> + )} </div> ) } diff --git a/src/renderer/src/components/FileTree.tsx b/src/renderer/src/components/FileTree.tsx index 3163485..e2f31f1 100644 --- a/src/renderer/src/components/FileTree.tsx +++ b/src/renderer/src/components/FileTree.tsx @@ -1,19 +1,25 @@ -import { useState, useCallback } from 'react' -import { useAppStore } from '../stores/appStore' -import { showInput, showConfirm } from '../hooks/useModal' - -interface FileNode { - name: string - path: string - isDir: boolean - children?: FileNode[] +import { useState, useCallback, useEffect, useRef } from 'react' +import { useAppStore, type FileNode } from '../stores/appStore' + +interface ContextMenuState { + x: number + y: number + node: FileNode } -function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) { +function FileTreeNode({ + node, + depth, + onContextMenu +}: { + node: FileNode + depth: number + onContextMenu: (e: React.MouseEvent, node: FileNode) => void +}) { const [expanded, setExpanded] = useState(depth < 2) - const { activeTab, mainDocument, openFile, setFileContent, setStatusMessage } = useAppStore() + const { activeTab, openFile, setFileContent, setStatusMessage, mainDocument, docPathMap } = useAppStore() const isActive = activeTab === node.path - const isMainDoc = mainDocument === node.path + const isMainDoc = node.docId && mainDocument === node.docId const handleClick = useCallback(async () => { if (node.isDir) { @@ -21,6 +27,7 @@ function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) { return } + // Binary files — skip const ext = node.name.split('.').pop()?.toLowerCase() if (ext === 'pdf' || ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'svg') { if (ext === 'pdf') { @@ -29,12 +36,34 @@ function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) { return } - try { - const content = await window.api.readFile(node.path) - setFileContent(node.path, content) - openFile(node.path, node.name) - } catch { - setStatusMessage('Failed to read file') + // Join doc via socket + if (node.docId) { + setStatusMessage('Opening document...') + try { + const result = await window.api.otJoinDoc(node.docId) + if (result.success && result.content !== undefined) { + setFileContent(node.path, result.content) + openFile(node.path, node.name) + if (result.version !== undefined) { + useAppStore.getState().setDocVersion(node.docId, result.version) + } + if (result.ranges?.comments) { + const contexts: Record<string, { file: string; text: string; pos: number }> = {} + for (const c of result.ranges.comments) { + if (c.op?.t) { + contexts[c.op.t] = { file: node.path, text: c.op.c || '', pos: c.op.p || 0 } + } + } + const existing = useAppStore.getState().commentContexts + useAppStore.getState().setCommentContexts({ ...existing, ...contexts }) + } + setStatusMessage('Ready') + } else { + setStatusMessage(result.message || 'Failed to open document') + } + } catch { + setStatusMessage('Failed to open document') + } } }, [node, expanded, openFile, setFileContent, setStatusMessage]) @@ -47,119 +76,263 @@ function FileTreeNode({ node, depth }: { node: FileNode; depth: number }) { : ext === 'png' || ext === 'jpg' ? '🖼️' : '📝' - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault() - setContextMenu({ x: e.clientX, y: e.clientY }) - const handler = () => { setContextMenu(null); window.removeEventListener('click', handler) } - window.addEventListener('click', handler) - } - - const handleNewFile = async () => { - setContextMenu(null) - const name = await showInput('New File', 'main.tex') - if (!name) return - const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/')) - await window.api.createFile(dir, name) - } - - const handleNewFolder = async () => { - setContextMenu(null) - const name = await showInput('New Folder', 'figures') - if (!name) return - const dir = node.isDir ? node.path : node.path.substring(0, node.path.lastIndexOf('/')) - await window.api.createDir(dir, name) - } - - const handleRename = async () => { - setContextMenu(null) - const newName = await showInput('Rename', node.name, node.name) - if (!newName || newName === node.name) return - const dir = node.path.substring(0, node.path.lastIndexOf('/')) - await window.api.renameFile(node.path, dir + '/' + newName) - } - - const handleDelete = async () => { - setContextMenu(null) - const ok = await showConfirm('Delete', `Delete "${node.name}"?`, true) - if (!ok) return - await window.api.deleteFile(node.path) - } - - const handleSetMainDoc = () => { - setContextMenu(null) - useAppStore.getState().setMainDocument(node.path) - setStatusMessage(`Main document: ${node.name}`) - } - - const handleReveal = () => { - window.api.showInFinder(node.path) - setContextMenu(null) - } - return ( <div> <div className={`file-tree-item ${isActive ? 'active' : ''}`} style={{ paddingLeft: depth * 16 + 8 }} onClick={handleClick} - onContextMenu={handleContextMenu} + onContextMenu={(e) => onContextMenu(e, node)} > <span className="file-icon">{icon}</span> - <span className="file-name">{node.name}</span> - {isMainDoc && <span className="main-doc-badge">main</span>} + <span className="file-name"> + {node.name} + {isMainDoc && <span className="main-doc-badge">main</span>} + </span> </div> - {contextMenu && ( - <div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}> - {!node.isDir && ext === 'tex' && ( - <> - <div className="context-menu-item" onClick={handleSetMainDoc}> - {isMainDoc ? '✓ Main Document' : 'Set as Main Document'} - </div> - <div className="context-menu-separator" /> - </> - )} - <div className="context-menu-item" onClick={handleNewFile}>New File</div> - <div className="context-menu-item" onClick={handleNewFolder}>New Folder</div> - <div className="context-menu-separator" /> - <div className="context-menu-item" onClick={handleRename}>Rename</div> - <div className="context-menu-item danger" onClick={handleDelete}>Delete</div> - <div className="context-menu-separator" /> - <div className="context-menu-item" onClick={handleReveal}>Reveal in Finder</div> - </div> - )} {node.isDir && expanded && node.children?.map((child) => ( - <FileTreeNode key={child.path} node={child} depth={depth + 1} /> + <FileTreeNode key={child.path} node={child} depth={depth + 1} onContextMenu={onContextMenu} /> ))} </div> ) } export default function FileTree() { - const { files, projectPath } = useAppStore() + const { files } = useAppStore() + const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null) + const menuRef = useRef<HTMLDivElement>(null) + + // Close context menu on outside click or escape + useEffect(() => { + if (!ctxMenu) return + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setCtxMenu(null) + } + } + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setCtxMenu(null) + } + document.addEventListener('mousedown', handleClick) + document.addEventListener('keydown', handleKey) + return () => { + document.removeEventListener('mousedown', handleClick) + document.removeEventListener('keydown', handleKey) + } + }, [ctxMenu]) + + const handleContextMenu = useCallback((e: React.MouseEvent, node: FileNode) => { + e.preventDefault() + e.stopPropagation() + setCtxMenu({ x: e.clientX, y: e.clientY, node }) + }, []) + + const closeMenu = () => setCtxMenu(null) + + const handleSetMainDoc = () => { + if (!ctxMenu) return + const node = ctxMenu.node + if (node.docId) { + useAppStore.getState().setMainDocument(node.docId) + useAppStore.getState().setStatusMessage(`Main document set to ${node.name}`) + } + closeMenu() + } + + const handleCopyPath = () => { + if (!ctxMenu) return + navigator.clipboard.writeText(ctxMenu.node.path) + useAppStore.getState().setStatusMessage('Path copied') + closeMenu() + } + + const handleRename = async () => { + if (!ctxMenu) return + const node = ctxMenu.node + const projectId = useAppStore.getState().overleafProjectId + if (!projectId) { closeMenu(); return } + + const newName = prompt('New name:', node.name) + if (!newName?.trim() || newName === node.name) { closeMenu(); return } + + let entityType: string + let entityId: string + if (node.isDir && node.folderId) { + entityType = 'folder' + entityId = node.folderId + } else if (node.docId) { + entityType = 'doc' + entityId = node.docId + } else if (node.fileRefId) { + entityType = 'file' + entityId = node.fileRefId + } else { + closeMenu(); return + } + + const result = await window.api.overleafRenameEntity(projectId, entityType, entityId, newName.trim()) + if (result.success) { + useAppStore.getState().setStatusMessage(`Renamed to ${newName.trim()}`) + // Reconnect to refresh file tree + await reconnectProject(projectId) + } else { + useAppStore.getState().setStatusMessage(`Rename failed: ${result.message}`) + } + closeMenu() + } + + const handleDelete = async () => { + if (!ctxMenu) return + const node = ctxMenu.node + const projectId = useAppStore.getState().overleafProjectId + if (!projectId) { closeMenu(); return } + + if (!confirm(`Delete "${node.name}"?`)) { closeMenu(); return } + + let entityType: string + let entityId: string + if (node.isDir && node.folderId) { + entityType = 'folder' + entityId = node.folderId + } else if (node.docId) { + entityType = 'doc' + entityId = node.docId + } else if (node.fileRefId) { + entityType = 'file' + entityId = node.fileRefId + } else { + closeMenu(); return + } + + const result = await window.api.overleafDeleteEntity(projectId, entityType, entityId) + if (result.success) { + useAppStore.getState().setStatusMessage(`Deleted ${node.name}`) + await reconnectProject(projectId) + } else { + useAppStore.getState().setStatusMessage(`Delete failed: ${result.message}`) + } + closeMenu() + } const handleNewFile = async () => { - if (!projectPath) return - const name = await showInput('New File', 'main.tex') - if (!name) return - await window.api.createFile(projectPath, name) + if (!ctxMenu) return + const node = ctxMenu.node + const projectId = useAppStore.getState().overleafProjectId + if (!projectId) { closeMenu(); return } + + const name = prompt('New file name:', 'untitled.tex') + if (!name?.trim()) { closeMenu(); return } + + const parentId = node.isDir && node.folderId + ? node.folderId + : useAppStore.getState().rootFolderId + + const result = await window.api.overleafCreateDoc(projectId, parentId, name.trim()) + if (result.success) { + useAppStore.getState().setStatusMessage(`Created ${name.trim()}`) + await reconnectProject(projectId) + } else { + useAppStore.getState().setStatusMessage(`Create failed: ${result.message}`) + } + closeMenu() + } + + const handleNewFolder = async () => { + if (!ctxMenu) return + const node = ctxMenu.node + const projectId = useAppStore.getState().overleafProjectId + if (!projectId) { closeMenu(); return } + + const name = prompt('New folder name:', 'new-folder') + if (!name?.trim()) { closeMenu(); return } + + const parentId = node.isDir && node.folderId + ? node.folderId + : useAppStore.getState().rootFolderId + + const result = await window.api.overleafCreateFolder(projectId, parentId, name.trim()) + if (result.success) { + useAppStore.getState().setStatusMessage(`Created folder ${name.trim()}`) + await reconnectProject(projectId) + } else { + useAppStore.getState().setStatusMessage(`Create failed: ${result.message}`) + } + closeMenu() + } + + const handleOpenInOverleaf = () => { + const projectId = useAppStore.getState().overleafProjectId + if (projectId) { + window.api.openExternal(`https://www.overleaf.com/project/${projectId}`) + } + closeMenu() } return ( <div className="file-tree"> <div className="file-tree-header"> <span>FILES</span> - <button className="file-tree-action" onClick={handleNewFile} title="New file">+</button> </div> <div className="file-tree-content"> {files.map((node) => ( - <FileTreeNode key={node.path} node={node} depth={0} /> + <FileTreeNode key={node.path} node={node} depth={0} onContextMenu={handleContextMenu} /> ))} {files.length === 0 && ( <div className="file-tree-empty">No files found</div> )} </div> + + {ctxMenu && ( + <div + ref={menuRef} + className="context-menu" + style={{ left: ctxMenu.x, top: ctxMenu.y }} + > + {ctxMenu.node.docId && ctxMenu.node.name.endsWith('.tex') && ( + <div className="context-menu-item" onClick={handleSetMainDoc}> + Set as Main Document + </div> + )} + <div className="context-menu-item" onClick={handleCopyPath}> + Copy Path + </div> + <div className="context-menu-separator" /> + <div className="context-menu-item" onClick={handleRename}> + Rename + </div> + {ctxMenu.node.isDir && ( + <> + <div className="context-menu-item" onClick={handleNewFile}> + New File + </div> + <div className="context-menu-item" onClick={handleNewFolder}> + New Folder + </div> + </> + )} + <div className="context-menu-separator" /> + <div className="context-menu-item danger" onClick={handleDelete}> + Delete + </div> + <div className="context-menu-separator" /> + <div className="context-menu-item" onClick={handleOpenInOverleaf}> + Open in Overleaf + </div> + </div> + )} </div> ) } + +/** Reconnect to refresh the file tree after a file operation */ +async function reconnectProject(projectId: string) { + const result = await window.api.otConnect(projectId) + 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) + } +} diff --git a/src/renderer/src/components/OverleafConnect.tsx b/src/renderer/src/components/OverleafConnect.tsx deleted file mode 100644 index 6258643..0000000 --- a/src/renderer/src/components/OverleafConnect.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useState, useEffect } from 'react' -import { useAppStore } from '../stores/appStore' - -interface Props { - onConnected: (projectPath: string) => void - onCancel: () => void -} - -export default function OverleafConnect({ onConnected, onCancel }: Props) { - const [projectUrl, setProjectUrl] = useState('') - const [token, setToken] = useState('') - const [hasStoredToken, setHasStoredToken] = useState(false) - const [busy, setBusy] = useState(false) - const [busyText, setBusyText] = useState('') - const [rememberMe, setRememberMe] = useState(true) - const [error, setError] = useState('') - const { setStatusMessage } = useAppStore() - - // Check if we already have stored credentials - useEffect(() => { - window.api.overleafCheck().then(({ loggedIn }) => { - if (loggedIn) setHasStoredToken(true) - }) - }, []) - - const extractProjectId = (url: string): string | null => { - const cleaned = url.trim() - if (!cleaned) return null - const patterns = [ - /overleaf\.com\/project\/([a-zA-Z0-9]+)/, - /overleaf\.com\/read\/([a-zA-Z0-9]+)/, - /git\.overleaf\.com\/([a-zA-Z0-9]+)/, - /^([a-zA-Z0-9]{10,})$/, - ] - for (const p of patterns) { - const m = cleaned.match(p) - if (m) return m[1] - } - return null - } - - const projectId = extractProjectId(projectUrl) - - const handleClone = async () => { - if (!projectUrl.trim()) { - setError('Please paste an Overleaf project URL'); return - } - if (!projectId) { - setError('Could not find project ID in this URL.\nExpected: https://www.overleaf.com/project/abc123...'); return - } - if (!token.trim()) { - setError('Please enter your Git Authentication Token'); return - } - - setError('') - setBusy(true) - setBusyText('Choose where to save...') - setStatusMessage('Connecting to Overleaf...') - - const parentDir = await window.api.selectSaveDir() - if (!parentDir) { - setBusy(false) - return - } - const dest = parentDir + '/overleaf-' + projectId - - setBusyText('Verifying token & cloning...') - - const result = await window.api.overleafCloneWithAuth(projectId, dest, token.trim(), rememberMe) - - setBusy(false) - - if (result.success) { - setStatusMessage('Cloned successfully') - onConnected(dest) - } else { - setStatusMessage('Clone failed') - setError(result.detail || 'Unknown error') - } - } - - const handleClearToken = async () => { - await window.api.overleafLogout() - setHasStoredToken(false) - setToken('') - } - - return ( - <div className="modal-overlay" onClick={onCancel}> - <div className="overleaf-dialog" onClick={(e) => e.stopPropagation()}> - <div className="overleaf-header"> - <h2>Clone from Overleaf</h2> - <button className="overleaf-close" onClick={onCancel}>x</button> - </div> - - {error && <div className="overleaf-error">{error}</div>} - - {busy ? ( - <div className="overleaf-body"> - <div className="overleaf-cloning"> - <div className="overleaf-spinner" /> - <div className="overleaf-log">{busyText}</div> - </div> - </div> - ) : ( - <div className="overleaf-body"> - {/* Project URL */} - <label className="overleaf-label">Project URL</label> - <input - className="modal-input" - type="text" - value={projectUrl} - onChange={(e) => { setProjectUrl(e.target.value); setError('') }} - placeholder="https://www.overleaf.com/project/..." - autoFocus - /> - <div className="overleaf-help"> - Copy from your browser address bar, or from Overleaf Menu → Sync → Git. - </div> - {projectId && ( - <div className="overleaf-id-preview"> - Project ID: <code>{projectId}</code> - </div> - )} - - {/* Token */} - <div className="overleaf-section-title" style={{ marginTop: 20 }}> - Git Authentication Token - {hasStoredToken && ( - <span className="overleaf-saved-hint"> - (saved in Keychain — <button className="overleaf-link-btn" onClick={handleClearToken}>clear</button>) - </span> - )} - </div> - <input - className="modal-input" - type="password" - value={token} - onChange={(e) => setToken(e.target.value)} - placeholder="olp_..." - onKeyDown={(e) => { if (e.key === 'Enter') handleClone() }} - /> - <label className="overleaf-checkbox"> - <input - type="checkbox" - checked={rememberMe} - onChange={(e) => setRememberMe(e.target.checked)} - /> - Remember token (saved in macOS Keychain) - </label> - - <div className="overleaf-help"> - Generate at{' '} - <span className="overleaf-link" onClick={() => window.api.openExternal('https://www.overleaf.com/user/settings')}> - Overleaf Account Settings - </span> - {' '}→ Git Integration. Requires premium. - </div> - - <div className="modal-actions"> - <button className="btn btn-secondary" onClick={onCancel}>Cancel</button> - <button className="btn btn-primary" onClick={handleClone}> - Verify & Clone - </button> - </div> - </div> - )} - </div> - </div> - ) -} diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx index e702f15..ea2820a 100644 --- a/src/renderer/src/components/PdfViewer.tsx +++ b/src/renderer/src/components/PdfViewer.tsx @@ -147,32 +147,26 @@ export default function PdfViewer() { : logEntries.filter((e) => e.level === logFilter) // Navigate to file:line in editor - const handleEntryClick = async (entry: LogEntry) => { + const handleEntryClick = (entry: LogEntry) => { if (!entry.line) return - const { projectPath, mainDocument } = useAppStore.getState() - if (!projectPath) return + const store = useAppStore.getState() - // If no file specified, use the main document - const entryFile = entry.file || (mainDocument ? mainDocument.split('/').pop()! : null) + // If no file specified, try to use the main document's path + const entryFile = entry.file || null if (!entryFile) return - // Try resolving the file path - const candidates = [ - entryFile.startsWith('/') ? entryFile : `${projectPath}/${entryFile}`, - ] - if (mainDocument) { - const dir = mainDocument.substring(0, mainDocument.lastIndexOf('/')) - candidates.push(`${dir}/${entryFile}`) - } + // In socket mode, files are keyed by relative path in fileContents + // Try to find a matching open file + const candidates = [entryFile] + // Also try without leading ./ or path prefix + if (entryFile.startsWith('./')) candidates.push(entryFile.slice(2)) - for (const fullPath of candidates) { - try { - const content = await window.api.readFile(fullPath) - useAppStore.getState().setFileContent(fullPath, content) - useAppStore.getState().openFile(fullPath, fullPath.split('/').pop() || entryFile) - useAppStore.getState().setPendingGoTo({ file: fullPath, line: entry.line! }) + for (const path of candidates) { + if (store.fileContents[path]) { + store.openFile(path, path.split('/').pop() || path) + store.setPendingGoTo({ file: path, line: entry.line! }) return - } catch { /* try next */ } + } } } diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx new file mode 100644 index 0000000..170e3ea --- /dev/null +++ b/src/renderer/src/components/ProjectList.tsx @@ -0,0 +1,284 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' +import { useAppStore } from '../stores/appStore' + +interface OverleafProject { + id: string + name: string + lastUpdated: string + owner?: { firstName: string; lastName: string; email?: string } + lastUpdatedBy?: { firstName: string; lastName: string } | null + accessLevel?: string + source?: string +} + +type SortKey = 'lastUpdated' | 'name' | 'owner' +type SortOrder = 'asc' | 'desc' + +interface Props { + onOpenProject: (projectId: string) => void +} + +export default function ProjectList({ onOpenProject }: Props) { + const [projects, setProjects] = useState<OverleafProject[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [searchFilter, setSearchFilter] = useState('') + const [busy, setBusy] = useState(false) + const [busyText, setBusyText] = useState('') + const [sortBy, setSortBy] = useState<SortKey>('lastUpdated') + const [sortOrder, setSortOrder] = useState<SortOrder>('desc') + const { setStatusMessage } = useAppStore() + + const loadProjects = useCallback(async () => { + setLoading(true) + setError('') + const result = await window.api.overleafListProjects() + setLoading(false) + if (result.success && result.projects) { + setProjects(result.projects) + } else { + setError(result.message || 'Failed to load projects') + } + }, []) + + useEffect(() => { + loadProjects() + }, [loadProjects]) + + const handleOpen = async (pid: 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) + setStatusMessage('Connected') + onOpenProject(pid) + } else { + setStatusMessage('Connection failed') + setError(result.message || 'Failed to connect') + } + } + + const handleCreateProject = async () => { + const name = prompt('Project name:', 'Untitled Project') + if (!name?.trim()) return + + setError('') + setBusy(true) + setBusyText('Creating project...') + + const result = await window.api.overleafCreateProject(name.trim()) + setBusy(false) + + if (result.success && result.projectId) { + setStatusMessage(`Created "${name.trim()}"`) + loadProjects() + } else { + setError(result.message || 'Failed to create project') + } + } + + const handleUploadProject = async () => { + setError('') + setBusy(true) + setBusyText('Uploading project...') + + const result = await window.api.overleafUploadProject() + setBusy(false) + + if (result.success && result.projectId) { + setStatusMessage('Project uploaded') + loadProjects() + } else if (result.message === 'cancelled') { + // user cancelled file dialog + } else { + setError(result.message || 'Failed to upload project') + } + } + + const handleLogout = async () => { + await window.api.otDisconnect() + useAppStore.getState().resetEditorState() + useAppStore.getState().setScreen('login') + } + + const toggleSort = (key: SortKey) => { + if (sortBy === key) { + setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc')) + } else { + setSortBy(key) + setSortOrder(key === 'name' ? 'asc' : 'desc') + } + } + + const ownerName = (p: OverleafProject) => { + if (!p.owner) return '' + return `${p.owner.firstName} ${p.owner.lastName}`.trim() + } + + const sortedAndFiltered = useMemo(() => { + let list = projects.filter((p) => + p.name.toLowerCase().includes(searchFilter.toLowerCase()) + ) + + list.sort((a, b) => { + let cmp = 0 + if (sortBy === 'lastUpdated') { + cmp = new Date(a.lastUpdated).getTime() - new Date(b.lastUpdated).getTime() + } else if (sortBy === 'name') { + cmp = a.name.localeCompare(b.name) + } else if (sortBy === 'owner') { + cmp = ownerName(a).localeCompare(ownerName(b)) + } + return sortOrder === 'asc' ? cmp : -cmp + }) + + return list + }, [projects, searchFilter, sortBy, sortOrder]) + + const formatDate = (d: string) => { + if (!d) return '' + try { + const date = new Date(d) + if (isNaN(date.getTime())) return '' + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffDays = Math.floor(diffMs / 86400000) + if (diffDays === 0) { + const diffH = Math.floor(diffMs / 3600000) + if (diffH === 0) { + const diffM = Math.floor(diffMs / 60000) + return diffM <= 1 ? 'Just now' : `${diffM}m ago` + } + return `${diffH}h ago` + } + if (diffDays === 1) return 'Yesterday' + if (diffDays < 7) return `${diffDays}d ago` + if (diffDays < 365) return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + } catch { return '' } + } + + const personName = (p?: { firstName: string; lastName: string } | null) => { + if (!p) return '' + return `${p.firstName} ${p.lastName}`.trim() + } + + const accessLabel = (level?: string) => { + switch (level) { + case 'owner': return 'Owner' + case 'readAndWrite': return 'Can edit' + case 'readOnly': return 'View only' + default: return level || '' + } + } + + const sortIndicator = (key: SortKey) => { + if (sortBy !== key) return '' + return sortOrder === 'asc' ? ' ↑' : ' ↓' + } + + return ( + <div className="projects-page"> + <div className="projects-drag-bar" /> + <div className="projects-container"> + <div className="projects-header"> + <h1>ClaudeTeX</h1> + <div className="projects-header-actions"> + <button className="btn btn-secondary btn-sm" onClick={handleLogout}> + Sign out + </button> + </div> + </div> + + {error && <div className="overleaf-error" style={{ margin: '0 0 16px' }}>{error}</div>} + + {busy ? ( + <div className="projects-busy"> + <div className="overleaf-spinner" /> + <div className="overleaf-log">{busyText}</div> + </div> + ) : ( + <> + <div className="projects-toolbar"> + <input + className="projects-search" + type="text" + value={searchFilter} + onChange={(e) => setSearchFilter(e.target.value)} + placeholder="Search projects..." + autoFocus + /> + <button className="btn btn-primary btn-sm" onClick={handleCreateProject}> + New Project + </button> + <button className="btn btn-secondary btn-sm" onClick={handleUploadProject}> + Upload + </button> + <button className="btn btn-secondary btn-sm" onClick={loadProjects} title="Refresh"> + {loading ? '...' : '↻'} + </button> + </div> + + <div className="projects-table-header"> + <span className="projects-col-name" onClick={() => toggleSort('name')}> + Name{sortIndicator('name')} + </span> + <span className="projects-col-owner" onClick={() => toggleSort('owner')}> + Owner{sortIndicator('owner')} + </span> + <span className="projects-col-updated" onClick={() => toggleSort('lastUpdated')}> + Last Modified{sortIndicator('lastUpdated')} + </span> + </div> + + <div className="projects-list"> + {loading && projects.length === 0 ? ( + <div className="projects-empty">Loading projects...</div> + ) : sortedAndFiltered.length === 0 ? ( + <div className="projects-empty"> + {searchFilter ? 'No matching projects' : 'No projects yet'} + </div> + ) : ( + sortedAndFiltered.map((p) => ( + <div + key={p.id} + className="projects-item" + onClick={() => handleOpen(p.id)} + > + <div className="projects-col-name"> + <span className="projects-item-name">{p.name}</span> + {p.accessLevel && p.accessLevel !== 'owner' && ( + <span className="projects-access-badge">{accessLabel(p.accessLevel)}</span> + )} + </div> + <div className="projects-col-owner"> + {personName(p.owner)} + </div> + <div className="projects-col-updated"> + <span className="projects-date">{formatDate(p.lastUpdated)}</span> + {p.lastUpdatedBy && ( + <span className="projects-updated-by">by {personName(p.lastUpdatedBy)}</span> + )} + </div> + </div> + )) + )} + </div> + </> + )} + </div> + </div> + ) +} diff --git a/src/renderer/src/components/ReviewPanel.tsx b/src/renderer/src/components/ReviewPanel.tsx new file mode 100644 index 0000000..fe94867 --- /dev/null +++ b/src/renderer/src/components/ReviewPanel.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useAppStore } from '../stores/appStore' + +interface User { + id: string + first_name?: string + last_name?: string + email?: string +} + +interface Message { + id: string + content: string + timestamp: number + user_id: string + user?: User +} + +interface Thread { + messages: Message[] + resolved?: boolean + resolved_at?: string + resolved_by_user_id?: string + resolved_by_user?: User +} + +type ThreadMap = Record<string, Thread> + +export default function ReviewPanel() { + const contexts = useAppStore((s) => s.commentContexts) + const activeTab = useAppStore((s) => s.activeTab) + const hoveredThreadId = useAppStore((s) => s.hoveredThreadId) + const focusedThreadId = useAppStore((s) => s.focusedThreadId) + const overleafProjectId = useAppStore((s) => s.overleafProjectId) + const [threads, setThreads] = useState<ThreadMap>({}) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [showResolved, setShowResolved] = useState(false) + const [replyingTo, setReplyingTo] = useState<string | null>(null) + const [replyText, setReplyText] = useState('') + const [editingMsg, setEditingMsg] = useState<{ threadId: string; messageId: string } | null>(null) + const [editText, setEditText] = useState('') + const threadRefs = useRef<Record<string, HTMLDivElement | null>>({}) + + const fetchThreads = useCallback(async () => { + if (!overleafProjectId) return + setLoading(true) + setError('') + + const [threadResult, ctxResult] = await Promise.all([ + window.api.overleafGetThreads(overleafProjectId), + window.api.otFetchAllCommentContexts() + ]) + + setLoading(false) + if (threadResult.success && threadResult.threads) { + setThreads(threadResult.threads as ThreadMap) + } else { + setError(threadResult.message || 'Failed to fetch comments') + } + if (ctxResult.success && ctxResult.contexts) { + useAppStore.getState().setCommentContexts(ctxResult.contexts) + } + }, [overleafProjectId]) + + useEffect(() => { + fetchThreads() + }, [fetchThreads]) + + useEffect(() => { + if (!focusedThreadId) return + const el = threadRefs.current[focusedThreadId] + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }, [focusedThreadId]) + + const handleReply = async (threadId: string) => { + if (!replyText.trim() || !overleafProjectId) return + const result = await window.api.overleafReplyThread(overleafProjectId, threadId, replyText.trim()) + if (result.success) { + setReplyText('') + setReplyingTo(null) + fetchThreads() + } + } + + const handleResolve = async (threadId: string) => { + if (!overleafProjectId) return + await window.api.overleafResolveThread(overleafProjectId, threadId) + fetchThreads() + } + + const handleReopen = async (threadId: string) => { + if (!overleafProjectId) return + await window.api.overleafReopenThread(overleafProjectId, threadId) + fetchThreads() + } + + const handleDeleteMessage = async (threadId: string, messageId: string) => { + if (!overleafProjectId) return + await window.api.overleafDeleteMessage(overleafProjectId, threadId, messageId) + fetchThreads() + } + + const handleStartEdit = (threadId: string, msg: Message) => { + setEditingMsg({ threadId, messageId: msg.id }) + setEditText(msg.content) + } + + const handleSaveEdit = async () => { + if (!editingMsg || !editText.trim() || !overleafProjectId) return + await window.api.overleafEditMessage(overleafProjectId, editingMsg.threadId, editingMsg.messageId, editText.trim()) + setEditingMsg(null) + setEditText('') + fetchThreads() + } + + const handleDeleteThread = async (threadId: string) => { + if (!overleafProjectId) return + const ctx = contexts[threadId] + const store = useAppStore.getState() + if (ctx) { + const docId = store.pathDocMap[ctx.file] + if (docId) { + await window.api.overleafDeleteThread(overleafProjectId, docId, threadId) + fetchThreads() + return + } + } + fetchThreads() + } + + const getUserName = (msg: Message) => { + if (msg.user?.first_name) { + return msg.user.last_name ? `${msg.user.first_name} ${msg.user.last_name}` : msg.user.first_name + } + if (msg.user?.email) return msg.user.email.split('@')[0] + return msg.user_id.slice(-6) + } + + const formatTime = (ts: number) => { + const d = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60000) + if (diffMins < 1) return 'just now' + if (diffMins < 60) return `${diffMins}m ago` + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + if (diffDays < 7) return `${diffDays}d ago` + return d.toLocaleDateString() + } + + // Navigate to comment position — always works for current file since it's already open + const handleClickContext = (threadId: string) => { + const ctx = contexts[threadId] + if (!ctx) return + const store = useAppStore.getState() + // File should already be open since we only show current file's comments + store.setPendingGoTo({ file: ctx.file, pos: ctx.pos, highlight: ctx.text }) + } + + if (!overleafProjectId) { + return ( + <div className="review-panel"> + <div className="review-header"><span>Review</span></div> + <div className="review-empty">Not connected</div> + </div> + ) + } + + // Filter threads to only show ones belonging to the current file + const threadEntries = Object.entries(threads) + const fileThreads = activeTab + ? threadEntries.filter(([threadId]) => { + const ctx = contexts[threadId] + return ctx && ctx.file === activeTab + }) + : [] + const activeThreads = fileThreads.filter(([, t]) => !t.resolved) + const resolvedThreads = fileThreads.filter(([, t]) => t.resolved) + + const handleThreadHover = (threadId: string | null) => { + useAppStore.getState().setHoveredThreadId(threadId) + } + + const renderThread = (threadId: string, thread: Thread, isResolved: boolean) => { + const ctx = contexts[threadId] + const isHighlighted = hoveredThreadId === threadId || focusedThreadId === threadId + return ( + <div + key={threadId} + ref={(el) => { threadRefs.current[threadId] = el }} + className={`review-thread ${isResolved ? 'review-thread-resolved' : ''} ${isHighlighted ? 'review-thread-highlighted' : ''}`} + onMouseEnter={() => handleThreadHover(threadId)} + onMouseLeave={() => handleThreadHover(null)} + > + {ctx && ctx.text && ( + <div className="review-context" onClick={() => handleClickContext(threadId)} title="Jump to position"> + <span className="review-context-text"> + “{ctx.text.length > 80 ? ctx.text.slice(0, 80) + '...' : ctx.text}” + </span> + </div> + )} + {thread.messages.map((msg, i) => { + const isEditing = editingMsg?.threadId === threadId && editingMsg?.messageId === msg.id + return ( + <div key={msg.id || i} className={`review-message ${i === 0 ? 'review-message-first' : ''}`}> + <div className="review-message-header"> + <span className="review-user">{getUserName(msg)}</span> + <div className="review-message-actions-inline"> + <span className="review-time">{formatTime(msg.timestamp)}</span> + <button className="review-msg-action" onClick={() => handleStartEdit(threadId, msg)} title="Edit">✎</button> + <button className="review-msg-action review-msg-delete" onClick={() => handleDeleteMessage(threadId, msg.id)} title="Delete">×</button> + </div> + </div> + {isEditing ? ( + <div className="review-edit-inline"> + <input + className="review-reply-input" + value={editText} + onChange={(e) => setEditText(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveEdit() + if (e.key === 'Escape') setEditingMsg(null) + }} + /> + <button className="review-reply-send" onClick={handleSaveEdit}>Save</button> + </div> + ) : ( + <div className="review-message-content">{msg.content}</div> + )} + </div> + ) + })} + <div className="review-thread-actions"> + {!isResolved ? ( + <> + <button className="review-action-btn" onClick={() => setReplyingTo(replyingTo === threadId ? null : threadId)}>Reply</button> + <button className="review-action-btn" onClick={() => handleResolve(threadId)}>Resolve</button> + <button className="review-action-btn review-action-delete" onClick={() => handleDeleteThread(threadId)}>Delete</button> + </> + ) : ( + <> + <button className="review-action-btn" onClick={() => handleReopen(threadId)}>Reopen</button> + <button className="review-action-btn review-action-delete" onClick={() => handleDeleteThread(threadId)}>Delete</button> + </> + )} + </div> + {replyingTo === threadId && ( + <div className="review-reply"> + <input + className="review-reply-input" + value={replyText} + onChange={(e) => setReplyText(e.target.value)} + placeholder="Reply..." + autoFocus + onKeyDown={(e) => { if (e.key === 'Enter') handleReply(threadId) }} + /> + <button className="review-reply-send" onClick={() => handleReply(threadId)}>Send</button> + </div> + )} + </div> + ) + } + + const fileName = activeTab?.split('/').pop() || '' + + return ( + <div className="review-panel"> + <div className="review-header"> + <span>{fileName ? `Review: ${fileName}` : 'Review'} ({activeThreads.length})</span> + <div className="review-header-actions"> + <button className="toolbar-btn" onClick={fetchThreads} title="Refresh"> + {loading ? '...' : '↻'} + </button> + {resolvedThreads.length > 0 && ( + <button + className={`toolbar-btn ${showResolved ? 'active' : ''}`} + onClick={() => setShowResolved(!showResolved)} + title="Show resolved" + > + ✓ {resolvedThreads.length} + </button> + )} + </div> + </div> + + {error && <div className="review-error">{error}</div>} + + <div className="review-threads"> + {!activeTab && ( + <div className="review-empty">Open a file to see its comments</div> + )} + {activeTab && activeThreads.length === 0 && !loading && ( + <div className="review-empty">No comments in this file</div> + )} + {activeThreads.map(([threadId, thread]) => renderThread(threadId, thread, false))} + {showResolved && resolvedThreads.length > 0 && ( + <> + <div className="review-section-title">Resolved</div> + {resolvedThreads.map(([threadId, thread]) => renderThread(threadId, thread, true))} + </> + )} + </div> + </div> + ) +} diff --git a/src/renderer/src/components/StatusBar.tsx b/src/renderer/src/components/StatusBar.tsx index cd11bdd..79b8a10 100644 --- a/src/renderer/src/components/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar.tsx @@ -1,10 +1,19 @@ import { useAppStore } from '../stores/appStore' export default function StatusBar() { - const { statusMessage, isGitRepo, gitStatus, activeTab, compiling } = useAppStore() + const { statusMessage, activeTab, compiling, connectionState } = useAppStore() const lineInfo = activeTab ? activeTab.split('/').pop() : '' + const connectionLabel = connectionState === 'connected' ? 'Connected' + : connectionState === 'connecting' ? 'Connecting...' + : connectionState === 'reconnecting' ? 'Reconnecting...' + : 'Disconnected' + + const connectionDot = connectionState === 'connected' ? 'connection-dot-green' + : connectionState === 'connecting' || connectionState === 'reconnecting' ? 'connection-dot-yellow' + : 'connection-dot-red' + return ( <div className="status-bar"> <div className="status-left"> @@ -12,11 +21,10 @@ export default function StatusBar() { <span className="status-message">{statusMessage}</span> </div> <div className="status-right"> - {isGitRepo && ( - <span className="status-git"> - Git{gitStatus ? ` (${gitStatus.split('\n').filter(Boolean).length} changes)` : ' (clean)'} - </span> - )} + <span className="status-connection"> + <span className={`connection-dot ${connectionDot}`} /> + {connectionLabel} + </span> {lineInfo && <span className="status-file">{lineInfo}</span>} <span className="status-encoding">UTF-8</span> <span className="status-lang">LaTeX</span> diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f7e306e..d84a00c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -8,11 +8,10 @@ export default function Terminal() { const termRef = useRef<HTMLDivElement>(null) const xtermRef = useRef<XTerm | null>(null) const fitAddonRef = useRef<FitAddon | null>(null) - const { projectPath } = useAppStore() const [mode, setMode] = useState<'terminal' | 'claude'>('terminal') useEffect(() => { - if (!termRef.current || !projectPath) return + if (!termRef.current) return const xterm = new XTerm({ theme: { @@ -54,7 +53,7 @@ export default function Terminal() { fitAddonRef.current = fitAddon // Spawn shell - window.api.ptySpawn(projectPath) + window.api.ptySpawn('/tmp') // Pipe data const unsubData = window.api.onPtyData((data) => { @@ -86,7 +85,7 @@ export default function Terminal() { window.api.ptyKill() xterm.dispose() } - }, [projectPath]) + }, []) const launchClaude = () => { if (!xtermRef.current) return diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index ac875bd..002b374 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -2,70 +2,50 @@ import { useAppStore } from '../stores/appStore' interface ToolbarProps { onCompile: () => void - onSave: () => void - onOpenProject: () => void + onBack: () => void } -export default function Toolbar({ onCompile, onSave, onOpenProject }: ToolbarProps) { - const { projectPath, compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree, isGitRepo, mainDocument } = useAppStore() - const projectName = projectPath?.split('/').pop() ?? 'ClaudeTeX' +export default function Toolbar({ onCompile, onBack }: ToolbarProps) { + const { + compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree, + showReviewPanel, toggleReviewPanel, connectionState, overleafProject + } = useAppStore() - const handlePull = async () => { - if (!projectPath) return - useAppStore.getState().setStatusMessage('Pulling from Overleaf...') - const result = await window.api.gitPull(projectPath) - useAppStore.getState().setStatusMessage(result.success ? 'Pull complete' : 'Pull failed') - } + const projectName = overleafProject?.name || 'Project' - const handlePush = async () => { - if (!projectPath) return - useAppStore.getState().setStatusMessage('Pushing to Overleaf...') - const result = await window.api.gitPush(projectPath) - useAppStore.getState().setStatusMessage(result.success ? 'Push complete' : 'Push failed') - } + const connectionDot = connectionState === 'connected' ? 'connection-dot-green' + : connectionState === 'connecting' || connectionState === 'reconnecting' ? 'connection-dot-yellow' + : 'connection-dot-red' return ( <div className="toolbar"> <div className="toolbar-left"> <div className="drag-region" /> - <button className="toolbar-btn" onClick={toggleFileTree} title="Toggle file tree (Cmd+\\)"> + <button className="toolbar-btn" onClick={onBack} title="Back to projects"> + ← + </button> + <button className="toolbar-btn" onClick={toggleFileTree} title="Toggle file tree"> {showFileTree ? '◧' : '☰'} </button> - <span className="project-name">{projectName}</span> + <span className="project-name"> + <span className={`connection-dot ${connectionDot}`} title={connectionState} /> + {projectName} + </span> </div> <div className="toolbar-center"> - <button className="toolbar-btn" onClick={onOpenProject} title="Open project"> - Open - </button> - <button className="toolbar-btn" onClick={onSave} title="Save (Cmd+S)"> - Save - </button> <button className={`toolbar-btn toolbar-btn-primary ${compiling ? 'compiling' : ''}`} onClick={onCompile} disabled={compiling} - title={`Compile (Cmd+B)${mainDocument ? ' — ' + mainDocument.split('/').pop() : ''}`} + title="Compile (Cmd+B)" > {compiling ? 'Compiling...' : 'Compile'} </button> - {mainDocument && ( - <span className="toolbar-main-doc" title={mainDocument}> - {mainDocument.split('/').pop()} - </span> - )} - {isGitRepo && ( - <> - <div className="toolbar-separator" /> - <button className="toolbar-btn" onClick={handlePull} title="Pull from Overleaf"> - Pull - </button> - <button className="toolbar-btn" onClick={handlePush} title="Push to Overleaf"> - Push - </button> - </> - )} </div> <div className="toolbar-right"> + <button className={`toolbar-btn ${showReviewPanel ? 'active' : ''}`} onClick={toggleReviewPanel} title="Toggle review panel"> + Review + </button> <button className="toolbar-btn" onClick={toggleTerminal} title="Toggle terminal (Cmd+`)"> {showTerminal ? 'Hide Terminal' : 'Terminal'} </button> diff --git a/src/renderer/src/extensions/addCommentTooltip.ts b/src/renderer/src/extensions/addCommentTooltip.ts new file mode 100644 index 0000000..dc60bb1 --- /dev/null +++ b/src/renderer/src/extensions/addCommentTooltip.ts @@ -0,0 +1,97 @@ +/** + * CodeMirror extension: "Add comment" tooltip on text selection. + * Inspired by Overleaf's review-tooltip.ts. + */ +import { + EditorView, + showTooltip, + type Tooltip, +} from '@codemirror/view' +import { + StateField, + type EditorState, +} from '@codemirror/state' + +export type AddCommentCallback = (from: number, to: number, text: string) => void + +let _addCommentCallback: AddCommentCallback | null = null + +export function setAddCommentCallback(cb: AddCommentCallback | null) { + _addCommentCallback = cb +} + +function buildTooltip(state: EditorState): Tooltip | null { + const sel = state.selection.main + if (sel.empty) return null + + return { + pos: sel.head, + above: sel.head < sel.anchor, + create() { + const dom = document.createElement('div') + dom.className = 'cm-add-comment-tooltip' + + const btn = document.createElement('button') + btn.className = 'cm-add-comment-btn' + btn.textContent = '+ Comment' + btn.addEventListener('mousedown', (e) => { + e.preventDefault() // prevent editor losing focus/selection + }) + btn.addEventListener('click', () => { + if (_addCommentCallback) { + const from = Math.min(sel.from, sel.to) + const to = Math.max(sel.from, sel.to) + const text = state.sliceDoc(from, to) + _addCommentCallback(from, to, text) + } + }) + + dom.appendChild(btn) + return { dom, overlap: true, offset: { x: 0, y: 4 } } + }, + } +} + +const addCommentTooltipField = StateField.define<Tooltip | null>({ + create(state) { + return buildTooltip(state) + }, + update(tooltip, tr) { + if (!tr.docChanged && !tr.selection) return tooltip + return buildTooltip(tr.state) + }, + provide: (field) => showTooltip.from(field), +}) + +const addCommentTooltipTheme = EditorView.baseTheme({ + '.cm-add-comment-tooltip.cm-tooltip': { + backgroundColor: 'transparent', + border: 'none', + zIndex: '10', + }, + '.cm-add-comment-btn': { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + padding: '4px 10px', + fontSize: '12px', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + fontWeight: '600', + color: '#5B4A28', + backgroundColor: '#FFF8E7', + border: '1px solid #D6CEBC', + borderRadius: '6px', + cursor: 'pointer', + boxShadow: '0 2px 8px rgba(0,0,0,0.12)', + transition: 'background 0.15s', + }, + '.cm-add-comment-btn:hover': { + backgroundColor: '#F5EDD6', + borderColor: '#B8A070', + }, +}) + +export const addCommentTooltip = () => [ + addCommentTooltipField, + addCommentTooltipTheme, +] diff --git a/src/renderer/src/extensions/commentHighlights.ts b/src/renderer/src/extensions/commentHighlights.ts new file mode 100644 index 0000000..115c8fc --- /dev/null +++ b/src/renderer/src/extensions/commentHighlights.ts @@ -0,0 +1,227 @@ +/** + * CodeMirror extension for highlighting commented text ranges. + * Inspired by Overleaf's ranges.ts — renders Decoration.mark for each comment + * in the current file, with hover/focus highlighting linkage to the ReviewPanel. + */ +import { + StateEffect, + StateField, +} from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + type PluginValue, + ViewPlugin, +} from '@codemirror/view' + +// ── Types ────────────────────────────────────────────────────── + +export interface CommentRange { + threadId: string + from: number // character offset in the doc + to: number // from + text.length + text: string +} + +// ── Effects ──────────────────────────────────────────────────── + +/** Replace all comment ranges in the editor */ +export const setCommentRangesEffect = StateEffect.define<CommentRange[]>() + +/** Highlight a specific thread (from ReviewPanel hover) */ +export const highlightThreadEffect = StateEffect.define<string | null>() + +/** Focus a specific thread (from cursor position) — internal */ +const focusThreadEffect = StateEffect.define<string | null>() + +// ── State Fields ─────────────────────────────────────────────── + +/** Stores comment ranges data */ +export const commentRangesField = StateField.define<CommentRange[]>({ + create() { + return [] + }, + update(ranges, tr) { + for (const effect of tr.effects) { + if (effect.is(setCommentRangesEffect)) { + return effect.value + } + } + return ranges + }, +}) + +/** Stores the currently highlighted thread ID (from panel hover) */ +const highlightedThreadField = StateField.define<string | null>({ + create() { + return null + }, + update(current, tr) { + for (const effect of tr.effects) { + if (effect.is(highlightThreadEffect)) { + return effect.value + } + } + return current + }, +}) + +/** Stores the currently focused thread ID (from cursor position) */ +const focusedThreadField = StateField.define<string | null>({ + create() { + return null + }, + update(current, tr) { + for (const effect of tr.effects) { + if (effect.is(focusThreadEffect)) { + return effect.value + } + } + return current + }, +}) + +// ── Decoration Builders ──────────────────────────────────────── + +function buildCommentDecorations(ranges: CommentRange[]): DecorationSet { + if (ranges.length === 0) return Decoration.none + + const decorations = [] + for (const r of ranges) { + if (r.from >= r.to) continue + decorations.push( + Decoration.mark({ + class: 'cm-comment-highlight', + attributes: { 'data-thread-id': r.threadId }, + }).range(r.from, r.to) + ) + } + // Must be sorted by from position + decorations.sort((a, b) => a.from - b.from) + return Decoration.set(decorations, true) +} + +function buildHighlightDecoration(ranges: CommentRange[], threadId: string | null): DecorationSet { + if (!threadId) return Decoration.none + const r = ranges.find(c => c.threadId === threadId) + if (!r || r.from >= r.to) return Decoration.none + return Decoration.set([ + Decoration.mark({ class: 'cm-comment-highlight-hover' }).range(r.from, r.to) + ]) +} + +function buildFocusDecoration(ranges: CommentRange[], threadId: string | null): DecorationSet { + if (!threadId) return Decoration.none + const r = ranges.find(c => c.threadId === threadId) + if (!r || r.from >= r.to) return Decoration.none + return Decoration.set([ + Decoration.mark({ class: 'cm-comment-highlight-focus' }).range(r.from, r.to) + ]) +} + +// ── View Plugins ─────────────────────────────────────────────── + +/** Base comment decorations (yellow background) */ +const commentDecorationsPlugin = ViewPlugin.define<PluginValue & { decorations: DecorationSet }>( + () => ({ + decorations: Decoration.none, + update(update) { + for (const tr of update.transactions) { + this.decorations = this.decorations.map(tr.changes) + for (const effect of tr.effects) { + if (effect.is(setCommentRangesEffect)) { + this.decorations = buildCommentDecorations(effect.value) + } + } + } + }, + }), + { decorations: (v) => v.decorations } +) + +/** Hover highlight decoration (stronger yellow, from ReviewPanel hover) */ +const hoverHighlightPlugin = ViewPlugin.define<PluginValue & { decorations: DecorationSet }>( + (view) => ({ + decorations: Decoration.none, + update(update) { + for (const tr of update.transactions) { + for (const effect of tr.effects) { + if (effect.is(highlightThreadEffect) || effect.is(setCommentRangesEffect)) { + const ranges = update.state.field(commentRangesField) + const threadId = update.state.field(highlightedThreadField) + this.decorations = buildHighlightDecoration(ranges, threadId) + return + } + } + this.decorations = this.decorations.map(tr.changes) + } + }, + }), + { decorations: (v) => v.decorations } +) + +/** Focus decoration (border, from cursor position in comment range) */ +const focusHighlightPlugin = ViewPlugin.define<PluginValue & { decorations: DecorationSet }>( + () => ({ + decorations: Decoration.none, + update(update) { + const needsRebuild = update.selectionSet || + update.transactions.some(tr => + tr.effects.some(e => e.is(setCommentRangesEffect)) + ) + + if (!needsRebuild) { + this.decorations = this.decorations.map(update.changes) + return + } + + const ranges = update.state.field(commentRangesField) + const cursorPos = update.state.selection.main.head + + let foundThreadId: string | null = null + for (const r of ranges) { + if (cursorPos >= r.from && cursorPos <= r.to) { + foundThreadId = r.threadId + break + } + } + + this.decorations = buildFocusDecoration(ranges, foundThreadId) + }, + }), + { decorations: (v) => v.decorations } +) + +// ── Theme ────────────────────────────────────────────────────── + +const commentHighlightTheme = EditorView.baseTheme({ + '.cm-comment-highlight': { + backgroundColor: 'rgba(243, 177, 17, 0.25)', + borderBottom: '2px solid rgba(243, 177, 17, 0.5)', + padding: '1px 0', + cursor: 'pointer', + }, + '.cm-comment-highlight-hover': { + backgroundColor: 'rgba(243, 177, 17, 0.45)', + borderBottom: '2px solid rgba(243, 177, 17, 0.8)', + }, + '.cm-comment-highlight-focus': { + backgroundColor: 'rgba(243, 177, 17, 0.45)', + borderBottom: '2px solid rgba(200, 140, 0, 1)', + outline: '1px solid rgba(200, 140, 0, 0.3)', + borderRadius: '2px', + }, +}) + +// ── Export Extension ─────────────────────────────────────────── + +export const commentHighlights = () => [ + commentRangesField, + highlightedThreadField, + focusedThreadField, + commentDecorationsPlugin, + hoverHighlightPlugin, + focusHighlightPlugin, + commentHighlightTheme, +] diff --git a/src/renderer/src/extensions/otSyncExtension.ts b/src/renderer/src/extensions/otSyncExtension.ts new file mode 100644 index 0000000..7ff2203 --- /dev/null +++ b/src/renderer/src/extensions/otSyncExtension.ts @@ -0,0 +1,39 @@ +// CM6 extension for OT sync: ViewPlugin + annotation to prevent echo loops +import { Annotation } from '@codemirror/state' +import { ViewPlugin, type ViewUpdate } from '@codemirror/view' +import type { OverleafDocSync } from '../ot/overleafSync' + +/** Annotation used to mark transactions that come from remote OT updates */ +export const remoteUpdateAnnotation = Annotation.define<boolean>() + +/** + * Creates a CM6 extension that intercepts local doc changes + * and feeds them to the OT orchestrator. Skips changes tagged + * with remoteUpdateAnnotation to prevent echo loops. + */ +export function otSyncExtension(sync: OverleafDocSync) { + return ViewPlugin.fromClass( + class { + constructor() { + // nothing to initialize + } + + update(update: ViewUpdate) { + if (!update.docChanged) return + + // Skip if this change was from a remote OT update + for (const tr of update.transactions) { + if (tr.annotation(remoteUpdateAnnotation)) return + } + + // Feed local changes to OT orchestrator + // We need the old doc (before changes) — it's the startState.doc + sync.onLocalChange(update.changes, update.startState.doc) + } + + destroy() { + // nothing to clean up + } + } + ) +} diff --git a/src/renderer/src/ot/cmAdapter.ts b/src/renderer/src/ot/cmAdapter.ts new file mode 100644 index 0000000..852402d --- /dev/null +++ b/src/renderer/src/ot/cmAdapter.ts @@ -0,0 +1,70 @@ +// Bidirectional conversion: CM6 ChangeSet <-> Overleaf OT ops +import type { ChangeSet, Text, ChangeSpec } from '@codemirror/state' +import type { OtOp, InsertOp, DeleteOp } from './types' +import { isInsert, isDelete } from './types' + +/** + * Convert a CM6 ChangeSet into Overleaf OT ops. + * Iterates through the changes and produces insert/delete ops + * with positions relative to the old document. + */ +export function changeSetToOtOps(changes: ChangeSet, oldDoc: Text): OtOp[] { + const ops: OtOp[] = [] + let posAdjust = 0 // tracks position shift from previous ops + + changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { + const origFrom = fromA + const deletedLen = toA - fromA + const insertedText = inserted.toString() + + // Delete first (at original position in the old doc) + if (deletedLen > 0) { + const deletedText = oldDoc.sliceString(fromA, toA) + ops.push({ d: deletedText, p: origFrom + posAdjust }) + // After deleting, subsequent positions shift back + } + + // Then insert + if (insertedText.length > 0) { + ops.push({ i: insertedText, p: origFrom + posAdjust }) + posAdjust += insertedText.length + } + + if (deletedLen > 0) { + posAdjust -= deletedLen + } + }) + + return ops +} + +/** + * Convert Overleaf OT ops into CM6 ChangeSpec array. + * These can be dispatched to an EditorView. + */ +export function otOpsToChangeSpec(ops: OtOp[]): ChangeSpec[] { + const specs: ChangeSpec[] = [] + // Sort ops by position (process in order). Inserts before deletes at same position. + const sorted = [...ops].filter(op => isInsert(op) || isDelete(op)).sort((a, b) => { + if (a.p !== b.p) return a.p - b.p + // Inserts before deletes at same position + if (isInsert(a) && isDelete(b)) return -1 + if (isDelete(a) && isInsert(b)) return 1 + return 0 + }) + + // We need to adjust positions as we apply ops sequentially + let posShift = 0 + + for (const op of sorted) { + if (isInsert(op)) { + specs.push({ from: op.p + posShift, insert: op.i }) + posShift += op.i.length + } else if (isDelete(op)) { + specs.push({ from: op.p + posShift, to: op.p + posShift + op.d.length }) + posShift -= op.d.length + } + } + + return specs +} diff --git a/src/renderer/src/ot/otClient.ts b/src/renderer/src/ot/otClient.ts new file mode 100644 index 0000000..a491d23 --- /dev/null +++ b/src/renderer/src/ot/otClient.ts @@ -0,0 +1,135 @@ +// OT state machine: Synchronized / AwaitingConfirm / AwaitingWithBuffer +import type { OtOp, OtState } from './types' +import { transformOps } from './transform' + +export type SendFn = (ops: OtOp[], version: number) => void +export type ApplyFn = (ops: OtOp[]) => void + +export class OtClient { + private state: OtState + private sendFn: SendFn + private applyFn: ApplyFn + + constructor(version: number, sendFn: SendFn, applyFn: ApplyFn) { + this.state = { name: 'synchronized', inflight: null, buffer: null, version } + this.sendFn = sendFn + this.applyFn = applyFn + } + + get version(): number { + return this.state.version + } + + get stateName(): string { + return this.state.name + } + + /** Called when local user makes changes */ + onLocalOps(ops: OtOp[]) { + if (ops.length === 0) return + + switch (this.state.name) { + case 'synchronized': + // Send immediately, transition to awaiting + this.state = { + name: 'awaitingConfirm', + inflight: ops, + buffer: null, + version: this.state.version + } + this.sendFn(ops, this.state.version) + break + + case 'awaitingConfirm': + // Buffer the ops + this.state = { + name: 'awaitingWithBuffer', + inflight: this.state.inflight, + buffer: ops, + version: this.state.version + } + break + + case 'awaitingWithBuffer': + // Compose into existing buffer + this.state = { + ...this.state, + buffer: [...(this.state.buffer || []), ...ops] + } + break + } + } + + /** Called when server acknowledges our inflight ops */ + onAck() { + switch (this.state.name) { + case 'awaitingConfirm': + this.state = { + name: 'synchronized', + inflight: null, + buffer: null, + version: this.state.version + 1 + } + break + + case 'awaitingWithBuffer': + // Send the buffer, move to awaitingConfirm + const bufferOps = this.state.buffer || [] + this.state = { + name: 'awaitingConfirm', + inflight: bufferOps, + buffer: null, + version: this.state.version + 1 + } + this.sendFn(bufferOps, this.state.version) + break + + case 'synchronized': + // Unexpected ack in synchronized state, ignore + console.warn('[OtClient] unexpected ack in synchronized state') + break + } + } + + /** Called when server sends a remote operation */ + onRemoteOps(ops: OtOp[], newVersion: number) { + switch (this.state.name) { + case 'synchronized': + // Apply directly + this.state = { ...this.state, version: newVersion } + this.applyFn(ops) + break + + case 'awaitingConfirm': { + // Transform: remote ops vs our inflight + const { left: transformedRemote, right: transformedInflight } = transformOps(ops, this.state.inflight || []) + this.state = { + ...this.state, + inflight: transformedInflight, + version: newVersion + } + this.applyFn(transformedRemote) + break + } + + case 'awaitingWithBuffer': { + // Transform remote vs inflight, then remote' vs buffer + const { left: remoteAfterInflight, right: inflightAfterRemote } = transformOps(ops, this.state.inflight || []) + const { left: remoteAfterBuffer, right: bufferAfterRemote } = transformOps(remoteAfterInflight, this.state.buffer || []) + this.state = { + ...this.state, + inflight: inflightAfterRemote, + buffer: bufferAfterRemote, + version: newVersion + } + this.applyFn(remoteAfterBuffer) + break + } + } + } + + /** Reset to a known version (e.g. after reconnect) */ + reset(version: number) { + this.state = { name: 'synchronized', inflight: null, buffer: null, version } + } +} diff --git a/src/renderer/src/ot/overleafSync.ts b/src/renderer/src/ot/overleafSync.ts new file mode 100644 index 0000000..e6169fc --- /dev/null +++ b/src/renderer/src/ot/overleafSync.ts @@ -0,0 +1,147 @@ +// Per-document orchestrator: ties CM6 adapter to OT client, IPC bridge +import type { EditorView } from '@codemirror/view' +import { ChangeSet, Transaction, type Text } from '@codemirror/state' +import { OtClient } from './otClient' +import type { OtOp } from './types' +import { changeSetToOtOps, otOpsToChangeSpec } from './cmAdapter' +import { remoteUpdateAnnotation } from '../extensions/otSyncExtension' + +function sha1(text: string): string { + return window.api.sha1(text) +} + +export class OverleafDocSync { + private otClient: OtClient + private view: EditorView | null = null + private docId: string + private pendingChanges: ChangeSet | null = null + private debounceTimer: ReturnType<typeof setTimeout> | null = null + private debounceMs = 150 + + constructor(docId: string, version: number) { + this.docId = docId + this.otClient = new OtClient( + version, + this.handleSend.bind(this), + this.handleApply.bind(this) + ) + } + + get version(): number { + return this.otClient.version + } + + setView(view: EditorView) { + this.view = view + } + + /** Called by CM6 update listener for local changes */ + onLocalChange(changes: ChangeSet, oldDoc: Text) { + // Compose into pending changes (buffer ChangeSets, convert to OT ops only at send time) + if (this.pendingChanges) { + this.pendingChanges = this.pendingChanges.compose(changes) + } else { + this.pendingChanges = changes + } + + // Debounce send + if (this.debounceTimer) clearTimeout(this.debounceTimer) + this.debounceTimer = setTimeout(() => this.flushLocalChanges(), this.debounceMs) + } + + private flushLocalChanges() { + if (!this.pendingChanges || !this.view) return + + const oldDoc = this.view.state.doc + // We need the doc state BEFORE the pending changes were applied + // Since we composed changes incrementally, we work backward + // Actually, we stored the ChangeSet which maps old positions, so we convert directly + const ops = changeSetToOtOps(this.pendingChanges, this.getOldDoc()) + this.pendingChanges = null + + if (ops.length > 0) { + this.otClient.onLocalOps(ops) + } + } + + private getOldDoc(): Text { + // The "old doc" is the current doc minus pending local changes + // Since pendingChanges is null at send time (we just cleared it), + // and the ChangeSet was already composed against the old doc, + // we just use the doc that was current when changes started accumulating. + // For simplicity, we pass the doc at change time via changeSetToOtOps + return this.view!.state.doc + } + + /** Send ops to server via IPC */ + private handleSend(ops: OtOp[], version: number) { + const docText = this.view?.state.doc.toString() || '' + const hash = sha1(docText) + window.api.otSendOp(this.docId, ops, version, hash) + } + + /** Apply remote ops to CM6 editor */ + private handleApply(ops: OtOp[]) { + if (!this.view) return + + const specs = otOpsToChangeSpec(ops) + if (specs.length === 0) return + + this.view.dispatch({ + changes: specs, + annotations: [ + remoteUpdateAnnotation.of(true), + Transaction.addToHistory.of(false) + ] + }) + } + + /** Called when server acknowledges our ops */ + onAck() { + this.otClient.onAck() + } + + /** Called when server sends remote ops */ + onRemoteOps(ops: OtOp[], version: number) { + this.otClient.onRemoteOps(ops, version) + } + + /** Reset after reconnect with fresh doc state */ + reset(version: number, docContent: string) { + this.otClient.reset(version) + this.pendingChanges = null + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + this.debounceTimer = null + } + // Replace editor content with server state + if (this.view) { + this.view.dispatch({ + changes: { from: 0, to: this.view.state.doc.length, insert: docContent }, + annotations: [ + remoteUpdateAnnotation.of(true), + Transaction.addToHistory.of(false) + ] + }) + } + } + + /** Replace entire editor content with new content (external edit from disk) */ + replaceContent(newContent: string) { + if (!this.view) return + + const currentContent = this.view.state.doc.toString() + if (currentContent === newContent) return + + // Dispatch as a local change (NOT remote annotation) so it flows through OT + this.view.dispatch({ + changes: { from: 0, to: this.view.state.doc.length, insert: newContent } + }) + } + + destroy() { + if (this.debounceTimer) clearTimeout(this.debounceTimer) + this.view = null + this.pendingChanges = null + } +} diff --git a/src/renderer/src/ot/transform.ts b/src/renderer/src/ot/transform.ts new file mode 100644 index 0000000..846e312 --- /dev/null +++ b/src/renderer/src/ot/transform.ts @@ -0,0 +1,174 @@ +// OT transform functions for Overleaf's text operation format +import type { OtOp } from './types' +import { isInsert, isDelete, isComment } from './types' + +/** + * Transform two lists of operations against each other. + * Returns { left, right } where: + * - left = ops1 transformed against ops2 (apply after ops2) + * - right = ops2 transformed against ops1 (apply after ops1) + */ +export function transformOps( + ops1: OtOp[], + ops2: OtOp[] +): { left: OtOp[]; right: OtOp[] } { + let left = ops1 + let right = ops2 + + // Transform each op in left against all ops in right, and vice versa + const newLeft: OtOp[] = [] + for (const op1 of left) { + let transformed = op1 + const newRight: OtOp[] = [] + for (const op2 of right) { + const { left: tl, right: tr } = transformOp(transformed, op2) + transformed = tl + newRight.push(tr) + } + newLeft.push(transformed) + right = newRight + } + + return { left: newLeft, right } +} + +/** Transform a single op against another single op */ +function transformOp(op1: OtOp, op2: OtOp): { left: OtOp; right: OtOp } { + // Insert vs Insert + if (isInsert(op1) && isInsert(op2)) { + if (op1.p <= op2.p) { + return { + left: op1, + right: { ...op2, p: op2.p + op1.i.length } + } + } else { + return { + left: { ...op1, p: op1.p + op2.i.length }, + right: op2 + } + } + } + + // Insert vs Delete + if (isInsert(op1) && isDelete(op2)) { + if (op1.p <= op2.p) { + return { + left: op1, + right: { ...op2, p: op2.p + op1.i.length } + } + } else if (op1.p >= op2.p + op2.d.length) { + return { + left: { ...op1, p: op1.p - op2.d.length }, + right: op2 + } + } else { + // Insert inside deleted region — place at delete position + return { + left: { ...op1, p: op2.p }, + right: op2 + } + } + } + + // Delete vs Insert + if (isDelete(op1) && isInsert(op2)) { + if (op2.p <= op1.p) { + return { + left: { ...op1, p: op1.p + op2.i.length }, + right: op2 + } + } else if (op2.p >= op1.p + op1.d.length) { + return { + left: op1, + right: { ...op2, p: op2.p - op1.d.length } + } + } else { + // Insert inside our deleted region + return { + left: op1, + right: { ...op2, p: op2.p - op1.d.length } + } + } + } + + // Delete vs Delete + if (isDelete(op1) && isDelete(op2)) { + if (op1.p >= op2.p + op2.d.length) { + return { + left: { ...op1, p: op1.p - op2.d.length }, + right: { ...op2, p: op2.p } + } + } else if (op2.p >= op1.p + op1.d.length) { + return { + left: op1, + right: { ...op2, p: op2.p - op1.d.length } + } + } else { + // Overlapping deletes — both become no-ops for the overlapping part + const start = Math.max(op1.p, op2.p) + const end1 = op1.p + op1.d.length + const end2 = op2.p + op2.d.length + + // op1 after removing overlap with op2 + let newOp1Text = op1.d + const overlapStart = Math.max(0, op2.p - op1.p) + const overlapEnd = Math.min(op1.d.length, op2.p + op2.d.length - op1.p) + if (overlapEnd > overlapStart) { + newOp1Text = op1.d.slice(0, overlapStart) + op1.d.slice(overlapEnd) + } + + let newOp2Text = op2.d + const overlapStart2 = Math.max(0, op1.p - op2.p) + const overlapEnd2 = Math.min(op2.d.length, op1.p + op1.d.length - op2.p) + if (overlapEnd2 > overlapStart2) { + newOp2Text = op2.d.slice(0, overlapStart2) + op2.d.slice(overlapEnd2) + } + + const newP1 = op1.p <= op2.p ? op1.p : op1.p - (overlapEnd2 - overlapStart2) + const newP2 = op2.p <= op1.p ? op2.p : op2.p - (overlapEnd - overlapStart) + + return { + left: newOp1Text ? { d: newOp1Text, p: Math.max(0, newP1) } : { d: '', p: 0 }, + right: newOp2Text ? { d: newOp2Text, p: Math.max(0, newP2) } : { d: '', p: 0 } + } + } + } + + // Comment ops: treat like inserts of zero length at their position for transform purposes + if (isComment(op1) || isComment(op2)) { + // Comments don't modify the document text, so they just need position adjustment + let p1 = isComment(op1) ? op1.p : ('p' in op1 ? op1.p : 0) + let p2 = isComment(op2) ? op2.p : ('p' in op2 ? op2.p : 0) + + if (isInsert(op2) && !isComment(op1)) { + // handled above + } + + // For comments, adjust position based on the other op + if (isComment(op1)) { + if (isInsert(op2) && op2.p <= op1.p) { + return { left: { ...op1, p: op1.p + op2.i.length }, right: op2 } + } + if (isDelete(op2) && op2.p < op1.p) { + const shift = Math.min(op2.d.length, op1.p - op2.p) + return { left: { ...op1, p: op1.p - shift }, right: op2 } + } + } + + if (isComment(op2)) { + if (isInsert(op1) && op1.p <= op2.p) { + return { left: op1, right: { ...op2, p: op2.p + op1.i.length } } + } + if (isDelete(op1) && op1.p < op2.p) { + const shift = Math.min(op1.d.length, op2.p - op1.p) + return { left: op1, right: { ...op2, p: op2.p - shift } } + } + } + + // Both comments or no positional conflict + return { left: op1, right: op2 } + } + + // Fallback: no transform needed + return { left: op1, right: op2 } +} diff --git a/src/renderer/src/ot/types.ts b/src/renderer/src/ot/types.ts new file mode 100644 index 0000000..2732e4f --- /dev/null +++ b/src/renderer/src/ot/types.ts @@ -0,0 +1,53 @@ +// OT type definitions for Overleaf's text operation format + +/** Insert text at position p */ +export interface InsertOp { + i: string + p: number +} + +/** Delete text at position p */ +export interface DeleteOp { + d: string + p: number +} + +/** Comment operation (mark text at position p) */ +export interface CommentOp { + c: string + p: number + t: string // threadId +} + +export type OtOp = InsertOp | DeleteOp | CommentOp + +export function isInsert(op: OtOp): op is InsertOp { + return 'i' in op +} + +export function isDelete(op: OtOp): op is DeleteOp { + return 'd' in op +} + +export function isComment(op: OtOp): op is CommentOp { + return 'c' in op +} + +/** A versioned OT update */ +export interface OtUpdate { + doc: string + op: OtOp[] + v: number + hash?: string + lastV?: number +} + +/** Possible states of the OT client */ +export type OtStateName = 'synchronized' | 'awaitingConfirm' | 'awaitingWithBuffer' + +export interface OtState { + name: OtStateName + inflight: OtOp[] | null // ops sent, awaiting ack + buffer: OtOp[] | null // ops queued while awaiting + version: number +} diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts index 6476239..9e8e0d0 100644 --- a/src/renderer/src/stores/appStore.ts +++ b/src/renderer/src/stores/appStore.ts @@ -1,22 +1,36 @@ import { create } from 'zustand' -interface FileNode { +export interface FileNode { name: string path: string isDir: boolean children?: FileNode[] + docId?: string + fileRefId?: string + folderId?: string } +export type SocketConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' + +/** Which screen is currently active */ +export type AppScreen = 'login' | 'projects' | 'editor' + interface OpenTab { path: string name: string modified: boolean } +export interface CommentContext { + file: string + text: string + pos: number +} + interface AppState { - // Project - projectPath: string | null - setProjectPath: (p: string | null) => void + // Screen + screen: AppScreen + setScreen: (s: AppScreen) => void // File tree files: FileNode[] @@ -34,7 +48,7 @@ interface AppState { fileContents: Record<string, string> setFileContent: (path: string, content: string) => void - // Main document + // Main document (rootDocId) mainDocument: string | null setMainDocument: (p: string | null) => void @@ -55,24 +69,54 @@ interface AppState { showFileTree: boolean toggleFileTree: () => void - // Git/Overleaf - isGitRepo: boolean - setIsGitRepo: (v: boolean) => void - gitStatus: string - setGitStatus: (s: string) => void - - // Navigation (from log click → editor) - pendingGoTo: { file: string; line: number } | null - setPendingGoTo: (g: { file: string; line: number } | null) => void + // Overleaf + overleafProjectId: string | null + setOverleafProjectId: (id: string | null) => void + + // Socket connection + connectionState: SocketConnectionState + setConnectionState: (s: SocketConnectionState) => void + docPathMap: Record<string, string> // docId → relativePath + pathDocMap: Record<string, string> // relativePath → docId + setDocMaps: (docPath: Record<string, string>, pathDoc: Record<string, string>) => void + docVersions: Record<string, number> // docId → version + setDocVersion: (docId: string, version: number) => void + overleafProject: { name: string; rootDocId: string } | null + setOverleafProject: (p: { name: string; rootDocId: string } | null) => void + fileRefs: Array<{ id: string; path: string }> + setFileRefs: (refs: Array<{ id: string; path: string }>) => void + rootFolderId: string + setRootFolderId: (id: string) => void + + // Review panel + showReviewPanel: boolean + toggleReviewPanel: () => void + + // Comment data + commentContexts: Record<string, CommentContext> + setCommentContexts: (c: Record<string, CommentContext>) => void + overleafDocs: Record<string, string> + setOverleafDocs: (d: Record<string, string>) => void + hoveredThreadId: string | null + setHoveredThreadId: (id: string | null) => void + focusedThreadId: string | null + setFocusedThreadId: (id: string | null) => void + + // Navigation + pendingGoTo: { file: string; line?: number; pos?: number; highlight?: string } | null + setPendingGoTo: (g: { file: string; line?: number; pos?: number; highlight?: string } | null) => void // Status statusMessage: string setStatusMessage: (m: string) => void + + // Reset editor state (when going back to project list) + resetEditorState: () => void } export const useAppStore = create<AppState>((set) => ({ - projectPath: null, - setProjectPath: (p) => set({ projectPath: p }), + screen: 'login', + setScreen: (s) => set({ screen: s }), files: [], setFiles: (f) => set({ files: f }), @@ -126,14 +170,64 @@ export const useAppStore = create<AppState>((set) => ({ showFileTree: true, toggleFileTree: () => set((s) => ({ showFileTree: !s.showFileTree })), - isGitRepo: false, - setIsGitRepo: (v) => set({ isGitRepo: v }), - gitStatus: '', - setGitStatus: (s) => set({ gitStatus: s }), + overleafProjectId: null, + setOverleafProjectId: (id) => set({ overleafProjectId: id }), + + connectionState: 'disconnected', + setConnectionState: (s) => set({ connectionState: s }), + docPathMap: {}, + pathDocMap: {}, + setDocMaps: (docPath, pathDoc) => set({ docPathMap: docPath, pathDocMap: pathDoc }), + docVersions: {}, + setDocVersion: (docId, version) => + set((s) => ({ docVersions: { ...s.docVersions, [docId]: version } })), + overleafProject: null, + setOverleafProject: (p) => set({ overleafProject: p }), + fileRefs: [], + setFileRefs: (refs) => set({ fileRefs: refs }), + rootFolderId: '', + setRootFolderId: (id) => set({ rootFolderId: id }), + + showReviewPanel: false, + toggleReviewPanel: () => set((s) => ({ showReviewPanel: !s.showReviewPanel })), + + commentContexts: {}, + setCommentContexts: (c) => set({ commentContexts: c }), + overleafDocs: {}, + setOverleafDocs: (d) => set({ overleafDocs: d }), + hoveredThreadId: null, + setHoveredThreadId: (id) => set({ hoveredThreadId: id }), + focusedThreadId: null, + setFocusedThreadId: (id) => set({ focusedThreadId: id }), pendingGoTo: null, setPendingGoTo: (g) => set({ pendingGoTo: g }), statusMessage: 'Ready', - setStatusMessage: (m) => set({ statusMessage: m }) + setStatusMessage: (m) => set({ statusMessage: m }), + + resetEditorState: () => set({ + files: [], + openTabs: [], + activeTab: null, + fileContents: {}, + mainDocument: null, + pdfPath: null, + compileLog: '', + compiling: false, + overleafProjectId: null, + connectionState: 'disconnected', + docPathMap: {}, + pathDocMap: {}, + docVersions: {}, + overleafProject: null, + fileRefs: [], + rootFolderId: '', + commentContexts: {}, + overleafDocs: {}, + hoveredThreadId: null, + focusedThreadId: null, + pendingGoTo: null, + statusMessage: 'Ready' + }) })) |
