From c261b8c4a95a7af64e3cd95a65c50f4dcbbb802c Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Sat, 14 Mar 2026 22:26:36 -0500 Subject: Add new file sync to Overleaf, Copy Comments button with file filtering - Sync new local files (created by Claude Code etc.) to Overleaf via REST API - Create intermediate folders as needed, handle both text docs and binaries - Scan for orphaned files on startup that weren't synced previously - Add "Copy Comments" quick action: copies unresolved comments for current file to clipboard with line numbers, context, author names, and timestamps - Filter comments to active file only, exclude resolved threads Co-Authored-By: Claude Opus 4.6 --- src/renderer/src/App.tsx | 8 ++++ src/renderer/src/components/Terminal.tsx | 75 ++++++++++++++++++++++++++++++++ src/renderer/src/stores/appStore.ts | 5 +++ 3 files changed, 88 insertions(+) (limited to 'src/renderer') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 869bdae..82306c9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -104,6 +104,13 @@ export default function App() { if (sync) sync.replaceContent(data.content) }) + // Listen for new docs created locally (e.g. by Claude Code) + const unsubNewDoc = window.api.onSyncNewDoc((data) => { + if (data.docId) { + useAppStore.getState().addDocPath(data.docId, data.relPath) + } + }) + // Listen for remote cursor updates const unsubCursorUpdate = window.api.onCursorRemoteUpdate((raw) => { const data = raw as { @@ -160,6 +167,7 @@ export default function App() { unsubState() unsubRejoined() unsubExternalEdit() + unsubNewDoc() unsubCursorUpdate() unsubCursorDisconnected() remoteCursors.clear() diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 72f785f..bc29633 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -168,6 +168,77 @@ function QuickActions({ ptyId }: { ptyId: string }) { window.api.ptyWrite(ptyId, prompt + '\n') } + const copyComments = async () => { + const store = useAppStore.getState() + const projectId = store.overleafProjectId + if (!projectId) return + + // Fetch threads and contexts in parallel + const [threadResult, ctxResult] = await Promise.all([ + window.api.overleafGetThreads(projectId), + window.api.otFetchAllCommentContexts() + ]) + + const threads = (threadResult.success ? threadResult.threads : {}) as Record + resolved?: boolean + }> + const contexts = ctxResult.success && ctxResult.contexts ? ctxResult.contexts : store.commentContexts + + const lines: string[] = [] + for (const [threadId, thread] of Object.entries(threads)) { + if (thread.resolved) continue + const ctx = contexts[threadId] + if (!ctx) continue + if (ctx.file !== activeTab) continue + + const firstMsg = thread.messages?.[0] + if (!firstMsg) continue + + // Compute line number and surrounding context from file content + const content = store.fileContents[ctx.file] || '' + let lineNum = 0 + let contextSnippet = ctx.text + if (content) { + const before = content.slice(0, ctx.pos) + lineNum = (before.match(/\n/g) || []).length + 1 + // Get the full line(s) containing the comment for context + const lineStart = before.lastIndexOf('\n') + 1 + const afterComment = content.indexOf('\n', ctx.pos + ctx.text.length) + const lineEnd = afterComment === -1 ? content.length : afterComment + const fullLine = content.slice(lineStart, lineEnd).trim() + // Show full line with the commented text marked + if (fullLine.length > ctx.text.length) { + contextSnippet = fullLine.replace(ctx.text, `«${ctx.text}»`) + } + } + + const fmtTime = (ts?: number) => ts ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '' + const author = firstMsg.user ? [firstMsg.user.first_name, firstMsg.user.last_name].filter(Boolean).join(' ') : '' + const time = fmtTime(firstMsg.timestamp) + const attribution = [author, time].filter(Boolean).join(', ') + let line = `- ${ctx.file}:${lineNum}: ${contextSnippet}\n → "${firstMsg.content}"${attribution ? ` — ${attribution}` : ''}` + + // Add replies + for (let i = 1; i < thread.messages.length; i++) { + const reply = thread.messages[i] + const rAuthor = reply.user ? [reply.user.first_name, reply.user.last_name].filter(Boolean).join(' ') : '' + const rTime = fmtTime(reply.timestamp) + const rAttribution = [rAuthor, rTime].filter(Boolean).join(', ') + line += `\n → "${reply.content}"${rAttribution ? ` — ${rAttribution}` : ''}` + } + lines.push(line) + } + + if (lines.length === 0) { + navigator.clipboard.writeText('No unresolved comments.') + return + } + + const text = `Overleaf comments (${lines.length} unresolved):\n\n${lines.join('\n\n')}` + navigator.clipboard.writeText(text) + } + const actions = [ { label: 'Fix Errors', @@ -193,6 +264,10 @@ function QuickActions({ ptyId }: { ptyId: string }) { send(`Explain the structure and content of: ${activeTab}`) } } + }, + { + label: 'Copy Comments', + action: copyComments } ] diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts index adb4957..e9c47ed 100644 --- a/src/renderer/src/stores/appStore.ts +++ b/src/renderer/src/stores/appStore.ts @@ -82,6 +82,7 @@ interface AppState { docPathMap: Record // docId → relativePath pathDocMap: Record // relativePath → docId setDocMaps: (docPath: Record, pathDoc: Record) => void + addDocPath: (docId: string, relPath: string) => void docVersions: Record // docId → version setDocVersion: (docId: string, version: number) => void overleafProject: { name: string; rootDocId: string } | null @@ -191,6 +192,10 @@ export const useAppStore = create((set) => ({ docPathMap: {}, pathDocMap: {}, setDocMaps: (docPath, pathDoc) => set({ docPathMap: docPath, pathDocMap: pathDoc }), + addDocPath: (docId, relPath) => set((s) => ({ + docPathMap: { ...s.docPathMap, [docId]: relPath }, + pathDocMap: { ...s.pathDocMap, [relPath]: docId } + })), docVersions: {}, setDocVersion: (docId, version) => set((s) => ({ docVersions: { ...s.docVersions, [docId]: version } })), -- cgit v1.2.3