summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-14 22:26:36 -0500
committerhaoyuren <13851610112@163.com>2026-03-14 22:26:36 -0500
commitc261b8c4a95a7af64e3cd95a65c50f4dcbbb802c (patch)
treeb2b31cd96a1ab8b1740402b417692a7ef5935581 /src
parent2ee6d867bd93bb955429a274865320dfa5bd0f69 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/main/fileSyncBridge.ts322
-rw-r--r--src/preload/index.ts5
-rw-r--r--src/renderer/src/App.tsx8
-rw-r--r--src/renderer/src/components/Terminal.tsx75
-rw-r--r--src/renderer/src/stores/appStore.ts5
5 files changed, 384 insertions, 31 deletions
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts
index 6593297..7763521 100644
--- a/src/main/fileSyncBridge.ts
+++ b/src/main/fileSyncBridge.ts
@@ -3,7 +3,7 @@
// Bidirectional file sync bridge: temp dir ↔ Overleaf via OT (text) + REST (binary)
import { join, dirname } from 'path'
-import { readFile, writeFile, mkdir, unlink, rename as fsRename, appendFile } from 'fs/promises'
+import { readFile, writeFile, mkdir, unlink, rename as fsRename, appendFile, readdir } from 'fs/promises'
import { createHash } from 'crypto'
import * as chokidar from 'chokidar'
import { diff_match_patch } from 'diff-match-patch'
@@ -22,6 +22,20 @@ function bridgeLog(msg: string) {
appendFile(LOG_FILE, line + '\n').catch(() => {})
}
+const TEXT_EXTENSIONS = new Set([
+ 'tex', 'bib', 'bst', 'cls', 'sty', 'dtx', 'ins', 'fd', 'def', 'cfg',
+ 'lbx', 'cbx', 'bbx', 'clo', 'lco', 'tikz', 'txt', 'md', 'py', 'r',
+ 'm', 'lua', 'sh', 'yml', 'yaml', 'json', 'xml', 'csv', 'tsv', 'html',
+ 'css', 'js', 'ts', 'c', 'cpp', 'h', 'hpp', 'java', 'rb', 'pl', 'mk', 'bbl'
+])
+
+function isTextExtension(relPath: string): boolean {
+ const name = relPath.split('/').pop()?.toLowerCase() || ''
+ if (name === 'makefile' || name === 'latexmkrc') return true
+ const ext = name.split('.').pop() || ''
+ return TEXT_EXTENSIONS.has(ext)
+}
+
export class FileSyncBridge {
private lastKnownContent = new Map<string, string>() // relPath → content (text docs)
private binaryHashes = new Map<string, string>() // relPath → sha1 hash (binary files)
@@ -29,6 +43,8 @@ export class FileSyncBridge {
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
private otClients = new Map<string, OtClient>() // docId → OtClient (non-editor docs)
private editorDocs = new Set<string>() // docIds owned by renderer
+ private pendingCreates = new Set<string>() // relPaths being created on Overleaf
+ private createdFolders = new Map<string, string>() // dirPath → folderId cache
private watcher: chokidar.FSWatcher | null = null
private socket: OverleafSocket
@@ -128,6 +144,8 @@ export class FileSyncBridge {
this.watcher.on('ready', () => {
bridgeLog(`[FileSyncBridge] chokidar ready, watching ${this.tmpDir}`)
+ // Scan for files that exist on disk but not on Overleaf (e.g. from previous failed sync)
+ this.scanForOrphanedFiles()
})
this.watcher.on('change', (absPath: string) => {
@@ -139,9 +157,12 @@ export class FileSyncBridge {
this.watcher.on('add', (absPath: string) => {
const relPath = absPath.replace(this.tmpDir + '/', '')
bridgeLog(`[FileSyncBridge] chokidar add: ${relPath}`)
- // Process if it's a known doc or fileRef
if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) {
+ // Known file — process as change
this.onFileChanged(relPath)
+ } else if (!this.pendingCreates.has(relPath)) {
+ // New local file — create on Overleaf
+ this.onNewLocalFile(relPath)
}
})
@@ -179,6 +200,8 @@ export class FileSyncBridge {
this.binaryHashes.clear()
this.writesInProgress.clear()
this.editorDocs.clear()
+ this.pendingCreates.clear()
+ this.createdFolders.clear()
console.log('[FileSyncBridge] stopped')
}
@@ -251,6 +274,12 @@ export class FileSyncBridge {
const folderPath = this.findFolderPath(folderId)
const relPath = folderPath + fileRef.name
+ // Skip if we just created this file locally
+ if (this.pendingCreates.has(relPath)) {
+ bridgeLog(`[FileSyncBridge] skipping reciveNewFile for ${relPath} (we created it)`)
+ return
+ }
+
bridgeLog(`[FileSyncBridge] remote new file: ${relPath} (${fileRef._id})`)
// Register in maps
@@ -273,6 +302,12 @@ export class FileSyncBridge {
const folderPath = this.findFolderPath(folderId)
const relPath = folderPath + doc.name
+ // Skip if we just created this doc locally
+ if (this.pendingCreates.has(relPath)) {
+ bridgeLog(`[FileSyncBridge] skipping reciveNewDoc for ${relPath} (we created it)`)
+ return
+ }
+
bridgeLog(`[FileSyncBridge] remote new doc: ${relPath} (${doc._id})`)
// Register in maps
@@ -377,30 +412,28 @@ export class FileSyncBridge {
}
}
- /** Find folder path prefix from folderId by looking at existing paths */
+ /** Find folder path prefix from folderId */
private findFolderPath(folderId: string): string {
- // Check doc paths to find a doc in this folder
- for (const relPath of Object.values(this.docPathMap)) {
- // Not a reliable method — fall back to root
- }
- // Check fileRef paths
- for (const relPath of Object.values(this.fileRefPathMap)) {
- // Not reliable either
- }
- // For root folder, return empty
- // For subfolders, we'd need the folder tree — but we can look for folder paths
- // ending with the folderId in the socket's project data
const projectData = this.socket.projectData
if (projectData) {
- const path = this.findFolderPathInTree(projectData.project.rootFolder, folderId, '')
- if (path !== null) return path
+ const rootFolder = projectData.project.rootFolder?.[0]
+ if (rootFolder) {
+ // Root folder itself → empty prefix
+ if (rootFolder._id === folderId) return ''
+ // Search children (skip root folder name to match walkRootFolder behavior)
+ const subFolders = rootFolder.folders as Array<{ _id: string; name: string; folders?: unknown[] }> | undefined
+ if (subFolders) {
+ const path = this.findFolderPathInTree(subFolders, folderId, '')
+ if (path !== null) return path
+ }
+ }
}
- return '' // default to root
+ return ''
}
private findFolderPathInTree(folders: Array<{ _id: string; name: string; folders?: unknown[] }>, targetId: string, prefix: string): string | null {
for (const f of folders) {
- if (f._id === targetId) return prefix
+ if (f._id === targetId) return prefix ? prefix + f.name + '/' : f.name + '/'
const sub = f.folders as Array<{ _id: string; name: string; folders?: unknown[] }> | undefined
if (sub) {
const subPrefix = prefix ? prefix + f.name + '/' : f.name + '/'
@@ -559,9 +592,9 @@ export class FileSyncBridge {
})
}
- private async uploadBinary(relPath: string, fileData: Buffer): Promise<void> {
+ private async uploadBinary(relPath: string, fileData: Buffer, overrideFolderId?: string): Promise<void> {
const fileName = relPath.includes('/') ? relPath.split('/').pop()! : relPath
- const folderId = this.findFolderIdForPath(relPath)
+ const folderId = overrideFolderId || this.findFolderIdForPath(relPath)
const ext = fileName.split('.').pop()?.toLowerCase() || ''
const mimeMap: Record<string, string> = {
@@ -627,22 +660,21 @@ export class FileSyncBridge {
/** Find the folder ID for a given relPath */
private findFolderIdForPath(relPath: string): string {
+ const projectData = this.socket.projectData
+ const rootId = projectData?.project.rootFolder?.[0]?._id || ''
const dir = dirname(relPath)
- if (dir === '.') {
- // Root folder
- const projectData = this.socket.projectData
- return projectData?.project.rootFolder?.[0]?._id || ''
- }
+ if (dir === '.') return rootId
- // Search project data for the folder
- const projectData = this.socket.projectData
+ // Search inside root folder's children (skip root folder name)
if (projectData) {
- const folderId = this.findFolderIdInTree(projectData.project.rootFolder, dir + '/', '')
- if (folderId) return folderId
+ const subFolders = projectData.project.rootFolder?.[0]?.folders as Array<{ _id: string; name: string; folders?: unknown[] }> | undefined
+ if (subFolders) {
+ const folderId = this.findFolderIdInTree(subFolders, dir + '/', '')
+ if (folderId) return folderId
+ }
}
- // Fallback to root
- return projectData?.project.rootFolder?.[0]?._id || ''
+ return rootId
}
private findFolderIdInTree(folders: Array<{ _id: string; name: string; folders?: unknown[] }>, targetPath: string, prefix: string): string | null {
@@ -775,6 +807,234 @@ export class FileSyncBridge {
}, 150)
}
+ // ── New local file creation ──────────────────────────────────
+
+ /** Scan temp dir for files not known to Overleaf (orphaned from previous sessions) */
+ private async scanForOrphanedFiles(): Promise<void> {
+ const walk = async (dir: string, prefix: string): Promise<string[]> => {
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
+ const results: string[] = []
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue
+ const relPath = prefix ? prefix + '/' + entry.name : entry.name
+ if (entry.isDirectory()) {
+ results.push(...await walk(join(dir, entry.name), relPath))
+ } else {
+ results.push(relPath)
+ }
+ }
+ return results
+ }
+
+ const allFiles = await walk(this.tmpDir, '')
+ let orphanCount = 0
+
+ for (const relPath of allFiles) {
+ if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) continue
+ // Skip LaTeX output files
+ if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|synctex)/.test(relPath)) continue
+ if (/(^|[/\\])\./.test(relPath)) continue
+
+ bridgeLog(`[FileSyncBridge] orphaned file found: ${relPath}`)
+ this.onNewLocalFile(relPath)
+ orphanCount++
+ }
+
+ if (orphanCount > 0) {
+ bridgeLog(`[FileSyncBridge] found ${orphanCount} orphaned files to sync`)
+ }
+ }
+
+ /** Debounce handler for new local files */
+ private onNewLocalFile(relPath: string): void {
+ if (this.stopped) return
+ if (this.writesInProgress.has(relPath)) return
+
+ // Skip LaTeX output files and dotfiles (same as chokidar ignored)
+ if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb)$/.test(relPath)) return
+ if (/(^|[/\\])\./.test(relPath)) return
+
+ // Debounce 1s to let the tool finish writing
+ const key = 'new:' + relPath
+ const existing = this.debounceTimers.get(key)
+ if (existing) clearTimeout(existing)
+
+ this.debounceTimers.set(key, setTimeout(() => {
+ this.debounceTimers.delete(key)
+ this.processNewFile(relPath)
+ }, 1000))
+ }
+
+ private async processNewFile(relPath: string): Promise<void> {
+ if (this.stopped) return
+ // Double-check it's still unknown (might have been registered by a server event)
+ if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) return
+
+ this.pendingCreates.add(relPath)
+
+ try {
+ if (isTextExtension(relPath)) {
+ await this.createLocalDocOnOverleaf(relPath)
+ } else {
+ await this.uploadNewLocalBinary(relPath)
+ }
+ } catch (e) {
+ bridgeLog(`[FileSyncBridge] failed to create ${relPath} on Overleaf: ${e}`)
+ } finally {
+ // Keep in pendingCreates briefly to avoid processing the echoed server event
+ setTimeout(() => this.pendingCreates.delete(relPath), 5000)
+ }
+ }
+
+ /** Create a text doc on Overleaf and sync its content */
+ private async createLocalDocOnOverleaf(relPath: string): Promise<void> {
+ const content = await readFile(join(this.tmpDir, relPath), 'utf-8')
+ const dir = dirname(relPath)
+ const fileName = relPath.split('/').pop()!
+
+ // Ensure parent folder exists
+ const folderId = await this.ensureFolderExists(dir === '.' ? '' : dir)
+
+ // Create doc via REST API
+ const result = await this.overleafPost(`/project/${this.projectId}/doc`, {
+ name: fileName,
+ parent_folder_id: folderId
+ })
+
+ if (!result.ok || !result.data?._id) {
+ throw new Error(`Create doc failed: HTTP ${result.status} ${JSON.stringify(result.data)}`)
+ }
+
+ const docId = result.data._id as string
+ bridgeLog(`[FileSyncBridge] created doc "${relPath}" (${docId}) on Overleaf`)
+
+ // Update maps
+ this.docPathMap[docId] = relPath
+ this.pathDocMap[relPath] = docId
+
+ // Join the doc
+ const joinResult = await this.socket.joinDoc(docId)
+ const serverContent = (joinResult.docLines || []).join('\n')
+
+ // Create OT client
+ const otClient = new OtClient(
+ joinResult.version,
+ (ops, version) => this.sendOps(docId, ops, version),
+ (ops) => this.onRemoteApply(docId, ops)
+ )
+ this.otClients.set(docId, otClient)
+
+ // Send content as OT ops (doc starts empty on server)
+ if (content && content !== serverContent) {
+ this.lastKnownContent.set(relPath, content)
+ const diffs = dmp.diff_main(serverContent, content)
+ dmp.diff_cleanupEfficiency(diffs)
+ const ops = diffsToOtOps(diffs)
+
+ if (ops.length > 0) {
+ otClient.onLocalOps(ops)
+ }
+ } else {
+ this.lastKnownContent.set(relPath, serverContent)
+ }
+
+ // Notify renderer about the new doc
+ this.mainWindow.webContents.send('sync:newDoc', { docId, relPath })
+ }
+
+ /** Upload a new binary file to Overleaf */
+ private async uploadNewLocalBinary(relPath: string): Promise<void> {
+ const fullPath = join(this.tmpDir, relPath)
+ const fileData = await readFile(fullPath)
+ const dir = dirname(relPath)
+
+ const folderId = await this.ensureFolderExists(dir === '.' ? '' : dir)
+
+ bridgeLog(`[FileSyncBridge] uploading new binary: ${relPath} (${fileData.length} bytes)`)
+ await this.uploadBinary(relPath, fileData, folderId)
+ this.binaryHashes.set(relPath, createHash('sha1').update(fileData).digest('hex'))
+
+ // Notify renderer
+ this.mainWindow.webContents.send('sync:newDoc', { docId: null, relPath })
+ }
+
+ /** Ensure a folder path exists on Overleaf, creating intermediaries as needed */
+ private async ensureFolderExists(dirPath: string): Promise<string> {
+ if (!dirPath || dirPath === '.') {
+ return this.socket.projectData?.project.rootFolder?.[0]?._id || ''
+ }
+
+ // Check cache
+ const cached = this.createdFolders.get(dirPath)
+ if (cached) return cached
+
+ // Check project data — search inside root folder's children
+ const projectData = this.socket.projectData
+ if (projectData) {
+ const subFolders = projectData.project.rootFolder?.[0]?.folders as Array<{ _id: string; name: string; folders?: unknown[] }> | undefined
+ if (subFolders) {
+ const folderId = this.findFolderIdInTree(subFolders, dirPath + '/', '')
+ if (folderId) {
+ this.createdFolders.set(dirPath, folderId)
+ return folderId
+ }
+ }
+ }
+
+ // Create — ensure parent exists first
+ const parts = dirPath.split('/')
+ const parentDir = parts.slice(0, -1).join('/')
+ const folderName = parts[parts.length - 1]
+
+ const parentId = await this.ensureFolderExists(parentDir)
+
+ const result = await this.overleafPost(`/project/${this.projectId}/folder`, {
+ name: folderName,
+ parent_folder_id: parentId
+ })
+
+ if (result.ok && result.data?._id) {
+ const folderId = result.data._id as string
+ this.createdFolders.set(dirPath, folderId)
+ bridgeLog(`[FileSyncBridge] created folder "${folderName}" (${folderId})`)
+ return folderId
+ }
+
+ throw new Error(`Failed to create folder "${dirPath}": HTTP ${result.status}`)
+ }
+
+ /** POST to Overleaf REST API */
+ private overleafPost(path: string, body: object): Promise<{ ok: boolean; data?: any; status: number }> {
+ return new Promise((resolve, reject) => {
+ const req = net.request({
+ method: 'POST',
+ url: `https://www.overleaf.com${path}`
+ })
+ req.setHeader('Cookie', this.cookie)
+ req.setHeader('Content-Type', 'application/json')
+ req.setHeader('Accept', 'application/json')
+ if (this.csrfToken) req.setHeader('x-csrf-token', this.csrfToken)
+
+ let resBody = ''
+ req.on('response', (res) => {
+ res.on('data', (chunk: Buffer) => { resBody += chunk.toString() })
+ res.on('end', () => {
+ try {
+ const data = JSON.parse(resBody)
+ resolve({ ok: (res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300, data, status: res.statusCode || 0 })
+ } catch {
+ resolve({ ok: false, status: res.statusCode || 0 })
+ }
+ })
+ })
+ req.on('error', reject)
+ req.write(JSON.stringify(body))
+ req.end()
+ })
+ }
+
+ // ── Public getters ─────────────────────────────────────────────
+
/** Get the temp dir path */
get dir(): string {
return this.tmpDir
diff --git a/src/preload/index.ts b/src/preload/index.ts
index aa16872..7b23d27 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -149,6 +149,11 @@ const api = {
},
syncContentChanged: (docId: string, content: string) =>
ipcRenderer.invoke('sync:contentChanged', docId, content),
+ onSyncNewDoc: (cb: (data: { docId: string | null; relPath: string }) => void) => {
+ const handler = (_e: Electron.IpcRendererEvent, data: { docId: string | null; relPath: string }) => cb(data)
+ ipcRenderer.on('sync:newDoc', handler)
+ return () => ipcRenderer.removeListener('sync:newDoc', handler)
+ },
// Cursor tracking
cursorUpdate: (docId: string, row: number, column: number) =>
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<string, {
+ messages: Array<{ content: string; timestamp?: number; user?: { first_name?: string; last_name?: string } }>
+ 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<string, string> // docId → relativePath
pathDocMap: Record<string, string> // relativePath → docId
setDocMaps: (docPath: Record<string, string>, pathDoc: Record<string, string>) => void
+ addDocPath: (docId: string, relPath: string) => void
docVersions: Record<string, number> // docId → version
setDocVersion: (docId: string, version: number) => void
overleafProject: { name: string; rootDocId: string } | null
@@ -191,6 +192,10 @@ export const useAppStore = create<AppState>((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 } })),