summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--src/main/fileSyncBridge.ts291
-rw-r--r--src/main/index.ts207
-rw-r--r--src/preload/index.ts102
-rw-r--r--src/renderer/src/App.css625
-rw-r--r--src/renderer/src/App.tsx57
-rw-r--r--src/renderer/src/components/Editor.tsx8
-rw-r--r--src/renderer/src/components/PdfViewer.tsx8
-rw-r--r--src/renderer/src/components/ProjectList.tsx1218
-rw-r--r--src/renderer/src/components/Toolbar.tsx5
-rw-r--r--src/renderer/src/data/latexClassesAndStyles.ts58
-rw-r--r--src/renderer/src/data/latexCommands.ts304
-rw-r--r--src/renderer/src/data/latexEnvironmentTemplates.ts85
-rw-r--r--src/renderer/src/data/latexEnvironments.ts101
-rw-r--r--src/renderer/src/data/latexPackageNames.ts103
-rw-r--r--src/renderer/src/data/latexTopHundred.ts701
-rw-r--r--src/renderer/src/extensions/latexAutocomplete.ts1061
-rw-r--r--src/renderer/src/extensions/latexClosing.ts9
-rw-r--r--src/renderer/src/extensions/remoteCursors.ts347
19 files changed, 4160 insertions, 1133 deletions
diff --git a/.gitignore b/.gitignore
index eee0821..5e9c666 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ dist/
*.AppImage
.DS_Store
reference/
+*.tsbuildinfo
+electron.vite.config.js
+electron.vite.config.d.ts
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts
index 59be86f..b7e1979 100644
--- a/src/main/fileSyncBridge.ts
+++ b/src/main/fileSyncBridge.ts
@@ -3,6 +3,7 @@
// Bidirectional file sync bridge: temp dir ↔ Overleaf via OT (text) + REST (binary)
import { join, dirname } from 'path'
+import { existsSync } from 'fs'
import { readFile, writeFile, mkdir, unlink, rename as fsRename, appendFile, readdir, rm } from 'fs/promises'
import { createHash } from 'crypto'
import * as chokidar from 'chokidar'
@@ -16,26 +17,50 @@ import { isInsert, isDelete } from './otTypes'
const dmp = new diff_match_patch()
const LOG_FILE = '/tmp/lattex-bridge.log'
-function bridgeLog(msg: string) {
- const line = `[${new Date().toISOString()}] ${msg}`
+function bridgeLog(msg: string, ...rest: unknown[]) {
+ const line = `[${new Date().toISOString()}] ${msg}${rest.length ? ' ' + rest.map(String).join(' ') : ''}`
console.log(line)
appendFile(LOG_FILE, line + '\n').catch(() => {})
}
+// Official Overleaf text-extension list (creates a doc rather than a binary
+// file) — mirrors `textExtensions` in the Overleaf server's settings.defaults,
+// plus the extensions LatteX historically synced as editable docs (the server
+// supports extra text extensions via ADDITIONAL_TEXT_EXTENSIONS, and the
+// /doc endpoint accepts any name).
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'
+ 'tex', 'latex', 'sty', 'cls', 'bst', 'bib', 'bibtex', 'txt', 'tikz',
+ 'mtx', 'rtex', 'md', 'asy', 'lbx', 'bbx', 'cbx', 'm', 'lco', 'dtx',
+ 'ins', 'ist', 'def', 'clo', 'ldf', 'rmd', 'qmd', 'lua', 'py', 'gv',
+ 'mf', 'yml', 'yaml', 'lhs', 'lean', 'lean4', 'hs', 'mk', 'xmpdata',
+ 'cfg', 'rnw', 'ltx', 'inc',
+ 'fd', 'r', 'sh', 'json', 'xml', 'csv', 'tsv', 'html', 'css', 'js',
+ 'ts', 'c', 'cpp', 'h', 'hpp', 'java', 'rb', 'pl'
])
+// Official Overleaf `editableFilenames` (case-insensitive)
+const EDITABLE_FILENAMES = new Set(['latexmkrc', '.latexmkrc', 'makefile', 'gnumakefile'])
+
function isTextExtension(relPath: string): boolean {
const name = relPath.split('/').pop()?.toLowerCase() || ''
- if (name === 'makefile' || name === 'latexmkrc') return true
+ if (EDITABLE_FILENAMES.has(name)) return true
const ext = name.split('.').pop() || ''
return TEXT_EXTENSIONS.has(ext)
}
+// Two-tier build-artifact filtering (based on the server's fileIgnorePattern,
+// but only for extensions that are never legitimate project content):
+//
+// Tier 1 (JUNK_EXT_RE): pure build noise — never synced, chokidar ignores it.
+// Tier 2 (MAYBE_ARTIFACT_EXT_RE): extensions that CAN be real project files
+// (figure/standalone PDFs, .docx supplements, .csv.gz datasets, arXiv .bbl).
+// These sync unless there is concrete evidence of a local compile: the file
+// is a tracked output of our own latexmk run, or a same-basename .aux/.log/
+// .fls/.fdb_latexmk sits next to it. Known Overleaf entities always sync.
+const JUNK_EXT_RE = /\.(aux|log|lof|lot|fls|fdb_latexmk|synctex|synctex\(busy\)|synctex\.gz|out|toc|nav|snm|vrb|xdv|pdfxref|stderr|stdout|chktex|blg|ilg|idx|ind|nlo|glo|gls|glg|thm|spl|swp|pdfsync)$/i
+const MAYBE_ARTIFACT_EXT_RE = /\.(pdf|dvi|ps|bbl|gz|doc|docx)$/i
+const COMPILE_EVIDENCE_EXTS = ['.aux', '.log', '.fls', '.fdb_latexmk']
+
export class FileSyncBridge {
private lastKnownContent = new Map<string, string>() // relPath → content (text docs)
private binaryHashes = new Map<string, string>() // relPath → sha1 hash (binary files)
@@ -44,8 +69,14 @@ export class FileSyncBridge {
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 pendingCreateExpiry = new Map<string, ReturnType<typeof setTimeout>>()
private createdFolders = new Map<string, string>() // dirPath → folderId cache
private watcher: chokidar.FSWatcher | null = null
+ private rescanTimer: ReturnType<typeof setInterval> | null = null
+ private retryTimers = new Map<string, ReturnType<typeof setTimeout>>() // relPath → retry timer
+ private retryAttempts = new Map<string, number>() // relPath → attempt count
+ private permanentFailures = new Set<string>() // relPaths that exhausted retries
+ private compileOutputs = new Set<string>() // relPaths written by local compiles
private socket: OverleafSocket
private tmpDir: string
@@ -63,6 +94,7 @@ export class FileSyncBridge {
private serverEventHandler: ((name: string, args: unknown[]) => void) | null = null
private docRejoinedHandler: ((docId: string, result: { docLines: string[]; version: number }) => void) | null = null
private stopped = false
+ private refreshAuth: (() => Promise<{ cookie: string; csrfToken: string } | null>) | null = null
constructor(
socket: OverleafSocket,
@@ -73,8 +105,10 @@ export class FileSyncBridge {
mainWindow: BrowserWindow,
projectId: string,
cookie: string,
- csrfToken: string
+ csrfToken: string,
+ refreshAuth?: () => Promise<{ cookie: string; csrfToken: string } | null>
) {
+ this.refreshAuth = refreshAuth || null
this.socket = socket
this.tmpDir = tmpDir
this.docPathMap = docPathMap
@@ -171,10 +205,10 @@ export class FileSyncBridge {
interval: 500,
atomic: true,
ignored: [
- /(^|[/\\])\../, // dotfiles
- /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/, // LaTeX output files
+ /(^|[/\\])\../, // dotfiles (also covers .build/ and .claude/)
+ JUNK_EXT_RE, // pure build noise (aux/log/synctex/…) — never project content
/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/, // App-generated config files
- /(?:^|[/\\])claude-workspace(?:[/\\]|$)/ // Claude Code scratch space (not synced)
+ /(?:^|[/\\])(?:claude-workspace|__MACOSX)(?:[/\\]|$)/ // scratch space + zip junk
]
})
@@ -184,15 +218,28 @@ export class FileSyncBridge {
this.scanForOrphanedFiles()
})
+ // Periodic rescan: catches files the watcher missed and retries failed
+ // creates/uploads (e.g. transient network errors, expired CSRF tokens).
+ this.rescanTimer = setInterval(() => {
+ if (!this.stopped) this.scanForOrphanedFiles()
+ }, 60_000)
+
this.watcher.on('change', (absPath: string) => {
const relPath = absPath.replace(this.tmpDir + '/', '')
bridgeLog(`[FileSyncBridge] chokidar change: ${relPath}`)
- this.onFileChanged(relPath)
+ // A real fs event means new content — give parked failures a fresh chance
+ this.permanentFailures.delete(relPath)
+ if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) {
+ this.onFileChanged(relPath)
+ } else if (!this.pendingCreates.has(relPath)) {
+ this.onNewLocalFile(relPath)
+ }
})
this.watcher.on('add', (absPath: string) => {
const relPath = absPath.replace(this.tmpDir + '/', '')
bridgeLog(`[FileSyncBridge] chokidar add: ${relPath}`)
+ this.permanentFailures.delete(relPath)
if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) {
// Known file — process as change
this.onFileChanged(relPath)
@@ -219,6 +266,22 @@ export class FileSyncBridge {
}
this.debounceTimers.clear()
+ if (this.rescanTimer) {
+ clearInterval(this.rescanTimer)
+ this.rescanTimer = null
+ }
+ for (const timer of this.retryTimers.values()) {
+ clearTimeout(timer)
+ }
+ this.retryTimers.clear()
+ this.retryAttempts.clear()
+ for (const timer of this.pendingCreateExpiry.values()) {
+ clearTimeout(timer)
+ }
+ this.pendingCreateExpiry.clear()
+ this.permanentFailures.clear()
+ this.compileOutputs.clear()
+
// Remove event handlers
if (this.serverEventHandler) {
this.socket.removeListener('serverEvent', this.serverEventHandler)
@@ -778,28 +841,44 @@ export class FileSyncBridge {
}
private async processBinaryChange(relPath: string, fileRefId: string): Promise<void> {
+ if (this.stopped) return
+ // Revalidate — the entity may have been renamed/moved/removed while a
+ // retry was pending; the stale closure must not upload under the old path.
+ if (this.pathFileRefMap[relPath] !== fileRefId) {
+ this.clearRetryState(relPath, false)
+ return
+ }
+
const fullPath = join(this.tmpDir, relPath)
let fileData: Buffer
try {
fileData = await readFile(fullPath)
} catch {
+ this.clearRetryState(relPath, false)
return // file deleted or unreadable
}
// Layer 2: Hash equality check
const newHash = createHash('sha1').update(fileData).digest('hex')
const oldHash = this.binaryHashes.get(relPath)
- if (newHash === oldHash) return
+ if (newHash === oldHash) {
+ this.clearRetryState(relPath) // content reverted to the synced state
+ return
+ }
bridgeLog(`[FileSyncBridge] binary change detected: ${relPath} (${fileData.length} bytes)`)
- this.binaryHashes.set(relPath, newHash)
- // Upload to Overleaf via REST API (this replaces the existing file)
+ // Upload to Overleaf via REST API (this replaces the existing file).
+ // Only record the hash after a successful upload — otherwise a failed
+ // upload would make the change look synced and it would never retry.
try {
await this.uploadBinary(relPath, fileData)
+ this.binaryHashes.set(relPath, newHash)
+ this.clearRetryState(relPath)
} catch (e) {
- bridgeLog(`[FileSyncBridge] failed to upload binary ${relPath}:`, e)
+ bridgeLog(`[FileSyncBridge] failed to upload binary ${relPath}: ${e}`)
+ this.scheduleRetry(relPath, () => this.processBinaryChange(relPath, fileRefId))
}
}
@@ -840,7 +919,30 @@ export class FileSyncBridge {
})
}
- private async uploadBinary(relPath: string, fileData: Buffer, overrideFolderId?: string): Promise<string | undefined> {
+ /** Refresh session cookie + CSRF token (e.g. after a 403), returns true if updated */
+ private async tryRefreshAuth(): Promise<boolean> {
+ if (!this.refreshAuth) return false
+ try {
+ const auth = await this.refreshAuth()
+ if (auth) {
+ this.cookie = auth.cookie
+ this.csrfToken = auth.csrfToken
+ bridgeLog('[FileSyncBridge] auth refreshed')
+ return true
+ }
+ } catch (e) {
+ bridgeLog(`[FileSyncBridge] auth refresh failed: ${e}`)
+ }
+ return false
+ }
+
+ /** Update auth credentials (called from main when session/CSRF rotates) */
+ updateAuth(cookie: string, csrfToken: string): void {
+ this.cookie = cookie
+ this.csrfToken = csrfToken
+ }
+
+ private async uploadBinary(relPath: string, fileData: Buffer, overrideFolderId?: string, isRetryAfterAuth = false): Promise<string | undefined> {
const fileName = relPath.includes('/') ? relPath.split('/').pop()! : relPath
const folderId = overrideFolderId || this.findFolderIdForPath(relPath)
@@ -881,6 +983,17 @@ export class FileSyncBridge {
res.on('data', (chunk: Buffer) => { resBody += chunk.toString() })
res.on('end', () => {
bridgeLog(`[FileSyncBridge] upload ${relPath}: ${res.statusCode} ${resBody.slice(0, 200)}`)
+ if (res.statusCode === 403 && !isRetryAfterAuth) {
+ // Stale CSRF token — refresh and retry once
+ this.tryRefreshAuth().then((refreshed) => {
+ if (refreshed) {
+ this.uploadBinary(relPath, fileData, overrideFolderId, true).then(resolve, reject)
+ } else {
+ reject(new Error(`HTTP 403 (auth refresh failed)`))
+ }
+ })
+ return
+ }
try {
const data = JSON.parse(resBody)
if (data.success !== false && !data.error) {
@@ -1291,8 +1404,13 @@ export class FileSyncBridge {
for (const relPath of allFiles) {
if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) continue
- // Skip LaTeX output files, app-generated config files, and scratch space
- if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex|synctex)/.test(relPath)) continue
+ // Skip files already scheduled for retry (backoff manages them) and
+ // files that exhausted their retries (a new fs event resets those)
+ if (this.retryTimers.has(relPath)) continue
+ if (this.pendingCreates.has(relPath)) continue
+ if (this.permanentFailures.has(relPath)) continue
+ // Skip LaTeX build artifacts, dotfiles, app config files, and scratch space
+ if (this.isCompileArtifact(relPath)) continue
if (/(^|[/\\])\./.test(relPath)) continue
if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) continue
if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') continue
@@ -1312,8 +1430,8 @@ export class FileSyncBridge {
if (this.stopped) return
if (this.writesInProgress.has(relPath)) return
- // Skip LaTeX output files, dotfiles, app-generated config files, and scratch space
- if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/.test(relPath)) return
+ // Skip LaTeX build artifacts, dotfiles, app config files, and scratch space
+ if (this.isCompileArtifact(relPath)) return
if (/(^|[/\\])\./.test(relPath)) return
if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) return
if (relPath.startsWith('claude-workspace/') || relPath === 'claude-workspace') return
@@ -1332,9 +1450,17 @@ export class FileSyncBridge {
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
+ if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) {
+ this.clearRetryState(relPath, false)
+ return
+ }
+ // File may have been deleted between detection and processing
+ if (!existsSync(join(this.tmpDir, relPath))) {
+ this.clearRetryState(relPath, false)
+ return
+ }
- this.pendingCreates.add(relPath)
+ this.addPendingCreate(relPath)
try {
if (isTextExtension(relPath)) {
@@ -1342,14 +1468,119 @@ export class FileSyncBridge {
} else {
await this.uploadNewLocalBinary(relPath)
}
+ this.clearRetryState(relPath)
} catch (e) {
bridgeLog(`[FileSyncBridge] failed to create ${relPath} on Overleaf: ${e}`)
+ this.scheduleRetry(relPath, () => this.processNewFile(relPath))
} finally {
// Keep in pendingCreates briefly to avoid processing the echoed server event
- setTimeout(() => this.pendingCreates.delete(relPath), 5000)
+ this.expirePendingCreate(relPath)
}
}
+ // ── Compile-artifact detection ───────────────────────────────
+
+ /** Register a file our own local compile will write (e.g. root main.pdf) */
+ addCompileOutput(relPath: string): void {
+ this.compileOutputs.add(relPath)
+ }
+
+ /**
+ * True when relPath is LaTeX build output rather than project content.
+ * Tier 1: pure-noise extensions. Tier 2: pdf/dvi/bbl/… are treated as
+ * output only with evidence of a local compile — either we tracked the
+ * output ourselves, or a same-basename .aux/.log/.fls/.fdb_latexmk exists
+ * (latexmk always leaves those next to what it builds). A standalone
+ * figure PDF or a .docx supplement has no such evidence and syncs.
+ */
+ private isCompileArtifact(relPath: string): boolean {
+ if (JUNK_EXT_RE.test(relPath)) return true
+ const m = relPath.match(MAYBE_ARTIFACT_EXT_RE)
+ if (!m) return false
+ if (this.compileOutputs.has(relPath)) return true
+ const base = relPath.replace(MAYBE_ARTIFACT_EXT_RE, '')
+ for (const ext of COMPILE_EVIDENCE_EXTS) {
+ if (existsSync(join(this.tmpDir, base + ext))) return true
+ }
+ return false
+ }
+
+ // ── pendingCreates bookkeeping ───────────────────────────────
+ //
+ // Files we are creating on Overleaf are guarded so the server's echo
+ // (reciveNewDoc/reciveNewFile) isn't processed as a remote create. The
+ // expiry timer is per-path and cancelled on re-add, so a retry attempt
+ // can't have its guard stripped by a stale timer from a failed attempt.
+
+ private addPendingCreate(relPath: string): void {
+ const existing = this.pendingCreateExpiry.get(relPath)
+ if (existing) clearTimeout(existing)
+ this.pendingCreateExpiry.delete(relPath)
+ this.pendingCreates.add(relPath)
+ }
+
+ private expirePendingCreate(relPath: string, delayMs = 5000): void {
+ const existing = this.pendingCreateExpiry.get(relPath)
+ if (existing) clearTimeout(existing)
+ this.pendingCreateExpiry.set(relPath, setTimeout(() => {
+ this.pendingCreateExpiry.delete(relPath)
+ this.pendingCreates.delete(relPath)
+ }, delayMs))
+ }
+
+ // ── Retry with exponential backoff ───────────────────────────
+ //
+ // Sync failures (expired CSRF token, transient network errors, server 5xx)
+ // must not silently drop files. Retries back off 5s → 10s → 20s → … capped
+ // at 5 minutes. After MAX_RETRY_ATTEMPTS the file is parked as a permanent
+ // failure (surfaced in the UI); a subsequent local change resets it.
+
+ private static readonly MAX_RETRY_ATTEMPTS = 8
+
+ private scheduleRetry(relPath: string, action: () => void): void {
+ if (this.stopped) return
+
+ const attempts = (this.retryAttempts.get(relPath) ?? 0) + 1
+ this.retryAttempts.set(relPath, attempts)
+
+ if (attempts > FileSyncBridge.MAX_RETRY_ATTEMPTS) {
+ bridgeLog(`[FileSyncBridge] giving up on ${relPath} after ${attempts - 1} attempts`)
+ this.permanentFailures.add(relPath)
+ this.retryAttempts.delete(relPath)
+ this.notifySyncStatus(relPath, 'failed')
+ return
+ }
+
+ const delay = Math.min(5000 * 2 ** (attempts - 1), 300_000)
+ bridgeLog(`[FileSyncBridge] retry #${attempts} for ${relPath} in ${Math.round(delay / 1000)}s`)
+ this.notifySyncStatus(relPath, 'retrying', attempts)
+
+ const existing = this.retryTimers.get(relPath)
+ if (existing) clearTimeout(existing)
+
+ this.retryTimers.set(relPath, setTimeout(() => {
+ this.retryTimers.delete(relPath)
+ if (!this.stopped) action()
+ }, delay))
+ }
+
+ /** Clear retry state; emits a "synced" status if the file was retrying */
+ private clearRetryState(relPath: string, notify = true): void {
+ const timer = this.retryTimers.get(relPath)
+ if (timer) clearTimeout(timer)
+ this.retryTimers.delete(relPath)
+ this.permanentFailures.delete(relPath)
+ if (this.retryAttempts.has(relPath)) {
+ this.retryAttempts.delete(relPath)
+ if (notify) this.notifySyncStatus(relPath, 'synced')
+ }
+ }
+
+ private notifySyncStatus(relPath: string, status: 'retrying' | 'synced' | 'failed', attempts?: number): void {
+ if (this.mainWindow.isDestroyed() || this.mainWindow.webContents.isDestroyed()) return
+ this.mainWindow.webContents.send('sync:fileStatus', { relPath, status, attempts })
+ }
+
/** 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')
@@ -1477,8 +1708,16 @@ export class FileSyncBridge {
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 }> {
+ /** POST to Overleaf REST API. Refreshes auth and retries once on 403. */
+ private async overleafPost(path: string, body: object): Promise<{ ok: boolean; data?: any; status: number }> {
+ const result = await this.overleafPostRaw(path, body)
+ if (result.status === 403 && await this.tryRefreshAuth()) {
+ return this.overleafPostRaw(path, body)
+ }
+ return result
+ }
+
+ private overleafPostRaw(path: string, body: object): Promise<{ ok: boolean; data?: any; status: number }> {
return new Promise((resolve, reject) => {
const req = net.request({
method: 'POST',
diff --git a/src/main/index.ts b/src/main/index.ts
index 4d06ebd..f03f8b6 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -431,8 +431,56 @@ async function loadOverleafSession(): Promise<void> {
} catch { /* no saved session */ }
}
-// Helper: make authenticated request to Overleaf web API
+/**
+ * Re-fetch the CSRF token from the projects page (it can rotate/expire).
+ * Single-flight: concurrent 403s share one refresh. Validates the session
+ * first — when the cookie itself is dead, the /project fetch would redirect
+ * to the login page whose (anonymous) CSRF token must not be adopted.
+ */
+let csrfRefreshInFlight: Promise<boolean> | null = null
+
+function refreshCsrfToken(): Promise<boolean> {
+ if (csrfRefreshInFlight) return csrfRefreshInFlight
+ csrfRefreshInFlight = (async () => {
+ try {
+ // Electron's net follows redirects, so an expired session may surface
+ // as a 200 login page rather than a 401 — require a JSON body too.
+ const session = await overleafFetchRaw('/user/projects')
+ if (!session.ok || typeof session.data !== 'object' || session.data === null) {
+ console.log('[overleaf] session expired — cannot refresh CSRF token')
+ sendToRenderer('auth:sessionExpired')
+ return false
+ }
+ const result = await overleafFetchRaw('/project', { raw: true })
+ if (!result.ok || typeof result.data !== 'string') return false
+ const m = (result.data as string).match(/ol-csrfToken[^>]*content="([^"]+)"/)
+ if (m) {
+ overleafCsrfToken = m[1]
+ saveOverleafSession()
+ return true
+ }
+ return false
+ } finally {
+ csrfRefreshInFlight = null
+ }
+ })()
+ return csrfRefreshInFlight
+}
+
+// Helper: make authenticated request to Overleaf web API.
+// On 403 (stale CSRF token), refreshes the token and retries once.
async function overleafFetch(path: string, options: { method?: string; body?: string; raw?: boolean; cookie?: string } = {}): Promise<{ ok: boolean; status: number; data: unknown; setCookies: string[] }> {
+ const result = await overleafFetchRaw(path, options)
+ if (result.status === 403 && options.method && options.method !== 'GET') {
+ console.log(`[overleaf] 403 on ${options.method} ${path} — refreshing CSRF token and retrying`)
+ if (await refreshCsrfToken()) {
+ return overleafFetchRaw(path, options)
+ }
+ }
+ return result
+}
+
+async function overleafFetchRaw(path: string, options: { method?: string; body?: string; raw?: boolean; cookie?: string } = {}): Promise<{ ok: boolean; status: number; data: unknown; setCookies: string[] }> {
return new Promise((resolve) => {
const url = `https://www.overleaf.com${path}`
const request = net.request({ url, method: options.method || 'GET' })
@@ -563,6 +611,8 @@ ipcMain.handle('overleaf:webLogin', async () => {
if (ok && !resolved) {
resolved = true
saveOverleafSession()
+ // Push fresh credentials into a live sync bridge (re-login mid-session)
+ fileSyncBridge?.updateAuth(overleafSessionCookie, overleafCsrfToken)
loginWindow.close()
resolve({ success: true })
}
@@ -854,7 +904,16 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => {
// Set up file sync bridge for bidirectional sync
const tmpDir = compilationManager.dir
- fileSyncBridge = new FileSyncBridge(overleafSock, tmpDir, docPathMap, pathDocMap, fileRefs, mainWindow!, projectId, overleafSessionCookie, overleafCsrfToken)
+ fileSyncBridge = new FileSyncBridge(
+ overleafSock, tmpDir, docPathMap, pathDocMap, fileRefs, mainWindow!,
+ projectId, overleafSessionCookie, overleafCsrfToken,
+ async () => {
+ // Re-fetch CSRF token (rotates over long sessions); cookie may also
+ // have been refreshed by a re-login in the meantime.
+ const ok = await refreshCsrfToken()
+ return ok ? { cookie: overleafSessionCookie, csrfToken: overleafCsrfToken } : null
+ }
+ )
await fileSyncBridge.start()
// Start MCP compile watcher (detects compile requests from Claude Code)
@@ -1198,6 +1257,19 @@ ipcMain.handle('sync:contentChanged', async (_e, docId: string, content: string)
fileSyncBridge?.onEditorContentChanged(docId, content)
})
+// Renderer ← bridge: all synced doc contents (for project-wide autocomplete)
+ipcMain.handle('sync:getAllDocContents', async () => {
+ return fileSyncBridge ? fileSyncBridge.getAllDocContents() : []
+})
+
+// Official metadata endpoint: labels + package command snippets per doc
+ipcMain.handle('overleaf:getMetadata', async (_e, projectId: string) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch(`/project/${projectId}/metadata`)
+ if (!result.ok) return { success: false, message: `HTTP ${result.status}` }
+ return { success: true, data: result.data }
+})
+
// ── Cursor Tracking ────────────────────────────────────────────
ipcMain.handle('cursor:update', async (_e, docId: string, row: number, column: number) => {
@@ -1264,6 +1336,8 @@ ipcMain.handle('overleaf:listProjects', async () => {
lastUpdatedBy?: { firstName: string; lastName: string; email?: string } | null
accessLevel?: string
source?: string
+ archived?: boolean
+ trashed?: boolean
}>
return {
@@ -1275,7 +1349,9 @@ ipcMain.handle('overleaf:listProjects', async () => {
owner: p.owner ? { firstName: p.owner.firstName, lastName: p.owner.lastName, email: p.owner.email } : undefined,
lastUpdatedBy: p.lastUpdatedBy ? { firstName: p.lastUpdatedBy.firstName, lastName: p.lastUpdatedBy.lastName } : null,
accessLevel: p.accessLevel || 'unknown',
- source: p.source || ''
+ source: p.source || '',
+ archived: !!p.archived,
+ trashed: !!p.trashed
}))
}
})
@@ -1345,6 +1421,127 @@ ipcMain.handle('overleaf:uploadProject', async () => {
})
})
+// ── Project Dashboard Operations (official Overleaf endpoints) ──
+//
+// Endpoints mirror services/web/frontend/js/features/project-list/util/api.ts
+// in the Overleaf source (the web dashboard's own API client).
+
+ipcMain.handle('overleaf:getTags', async () => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch('/tag')
+ if (!result.ok) return { success: false, message: `HTTP ${result.status}` }
+ return { success: true, tags: result.data }
+})
+
+ipcMain.handle('overleaf:createTag', async (_e, name: string, color?: string) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch('/tag', {
+ method: 'POST',
+ body: JSON.stringify({ name, color })
+ })
+ if (!result.ok) return { success: false, message: `HTTP ${result.status}` }
+ return { success: true, tag: result.data }
+})
+
+ipcMain.handle('overleaf:editTag', async (_e, tagId: string, name: string, color?: string) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch(`/tag/${tagId}/edit`, {
+ method: 'POST',
+ body: JSON.stringify({ name, color })
+ })
+ return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` }
+})
+
+ipcMain.handle('overleaf:deleteTag', async (_e, tagId: string) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch(`/tag/${tagId}`, { method: 'DELETE' })
+ return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` }
+})
+
+ipcMain.handle('overleaf:addProjectsToTag', async (_e, tagId: string, projectIds: string[]) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch(`/tag/${tagId}/projects`, {
+ method: 'POST',
+ body: JSON.stringify({ projectIds })
+ })
+ return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` }
+})
+
+ipcMain.handle('overleaf:removeProjectsFromTag', async (_e, tagId: string, projectIds: string[]) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch(`/tag/${tagId}/projects/remove`, {
+ method: 'POST',
+ body: JSON.stringify({ projectIds })
+ })
+ return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` }
+})
+
+// Archive / trash state transitions. Paths match the official router
+// (case-sensitive: /Project/:id/archive vs /project/:id/trash).
+ipcMain.handle('overleaf:setProjectState', async (_e, projectId: string, action: string) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+
+ const routes: Record<string, { method: string; path: string }> = {
+ archive: { method: 'POST', path: `/project/${projectId}/archive` },
+ unarchive: { method: 'DELETE', path: `/project/${projectId}/archive` },
+ trash: { method: 'POST', path: `/project/${projectId}/trash` },
+ untrash: { method: 'DELETE', path: `/project/${projectId}/trash` },
+ delete: { method: 'DELETE', path: `/project/${projectId}` },
+ leave: { method: 'POST', path: `/project/${projectId}/leave` }
+ }
+ const route = routes[action]
+ if (!route) return { success: false, message: `unknown action: ${action}` }
+
+ const result = await overleafFetch(route.path, { method: route.method, body: '{}' })
+ return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` }
+})
+
+ipcMain.handle('overleaf:renameProject', async (_e, projectId: string, newName: string) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch(`/project/${projectId}/rename`, {
+ method: 'POST',
+ body: JSON.stringify({ newProjectName: newName })
+ })
+ return { success: result.ok, message: result.ok ? '' : `HTTP ${result.status}` }
+})
+
+ipcMain.handle('overleaf:cloneProject', async (_e, projectId: string, projectName: string, tags?: string[]) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ const result = await overleafFetch(`/project/${projectId}/clone`, {
+ method: 'POST',
+ body: JSON.stringify({ projectName, tags: (tags || []).map((id) => ({ id })) })
+ })
+ if (!result.ok) return { success: false, message: `HTTP ${result.status}` }
+ const data = result.data as { project_id?: string }
+ return { success: true, projectId: data.project_id }
+})
+
+ipcMain.handle('overleaf:downloadProjectZip', async (_e, projectIds: string[], suggestedName: string) => {
+ if (!overleafSessionCookie) return { success: false, message: 'not_logged_in' }
+ if (projectIds.length === 0) return { success: false, message: 'no projects' }
+
+ const { canceled, filePath } = await dialog.showSaveDialog({
+ title: 'Download Project',
+ defaultPath: `${suggestedName || 'projects'}.zip`,
+ filters: [{ name: 'ZIP Archives', extensions: ['zip'] }]
+ })
+ if (canceled || !filePath) return { success: false, message: 'cancelled' }
+
+ // Official download routes: single /project/:id/download/zip,
+ // multi /project/download/zip?project_ids=a,b
+ const url = projectIds.length === 1
+ ? `https://www.overleaf.com/project/${projectIds[0]}/download/zip`
+ : `https://www.overleaf.com/project/download/zip?project_ids=${projectIds.join(',')}`
+
+ try {
+ const data = await fetchBinary(url, overleafSessionCookie)
+ await writeFile(filePath, Buffer.from(data))
+ return { success: true, path: filePath }
+ } catch (e) {
+ return { success: false, message: String(e) }
+ }
+})
+
// ── File Operations via Overleaf REST API ──────────────────────
ipcMain.handle('overleaf:renameEntity', async (_e, projectId: string, entityType: string, entityId: string, newName: string) => {
@@ -1492,6 +1689,10 @@ ipcMain.handle('overleaf:socketCompile', async (_e, mainTexRelPath: string) => {
return { success: false, log: 'No compilation manager or not connected', pdfPath: '' }
}
+ // latexmk writes its output into the synced dir root (-outdir) — tell the
+ // bridge so the produced PDF is never uploaded to Overleaf as content.
+ fileSyncBridge?.addCompileOutput(basename(mainTexRelPath, '.tex') + '.pdf')
+
// Bridge already keeps all docs synced to disk. Sync content to compilation manager.
if (fileSyncBridge) {
for (const { path, content } of fileSyncBridge.getAllDocContents()) {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 05dc893..d3cddea 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -19,7 +19,7 @@ const api = {
onCompileLog: (cb: (log: string) => void) => {
const handler = (_e: Electron.IpcRendererEvent, log: string) => cb(log)
ipcRenderer.on('latex:log', handler)
- return () => ipcRenderer.removeListener('latex:log', handler)
+ return () => { ipcRenderer.removeListener('latex:log', handler) }
},
// Terminal (supports multiple named instances)
@@ -30,12 +30,12 @@ const api = {
onPtyData: (id: string, cb: (data: string) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: string) => cb(data)
ipcRenderer.on(`pty:data:${id}`, handler)
- return () => ipcRenderer.removeListener(`pty:data:${id}`, handler)
+ return () => { ipcRenderer.removeListener(`pty:data:${id}`, handler) }
},
onPtyExit: (id: string, cb: () => void) => {
const handler = () => cb()
ipcRenderer.on(`pty:exit:${id}`, handler)
- return () => ipcRenderer.removeListener(`pty:exit:${id}`, handler)
+ return () => { ipcRenderer.removeListener(`pty:exit:${id}`, handler) }
},
// SyncTeX
@@ -103,22 +103,22 @@ const api = {
onOtRemoteOp: (cb: (data: { docId: string; ops: unknown[]; version: number }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; ops: unknown[]; version: number }) => cb(data)
ipcRenderer.on('ot:remoteOp', handler)
- return () => ipcRenderer.removeListener('ot:remoteOp', handler)
+ return () => { ipcRenderer.removeListener('ot:remoteOp', handler) }
},
onOtAck: (cb: (data: { docId: string }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { docId: string }) => cb(data)
ipcRenderer.on('ot:ack', handler)
- return () => ipcRenderer.removeListener('ot:ack', handler)
+ return () => { ipcRenderer.removeListener('ot:ack', handler) }
},
onOtConnectionState: (cb: (state: string) => void) => {
const handler = (_e: Electron.IpcRendererEvent, state: string) => cb(state)
ipcRenderer.on('ot:connectionState', handler)
- return () => ipcRenderer.removeListener('ot:connectionState', handler)
+ return () => { ipcRenderer.removeListener('ot:connectionState', handler) }
},
onOtDocRejoined: (cb: (data: { docId: string; content: string; version: number }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: string; version: number }) => cb(data)
ipcRenderer.on('ot:docRejoined', handler)
- return () => ipcRenderer.removeListener('ot:docRejoined', handler)
+ return () => { ipcRenderer.removeListener('ot:docRejoined', handler) }
},
overleafListProjects: () =>
ipcRenderer.invoke('overleaf:listProjects') as Promise<{
@@ -128,6 +128,7 @@ const api = {
owner?: { firstName: string; lastName: string; email?: string }
lastUpdatedBy?: { firstName: string; lastName: string } | null
accessLevel?: string; source?: string
+ archived?: boolean; trashed?: boolean
}>
message?: string
}>,
@@ -139,6 +140,40 @@ const api = {
ipcRenderer.invoke('overleaf:uploadProject') as Promise<{
success: boolean; projectId?: string; message?: string
}>,
+
+ // Project dashboard operations (official Overleaf endpoints)
+ overleafGetTags: () =>
+ ipcRenderer.invoke('overleaf:getTags') as Promise<{
+ success: boolean
+ tags?: Array<{ _id: string; name: string; color?: string | null; project_ids?: string[] }>
+ message?: string
+ }>,
+ overleafCreateTag: (name: string, color?: string) =>
+ ipcRenderer.invoke('overleaf:createTag', name, color) as Promise<{
+ success: boolean
+ tag?: { _id: string; name: string; color?: string | null; project_ids?: string[] }
+ message?: string
+ }>,
+ overleafEditTag: (tagId: string, name: string, color?: string) =>
+ ipcRenderer.invoke('overleaf:editTag', tagId, name, color) as Promise<{ success: boolean; message?: string }>,
+ overleafDeleteTag: (tagId: string) =>
+ ipcRenderer.invoke('overleaf:deleteTag', tagId) as Promise<{ success: boolean; message?: string }>,
+ overleafAddProjectsToTag: (tagId: string, projectIds: string[]) =>
+ ipcRenderer.invoke('overleaf:addProjectsToTag', tagId, projectIds) as Promise<{ success: boolean; message?: string }>,
+ overleafRemoveProjectsFromTag: (tagId: string, projectIds: string[]) =>
+ ipcRenderer.invoke('overleaf:removeProjectsFromTag', tagId, projectIds) as Promise<{ success: boolean; message?: string }>,
+ overleafSetProjectState: (projectId: string, action: 'archive' | 'unarchive' | 'trash' | 'untrash' | 'delete' | 'leave') =>
+ ipcRenderer.invoke('overleaf:setProjectState', projectId, action) as Promise<{ success: boolean; message?: string }>,
+ overleafRenameProject: (projectId: string, newName: string) =>
+ ipcRenderer.invoke('overleaf:renameProject', projectId, newName) as Promise<{ success: boolean; message?: string }>,
+ overleafCloneProject: (projectId: string, projectName: string, tags?: string[]) =>
+ ipcRenderer.invoke('overleaf:cloneProject', projectId, projectName, tags) as Promise<{
+ success: boolean; projectId?: string; message?: string
+ }>,
+ overleafDownloadProjectZip: (projectIds: string[], suggestedName: string) =>
+ ipcRenderer.invoke('overleaf:downloadProjectZip', projectIds, suggestedName) as Promise<{
+ success: boolean; path?: string; message?: string
+ }>,
overleafSocketCompile: (mainTexRelPath: string) =>
ipcRenderer.invoke('overleaf:socketCompile', mainTexRelPath) as Promise<{
success: boolean; log: string; pdfPath: string
@@ -164,26 +199,51 @@ const api = {
onSyncExternalEdit: (cb: (data: { docId: string; content: string; baseContent?: string }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { docId: string; content: string; baseContent?: string }) => cb(data)
ipcRenderer.on('sync:externalEdit', handler)
- return () => ipcRenderer.removeListener('sync:externalEdit', handler)
+ return () => { ipcRenderer.removeListener('sync:externalEdit', handler) }
},
syncContentChanged: (docId: string, content: string) =>
ipcRenderer.invoke('sync:contentChanged', docId, content),
+ syncGetAllDocContents: () =>
+ ipcRenderer.invoke('sync:getAllDocContents') as Promise<Array<{ path: string; content: string }>>,
+ onSyncFileStatus: (cb: (data: { relPath: string; status: 'retrying' | 'synced' | 'failed'; attempts?: number }) => void) => {
+ const handler = (_e: Electron.IpcRendererEvent, data: { relPath: string; status: 'retrying' | 'synced' | 'failed'; attempts?: number }) => cb(data)
+ ipcRenderer.on('sync:fileStatus', handler)
+ return () => { ipcRenderer.removeListener('sync:fileStatus', handler) }
+ },
+ onAuthSessionExpired: (cb: () => void) => {
+ const handler = () => cb()
+ ipcRenderer.on('auth:sessionExpired', handler)
+ return () => { ipcRenderer.removeListener('auth:sessionExpired', handler) }
+ },
+ overleafGetMetadata: (projectId: string) =>
+ ipcRenderer.invoke('overleaf:getMetadata', projectId) as Promise<{
+ success: boolean
+ data?: {
+ projectId: string
+ projectMeta: Record<string, {
+ labels: string[]
+ packages: Record<string, Array<{ caption: string; snippet: string; meta: string; score: number }>>
+ packageNames: string[]
+ }>
+ }
+ message?: string
+ }>,
// MCP compile events (Claude Code triggers compile via file signal)
onMcpCompileStarted: (cb: () => void) => {
const handler = () => cb()
ipcRenderer.on('compile:mcpStarted', handler)
- return () => ipcRenderer.removeListener('compile:mcpStarted', handler)
+ return () => { ipcRenderer.removeListener('compile:mcpStarted', handler) }
},
onMcpCompileFinished: (cb: (data: { success: boolean; pdfPath: string }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { success: boolean; pdfPath: string }) => cb(data)
ipcRenderer.on('compile:mcpFinished', handler)
- return () => ipcRenderer.removeListener('compile:mcpFinished', handler)
+ return () => { ipcRenderer.removeListener('compile:mcpFinished', handler) }
},
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)
+ return () => { ipcRenderer.removeListener('sync:newDoc', handler) }
},
onSyncEntityCreated: (cb: (data: {
kind: 'doc' | 'file' | 'folder'
@@ -200,7 +260,7 @@ const api = {
parentFolderId?: string
}) => cb(data)
ipcRenderer.on('sync:entityCreated', handler)
- return () => ipcRenderer.removeListener('sync:entityCreated', handler)
+ return () => { ipcRenderer.removeListener('sync:entityCreated', handler) }
},
onSyncEntityRemoved: (cb: (data: {
kind: 'doc' | 'file' | 'folder'
@@ -213,7 +273,7 @@ const api = {
relPath: string
}) => cb(data)
ipcRenderer.on('sync:entityRemoved', handler)
- return () => ipcRenderer.removeListener('sync:entityRemoved', handler)
+ return () => { ipcRenderer.removeListener('sync:entityRemoved', handler) }
},
onSyncEntityRenamed: (cb: (data: {
kind: 'doc' | 'file' | 'folder'
@@ -230,7 +290,7 @@ const api = {
newName: string
}) => cb(data)
ipcRenderer.on('sync:entityRenamed', handler)
- return () => ipcRenderer.removeListener('sync:entityRenamed', handler)
+ return () => { ipcRenderer.removeListener('sync:entityRenamed', handler) }
},
onSyncEntityMoved: (cb: (data: {
kind: 'doc' | 'file' | 'folder'
@@ -247,7 +307,7 @@ const api = {
parentFolderId: string
}) => cb(data)
ipcRenderer.on('sync:entityMoved', handler)
- return () => ipcRenderer.removeListener('sync:entityMoved', handler)
+ return () => { ipcRenderer.removeListener('sync:entityMoved', handler) }
},
// Cursor tracking
@@ -258,12 +318,12 @@ const api = {
onCursorRemoteUpdate: (cb: (data: unknown) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: unknown) => cb(data)
ipcRenderer.on('cursor:remoteUpdate', handler)
- return () => ipcRenderer.removeListener('cursor:remoteUpdate', handler)
+ return () => { ipcRenderer.removeListener('cursor:remoteUpdate', handler) }
},
onCursorRemoteDisconnected: (cb: (clientId: string) => void) => {
const handler = (_e: Electron.IpcRendererEvent, clientId: string) => cb(clientId)
ipcRenderer.on('cursor:remoteDisconnected', handler)
- return () => ipcRenderer.removeListener('cursor:remoteDisconnected', handler)
+ return () => { ipcRenderer.removeListener('cursor:remoteDisconnected', handler) }
},
// Chat
@@ -274,24 +334,24 @@ const api = {
onChatMessage: (cb: (msg: unknown) => void) => {
const handler = (_e: Electron.IpcRendererEvent, msg: unknown) => cb(msg)
ipcRenderer.on('chat:newMessage', handler)
- return () => ipcRenderer.removeListener('chat:newMessage', handler)
+ return () => { ipcRenderer.removeListener('chat:newMessage', handler) }
},
// Comments real-time events
onCommentsEvent: (cb: (event: { type: string; args: unknown[] }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, event: { type: string; args: unknown[] }) => cb(event)
ipcRenderer.on('comments:event', handler)
- return () => ipcRenderer.removeListener('comments:event', handler)
+ return () => { ipcRenderer.removeListener('comments:event', handler) }
},
onCommentsInitThreads: (cb: (data: { threads: Record<string, unknown>; resolvedIds: string[] }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { threads: Record<string, unknown>; resolvedIds: string[] }) => cb(data)
ipcRenderer.on('comments:initThreads', handler)
- return () => ipcRenderer.removeListener('comments:initThreads', handler)
+ return () => { ipcRenderer.removeListener('comments:initThreads', handler) }
},
onCommentsInitContexts: (cb: (data: { contexts: Record<string, { file: string; text: string; pos: number }> }) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: { contexts: Record<string, { file: string; text: string; pos: number }> }) => cb(data)
ipcRenderer.on('comments:initContexts', handler)
- return () => ipcRenderer.removeListener('comments:initContexts', handler)
+ return () => { ipcRenderer.removeListener('comments:initContexts', handler) }
},
// API Keys
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index 906bf36..6601e57 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -945,71 +945,290 @@ html, body, #root {
flex-shrink: 0;
}
-.projects-container {
+.projects-search {
flex: 1;
- max-width: 860px;
+ 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-updated-by {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+/* ── Project Dashboard (Overleaf-style sidebar + table) ───────── */
+
+.pl-layout {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.pl-sidebar {
+ width: 220px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 0 12px 16px 16px;
+ border-right: 1px solid var(--border);
+ background: var(--bg-secondary);
+ overflow-y: auto;
+}
+
+.pl-sidebar-brand h1 {
+ font-size: 20px;
+ font-weight: 700;
+ color: var(--accent);
+ font-family: "Georgia", "Times New Roman", serif;
+ margin: 0 0 14px 4px;
+}
+
+.pl-new-project-wrap {
+ position: relative;
+ margin-bottom: 14px;
+}
+.pl-new-project-btn {
+ width: 100%;
+ padding: 8px 12px;
+ font-size: 13px;
+}
+
+.pl-filters {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.pl-filter-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
width: 100%;
- margin: 0 auto;
- padding: 0 32px 32px;
+ text-align: left;
+ padding: 6px 10px;
+ font-size: 12.5px;
+ color: var(--text-secondary);
+ background: none;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-family: var(--font-sans);
+}
+.pl-filter-item:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+.pl-filter-item.active {
+ background: var(--bg-active);
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.pl-sidebar-divider {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 12px 4px;
+}
+
+.pl-tags-section {
+ flex: 1;
+ min-height: 0;
+}
+
+.pl-tags-header {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+ color: var(--text-muted);
+ padding: 0 10px 6px;
+}
+
+.pl-new-tag {
+ color: var(--text-muted);
+}
+
+.pl-tag-row {
+ display: flex;
+ align-items: center;
+ border-radius: var(--radius);
+}
+.pl-tag-row:hover {
+ background: var(--bg-hover);
+}
+.pl-tag-row.active {
+ background: var(--bg-active);
+}
+.pl-tag-row.active .pl-tag-name {
+ font-weight: 600;
+}
+
+.pl-tag-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 6px 10px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-size: 12.5px;
+ color: var(--text-secondary);
+}
+
+.pl-tag-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.pl-tag-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--text-primary);
+}
+
+.pl-tag-count {
+ color: var(--text-muted);
+ font-size: 11px;
+ flex-shrink: 0;
+}
+
+.pl-tag-kebab-wrap {
+ position: relative;
+ flex-shrink: 0;
+}
+.pl-tag-kebab {
+ opacity: 0;
+}
+.pl-tag-row:hover .pl-tag-kebab {
+ opacity: 1;
+}
+
+.pl-uncategorized {
+ font-style: italic;
+ color: var(--text-muted);
+}
+
+.pl-sidebar-footer {
+ display: flex;
+ gap: 6px;
+ padding-top: 12px;
+}
+
+.pl-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 0 24px 24px;
overflow-y: auto;
}
-.projects-header {
+.pl-main-header {
display: flex;
align-items: center;
justify-content: space-between;
- margin-bottom: 24px;
+ margin-bottom: 14px;
+ min-height: 34px;
}
-.projects-header h1 {
- font-size: 22px;
+.pl-title {
+ font-size: 19px;
font-weight: 700;
- color: var(--accent);
- font-family: "Georgia", "Times New Roman", serif;
+ color: var(--text-primary);
+ margin: 0;
}
-.projects-header-actions {
+.pl-header-actions {
display: flex;
- gap: 8px;
+ align-items: center;
+ gap: 6px;
}
-.projects-toolbar {
+.pl-bulk-tools {
display: flex;
- gap: 8px;
- margin-bottom: 16px;
align-items: center;
+ gap: 6px;
}
-.projects-search {
- flex: 1;
- padding: 8px 12px;
- border: 1px solid var(--border);
+.pl-tags-dropdown-wrap {
+ position: relative;
+}
+
+.pl-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: none;
border-radius: var(--radius);
- background: var(--bg-primary);
- font-size: 13px;
+ background: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+.pl-icon-btn:hover {
+ background: var(--bg-hover);
color: var(--text-primary);
- font-family: var(--font-sans);
- outline: none;
}
-.projects-search:focus {
- border-color: var(--accent);
+.pl-icon-btn-danger:hover {
+ color: #B3402F;
}
-.btn-sm {
- padding: 6px 14px;
- font-size: 12px;
+.pl-search-row {
+ position: relative;
+ display: flex;
+ align-items: center;
+ margin-bottom: 14px;
+}
+.pl-search-icon {
+ position: absolute;
+ left: 10px;
+ display: flex;
+ color: var(--text-muted);
+ pointer-events: none;
+}
+.pl-search-input {
+ padding-left: 32px;
+ padding-right: 32px;
+}
+.pl-search-clear {
+ position: absolute;
+ right: 4px;
}
-.projects-list {
+.pl-table {
border: 1px solid var(--border);
border-radius: var(--radius);
- overflow: hidden;
+ overflow: visible;
+ background: var(--bg-primary);
}
-.projects-table-header {
+.pl-table-header {
display: flex;
align-items: center;
- padding: 8px 16px;
+ padding: 8px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
@@ -1017,86 +1236,308 @@ html, body, #root {
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border);
user-select: none;
+ gap: 10px;
}
-.projects-table-header span {
+.pl-sortable {
cursor: pointer;
}
-.projects-table-header span:hover {
+.pl-sortable:hover {
color: var(--text-primary);
}
-.projects-col-name {
+.pl-col-check {
+ width: 22px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+}
+.pl-col-check input {
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.pl-col-name {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
+ flex-wrap: wrap;
}
-.projects-col-owner {
- width: 140px;
+
+.pl-col-owner {
+ width: 130px;
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ gap: 4px;
}
-.projects-col-updated {
- width: 160px;
+
+.pl-col-updated {
+ width: 150px;
flex-shrink: 0;
font-size: 12px;
- color: var(--text-muted);
+ color: var(--text-secondary);
text-align: right;
+ justify-content: flex-end;
+}
+
+.pl-col-actions {
+ width: 168px;
+ flex-shrink: 0;
display: flex;
- flex-direction: column;
- align-items: flex-end;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 2px;
}
-.projects-item {
+.pl-row {
display: flex;
align-items: center;
- padding: 10px 16px;
- cursor: pointer;
+ gap: 10px;
+ padding: 8px 12px;
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
-.projects-item:last-child {
+.pl-row:last-child {
border-bottom: none;
}
-.projects-item:hover {
+.pl-row:hover {
background: var(--bg-secondary);
}
-
-.projects-item .projects-col-name {
- display: flex;
- align-items: center;
- gap: 8px;
+.pl-row.selected {
+ background: var(--bg-tertiary);
+}
+.pl-row .pl-icon-btn {
+ opacity: 0;
+}
+.pl-row:hover .pl-icon-btn,
+.pl-row.selected .pl-icon-btn {
+ opacity: 1;
}
-.projects-item-name {
+.pl-project-link {
+ background: none;
+ border: none;
+ padding: 0;
+ font-family: var(--font-sans);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
+ cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ max-width: 100%;
+ text-align: left;
+}
+.pl-project-link:hover {
+ color: var(--accent-blue);
+ text-decoration: underline;
}
-.projects-access-badge {
- font-size: 10px;
- padding: 1px 6px;
- border-radius: 3px;
- background: var(--bg-tertiary, #3a3730);
+.pl-row-tags {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex-wrap: wrap;
+}
+
+.pl-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 4px 1px 6px;
+ border-radius: 10px;
+ background: var(--bg-tertiary);
+ font-size: 11px;
+ line-height: 16px;
+}
+.pl-chip-name {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 11px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-family: var(--font-sans);
+}
+.pl-chip-name:hover {
+ color: var(--text-primary);
+}
+.pl-chip-x {
+ display: inline-flex;
+ align-items: center;
+ background: none;
+ border: none;
+ padding: 1px;
color: var(--text-muted);
+ cursor: pointer;
+ border-radius: 50%;
+}
+.pl-chip-x:hover {
+ color: var(--text-primary);
+ background: var(--bg-hover);
+}
+
+.pl-link-icon {
+ display: inline-flex;
+ color: var(--text-muted);
+}
+
+.pl-load-more {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 14px;
+ justify-content: center;
+}
+.pl-load-more-info {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.pl-link-btn {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 12px;
+ color: var(--accent-blue);
+ cursor: pointer;
+ text-decoration: underline;
+ font-family: var(--font-sans);
+}
+
+.pl-dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ min-width: 180px;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: 0 4px 16px rgba(59, 50, 40, 0.18);
+ padding: 4px;
+ z-index: 100;
+}
+.pl-dropdown-right {
+ left: auto;
+ right: 0;
+}
+.pl-dropdown-header {
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+ padding: 6px 10px 4px;
+}
+.pl-dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ text-align: left;
+ padding: 7px 10px;
+ background: none;
+ border: none;
+ border-radius: calc(var(--radius) - 2px);
+ font-size: 12.5px;
+ color: var(--text-primary);
+ cursor: pointer;
+ font-family: var(--font-sans);
+}
+.pl-dropdown-item:hover {
+ background: var(--bg-hover);
+}
+.pl-dropdown-label {
+ flex: 1;
+ min-width: 0;
white-space: nowrap;
- flex-shrink: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.pl-dropdown-divider {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 4px 0;
+}
+.pl-danger {
+ color: #B3402F;
}
-.projects-date {
+.pl-modal-title {
+ margin: 0 0 14px;
+ font-size: 16px;
+}
+.pl-modal-label {
+ display: block;
font-size: 12px;
+ font-weight: 500;
+ margin: 10px 0 4px;
color: var(--text-secondary);
}
-
-.projects-updated-by {
- font-size: 10px;
+.pl-modal-text {
+ margin: 0 0 10px;
+ font-size: 13px;
+ color: var(--text-primary);
+}
+.pl-modal-list {
+ margin: 0 0 10px;
+ padding-left: 22px;
+ font-size: 13px;
+ max-height: 180px;
+ overflow-y: auto;
+}
+.pl-modal-note {
+ font-size: 12px;
color: var(--text-muted);
+ margin: 0 0 6px;
+}
+.pl-modal-note.pl-danger {
+ color: #B3402F;
+ font-weight: 600;
+}
+.pl-modal-error {
+ margin: 10px 0 0;
+}
+.pl-modal-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ margin-top: 16px;
+}
+
+.pl-color-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.pl-color-swatch {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ padding: 0;
+}
+.pl-color-swatch.selected {
+ border-color: var(--text-primary);
+}
+.pl-color-custom {
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ cursor: pointer;
+ background: none;
}
.projects-empty {
@@ -2192,40 +2633,8 @@ html, body, #root {
/* ── Remote Cursors ──────────────────────────────────────────── */
-.cm-remote-cursor {
- position: relative;
- display: inline;
- pointer-events: none;
-}
-
-.cm-remote-cursor-line {
- position: absolute;
- top: 0;
- height: 1.2em;
- border-left: 2px solid;
- z-index: 10;
-}
-
-.cm-remote-cursor-label {
- position: absolute;
- top: -1.4em;
- left: -1px;
- font-size: 10px;
- line-height: 1.4;
- padding: 0 4px;
- border-radius: 3px 3px 3px 0;
- color: white;
- white-space: nowrap;
- z-index: 11;
- font-family: var(--font-sans);
- font-weight: 500;
- transition: opacity 0.5s;
- pointer-events: none;
-}
-
-.cm-remote-cursor-label.faded {
- opacity: 0;
-}
+/* Remote collaborator cursors are styled by the remoteCursors extension
+ (layer-based, ported from Overleaf's cursor-highlights). */
/* ── Toolbar Users Count ─────────────────────────────────────── */
@@ -2468,6 +2877,28 @@ html, body, #root {
padding: 3px 8px !important;
color: var(--text-primary) !important;
line-height: 1.5;
+ display: flex !important;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-width: 0;
+}
+
+.cm-tooltip-autocomplete .cm-completionLabel {
+ flex-shrink: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Completion type tag, right-aligned (Overleaf's ol-cm-completionType) */
+.ol-cm-completionType {
+ margin-left: auto;
+ opacity: 0.5;
+ font-size: 10.5px;
+ font-family: var(--font-sans);
+ flex-shrink: 0;
}
.cm-tooltip-autocomplete > ul > li[aria-selected] {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 808176b..13b65fd 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -19,6 +19,11 @@ import StatusBar from './components/StatusBar'
import type { OverleafDocSync } from './ot/overleafSync'
import { colorForUser, type RemoteCursor } from './extensions/remoteCursors'
import {
+ startAutocompleteSync,
+ stopAutocompleteSync,
+ scheduleAutocompleteRefresh,
+} from './extensions/latexAutocomplete'
+import {
applyEntityCreated,
applyEntityMoved,
applyEntityRemoved,
@@ -106,17 +111,54 @@ export default function App() {
if (sync) sync.reset(data.version, data.content)
})
+ // Project-wide autocomplete data (all docs + official metadata endpoint)
+ const projectId = useAppStore.getState().overleafProjectId
+ if (projectId) startAutocompleteSync(projectId)
+ const refreshAutocomplete = () => {
+ const pid = useAppStore.getState().overleafProjectId
+ if (pid) scheduleAutocompleteRefresh(pid)
+ }
+
// 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, data.baseContent)
+ refreshAutocomplete()
+ })
+
+ // Surface sync retry state in the status bar
+ const unsubFileStatus = window.api.onSyncFileStatus?.((data) => {
+ if (data.status === 'retrying') {
+ setStatusMessage(`Sync failed for ${data.relPath} — retrying (attempt ${data.attempts ?? 1})`)
+ } else if (data.status === 'failed') {
+ setStatusMessage(`Sync failed for ${data.relPath} — gave up (edit the file to retry)`)
+ } else {
+ setStatusMessage(`Synced ${data.relPath}`)
+ }
+ })
+
+ // Session cookie died mid-session — sync can't recover without a re-login
+ const unsubAuthExpired = window.api.onAuthSessionExpired?.(() => {
+ setStatusMessage('Overleaf session expired — please sign in again')
})
// Keep the file tree in sync with Overleaf project-entity socket events.
- const unsubEntityCreated = window.api.onSyncEntityCreated(applyEntityCreated)
- const unsubEntityRemoved = window.api.onSyncEntityRemoved(applyEntityRemoved)
- const unsubEntityRenamed = window.api.onSyncEntityRenamed(applyEntityRenamed)
- const unsubEntityMoved = window.api.onSyncEntityMoved(applyEntityMoved)
+ const unsubEntityCreated = window.api.onSyncEntityCreated((data) => {
+ applyEntityCreated(data)
+ refreshAutocomplete()
+ })
+ const unsubEntityRemoved = window.api.onSyncEntityRemoved((data) => {
+ applyEntityRemoved(data)
+ refreshAutocomplete()
+ })
+ const unsubEntityRenamed = window.api.onSyncEntityRenamed((data) => {
+ applyEntityRenamed(data)
+ refreshAutocomplete()
+ })
+ const unsubEntityMoved = window.api.onSyncEntityMoved((data) => {
+ applyEntityMoved(data)
+ refreshAutocomplete()
+ })
// Listen for initial comment data (threads + contexts) from background fetch on connect
const unsubInitThreads = window.api.onCommentsInitThreads?.((data) => {
@@ -158,7 +200,7 @@ export default function App() {
doc_id: string; row: number; column: number
}
remoteCursors.set(data.id, {
- userId: data.id,
+ userId: data.user_id || data.id,
name: data.name || data.email?.split('@')[0] || 'User',
color: colorForUser(data.user_id || data.id),
row: data.row,
@@ -188,7 +230,7 @@ export default function App() {
if (u.cursorData) {
const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email?.split('@')[0] || 'User'
remoteCursors.set(u.client_id, {
- userId: u.client_id,
+ userId: u.user_id || u.client_id,
name,
color: colorForUser(u.user_id || u.client_id),
row: u.cursorData.row,
@@ -216,7 +258,10 @@ export default function App() {
unsubCommentsEvent?.()
unsubCursorUpdate()
unsubCursorDisconnected()
+ unsubFileStatus?.()
+ unsubAuthExpired?.()
remoteCursors.clear()
+ stopAutocompleteSync()
}
}, [screen, setStatusMessage])
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index b8075f7..2afdc0e 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -6,7 +6,7 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLi
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { bracketMatching, foldGutter, indentOnInput, StreamLanguage, syntaxHighlighting, HighlightStyle } from '@codemirror/language'
-import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'
+import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { stex } from '@codemirror/legacy-modes/mode/stex'
import { tags } from '@lezer/highlight'
@@ -243,11 +243,13 @@ export default function Editor() {
search({ top: true }),
StreamLanguage.define(stex),
syntaxHighlighting(cosmicLatteHighlight),
+ // Completion keys (Enter/Tab/arrows) are bound at Prec.highest inside
+ // latexAutocomplete(), mirroring Overleaf's keymap setup — they must
+ // win over defaultKeymap's Enter/arrow bindings while the popup is open.
keymap.of([
+ ...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
- ...closeBracketsKeymap,
- ...completionKeymap,
...searchKeymap,
indentWithTab
]),
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
index cfd34e9..92de1f4 100644
--- a/src/renderer/src/components/PdfViewer.tsx
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -458,16 +458,18 @@ export default function PdfViewer() {
for (let i = 1; i <= pdf.numPages; i++) {
let text = pdfTextCache.current.get(i)
- if (!text) {
+ if (text === undefined) {
try {
const page = await pdf.getPage(i)
const tc = await page.getTextContent()
- text = tc.items.map((item: any) => item.str).join(' ')
- pdfTextCache.current.set(i, text)
+ const extracted: string = tc.items.map((item: any) => item.str).join(' ')
+ text = extracted
+ pdfTextCache.current.set(i, extracted)
} catch {
continue
}
}
+ if (text === undefined) continue
const lower = text.toLowerCase()
let pos = 0
while ((pos = lower.indexOf(q, pos)) !== -1) {
diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx
index 7a9fd7f..ec9e997 100644
--- a/src/renderer/src/components/ProjectList.tsx
+++ b/src/renderer/src/components/ProjectList.tsx
@@ -1,8 +1,13 @@
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
+// Project dashboard — a faithful clone of the Overleaf project list
+// (services/web/frontend/js/features/project-list): sidebar filters,
+// tags, archive/trash, bulk actions, and the same client-side filtering
+// pipeline over the official /api/project + /tag endpoints.
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useAppStore } from '../stores/appStore'
+import { hashString } from '../extensions/remoteCursors'
interface OverleafProject {
id: string
@@ -12,46 +17,362 @@ interface OverleafProject {
lastUpdatedBy?: { firstName: string; lastName: string } | null
accessLevel?: string
source?: string
+ archived?: boolean
+ trashed?: boolean
}
-type SortKey = 'lastUpdated' | 'name' | 'owner'
+interface Tag {
+ _id: string
+ name: string
+ color?: string | null
+ project_ids?: string[]
+}
+
+type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed'
+type SortKey = 'lastUpdated' | 'title' | 'owner'
type SortOrder = 'asc' | 'desc'
+const UNCATEGORIZED_KEY = 'uncategorized'
+const PAGE_SIZE = 20
+
+// Overleaf's preset tag palette (project-list color-picker)
+const PRESET_COLORS = [
+ { color: '#A7B1C2', name: 'Grey' },
+ { color: '#F04343', name: 'Red' },
+ { color: '#DD8A3E', name: 'Orange' },
+ { color: '#E4CA3E', name: 'Yellow' },
+ { color: '#33CF67', name: 'Green' },
+ { color: '#43A7F0', name: 'Light blue' },
+ { color: '#434AF0', name: 'Dark blue' },
+ { color: '#B943F0', name: 'Purple' },
+ { color: '#FF4BCD', name: 'Pink' },
+]
+
+const MAX_TAG_LENGTH = 50
+
+/** Default tag color when none is set (Overleaf: hsl(hue, 70%, 45%)) */
+function getTagColor(tag?: Tag): string | undefined {
+ if (!tag) return undefined
+ return tag.color || `hsl(${hashString(tag._id) % 320}, 70%, 45%)`
+}
+
+const isArchivedOrTrashed = (p: OverleafProject) => !!p.archived || !!p.trashed
+
+// ── Icons (inline SVG, Material-style outlines) ─────────────────────
+
+const Icon = ({ d, size = 16 }: { d: string; size?: number }) => (
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
+ strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+ <path d={d} />
+ </svg>
+)
+
+const ICONS = {
+ copy: 'M8 8V5a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-3M4 8h11a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z',
+ download: 'M12 4v11m0 0l-4-4m4 4l4-4M5 19h14',
+ archive: 'M4 7h16v3H4zM6 10v9h12v-9M10 14h4',
+ trash: 'M5 7h14M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7M10 11v6M14 11v6',
+ restore: 'M4 10a8 8 0 1 1 2 6M4 10V5m0 5h5',
+ leave: 'M14 5h5v14h-5M10 8l-4 4 4 4M6 12h10',
+ block: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM5.5 5.5l13 13',
+ tag: 'M4 5a1 1 0 0 1 1-1h6l9 9-7 7-9-9V5zM8.5 8.5h0',
+ kebab: 'M12 6h.01M12 12h.01M12 18h.01',
+ search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM15 15l5 5',
+ x: 'M6 6l12 12M18 6L6 18',
+ plus: 'M12 5v14M5 12h14',
+ edit: 'M4 20h4l11-11-4-4L4 16v4zM13 7l4 4',
+ check: 'M5 13l4 4 10-10',
+ link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6L12.5 18',
+}
+
+// ── Component ───────────────────────────────────────────────────────
+
interface Props {
onOpenProject: (projectId: string) => void
}
export default function ProjectList({ onOpenProject }: Props) {
const [projects, setProjects] = useState<OverleafProject[]>([])
+ const [tags, setTags] = useState<Tag[]>([])
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 [showNewProject, setShowNewProject] = useState(false)
- const [newProjectName, setNewProjectName] = useState('Untitled Project')
- const [showApiKeys, setShowApiKeys] = useState(false)
+
+ const [filter, setFilter] = useState<Filter>('all')
+ const [selectedTagId, setSelectedTagId] = useState<string | undefined>(undefined)
+ const [searchText, setSearchText] = useState('')
+ const [sort, setSort] = useState<{ by: SortKey; order: SortOrder }>({ by: 'lastUpdated', order: 'desc' })
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
+ const [maxVisible, setMaxVisible] = useState(PAGE_SIZE)
+
+ // Modals
+ const [modal, setModal] = useState<
+ | { kind: 'newProject'; template?: 'none' | 'example' }
+ | { kind: 'rename'; project: OverleafProject }
+ | { kind: 'clone'; project: OverleafProject }
+ | { kind: 'confirm'; action: 'archive' | 'trash' | 'delete' | 'leave'; projects: OverleafProject[] }
+ | { kind: 'createTag'; forProjects?: string[] }
+ | { kind: 'editTag'; tag: Tag }
+ | { kind: 'deleteTag'; tag: Tag }
+ | { kind: 'apiKeys' }
+ | null
+ >(null)
+ const [modalInput, setModalInput] = useState('')
+ const [modalColor, setModalColor] = useState<string | undefined>(undefined)
+ const [modalBusy, setModalBusy] = useState(false)
+ const [modalError, setModalError] = useState('')
+ const [tagsMenuOpen, setTagsMenuOpen] = useState(false)
+ const [newMenuOpen, setNewMenuOpen] = useState(false)
+ const [tagKebabOpen, setTagKebabOpen] = useState<string | null>(null)
const [apiKeys, setApiKeys] = useState<Record<string, string>>({})
const [apiKeysVisible, setApiKeysVisible] = useState<Record<string, boolean>>({})
const { setStatusMessage } = useAppStore()
- const loadProjects = useCallback(async () => {
+ const loadData = useCallback(async () => {
setLoading(true)
setError('')
- const result = await window.api.overleafListProjects()
+ const [projResult, tagResult] = await Promise.all([
+ window.api.overleafListProjects(),
+ window.api.overleafGetTags(),
+ ])
setLoading(false)
- if (result.success && result.projects) {
- setProjects(result.projects)
+ if (projResult.success && projResult.projects) {
+ setProjects(projResult.projects)
} else {
- setError(result.message || 'Failed to load projects')
+ setError(projResult.message || 'Failed to load projects')
+ }
+ if (tagResult.success && tagResult.tags) {
+ setTags(tagResult.tags)
}
}, [])
+ useEffect(() => { loadData() }, [loadData])
+
+ // Close dropdowns on outside click
useEffect(() => {
- loadProjects()
- }, [loadProjects])
+ const close = () => { setTagsMenuOpen(false); setNewMenuOpen(false); setTagKebabOpen(null) }
+ window.addEventListener('click', close)
+ return () => window.removeEventListener('click', close)
+ }, [])
+
+ // ── Derived state (mirrors Overleaf's project-list-context pipeline) ──
+
+ const taggedProjectIds = useMemo(
+ () => new Set(tags.flatMap((t) => t.project_ids || [])),
+ [tags]
+ )
+
+ const filteredProjects = useMemo(() => {
+ let list = projects
+
+ // 1. Search
+ if (searchText.length) {
+ const q = searchText.toLowerCase()
+ list = list.filter((p) => p.name.toLowerCase().includes(q))
+ }
+
+ // 2. Tag or filter (mutually exclusive)
+ if (selectedTagId === UNCATEGORIZED_KEY) {
+ list = list.filter((p) => !p.archived && !p.trashed && !taggedProjectIds.has(p.id))
+ } else if (selectedTagId) {
+ const tag = tags.find((t) => t._id === selectedTagId)
+ list = list.filter((p) => !isArchivedOrTrashed(p) && !!tag?.project_ids?.includes(p.id))
+ } else {
+ switch (filter) {
+ case 'all': list = list.filter((p) => !p.archived && !p.trashed); break
+ case 'owned': list = list.filter((p) => p.accessLevel === 'owner' && !p.archived && !p.trashed); break
+ case 'shared': list = list.filter((p) => p.accessLevel !== 'owner' && !p.archived && !p.trashed); break
+ case 'archived': list = list.filter((p) => p.archived && !p.trashed); break
+ case 'trashed': list = list.filter((p) => p.trashed); break
+ }
+ }
+
+ // 3. Sort
+ const dir = sort.order === 'asc' ? 1 : -1
+ const ownerName = (p: OverleafProject) =>
+ p.accessLevel === 'owner' ? '' : `${p.owner?.firstName || ''} ${p.owner?.lastName || ''}`.trim() || p.owner?.email || '~'
+ list = [...list].sort((a, b) => {
+ let cmp = 0
+ if (sort.by === 'lastUpdated') {
+ cmp = a.lastUpdated < b.lastUpdated ? -1 : a.lastUpdated > b.lastUpdated ? 1 : 0
+ } else if (sort.by === 'title') {
+ cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase())
+ } else {
+ cmp = ownerName(a).localeCompare(ownerName(b))
+ }
+ return cmp * dir
+ })
+
+ return list
+ }, [projects, tags, filter, selectedTagId, searchText, sort, taggedProjectIds])
+
+ const visibleProjects = useMemo(() => filteredProjects.slice(0, maxVisible), [filteredProjects, maxVisible])
+ const hiddenCount = filteredProjects.length - visibleProjects.length
+ const selectedProjects = useMemo(
+ () => visibleProjects.filter((p) => selectedIds.has(p.id)),
+ [visibleProjects, selectedIds]
+ )
+
+ const projectsPerTag = useMemo(() => {
+ const counts: Record<string, number> = {}
+ for (const tag of tags) {
+ counts[tag._id] = projects.filter(
+ (p) => !isArchivedOrTrashed(p) && tag.project_ids?.includes(p.id)
+ ).length
+ }
+ return counts
+ }, [tags, projects])
+
+ const untaggedCount = useMemo(
+ () => projects.filter((p) => !p.archived && !p.trashed && !taggedProjectIds.has(p.id)).length,
+ [projects, taggedProjectIds]
+ )
+
+ // ── Navigation helpers ──
+
+ const selectFilter = (f: Filter) => {
+ setFilter(f)
+ setSelectedTagId(undefined)
+ setSelectedIds(new Set())
+ setMaxVisible(PAGE_SIZE)
+ }
+
+ const selectTag = (tagId: string) => {
+ setFilter('all')
+ setSelectedTagId(tagId)
+ setSelectedIds(new Set())
+ setMaxVisible(PAGE_SIZE)
+ }
+
+ const pageTitle = useMemo(() => {
+ if (selectedTagId === UNCATEGORIZED_KEY) return 'Uncategorized projects'
+ if (selectedTagId) return tags.find((t) => t._id === selectedTagId)?.name || 'All projects'
+ switch (filter) {
+ case 'owned': return 'Your projects'
+ case 'shared': return 'Shared with you'
+ case 'archived': return 'Archived projects'
+ case 'trashed': return 'Trashed projects'
+ default: return 'All projects'
+ }
+ }, [filter, selectedTagId, tags])
+
+ const searchPlaceholder = useMemo(() => {
+ if (selectedTagId === UNCATEGORIZED_KEY) return 'Search uncategorized projects…'
+ if (selectedTagId) return `Search ${tags.find((t) => t._id === selectedTagId)?.name || ''}…`
+ switch (filter) {
+ case 'owned': return 'Search in your projects…'
+ case 'shared': return 'Search in projects shared with you…'
+ case 'archived': return 'Search in archived projects…'
+ case 'trashed': return 'Search in trashed projects…'
+ default: return 'Search in all projects…'
+ }
+ }, [filter, selectedTagId, tags])
+
+ // ── Project state transitions (mirror Overleaf's handlers) ──
+
+ const updateProject = (updated: OverleafProject) => {
+ setProjects((list) => list.map((p) => (p.id === updated.id ? updated : p)))
+ }
+
+ const deselect = (id: string) => {
+ setSelectedIds((ids) => {
+ const next = new Set(ids)
+ next.delete(id)
+ return next
+ })
+ }
+
+ type ProjectAction = 'archive' | 'unarchive' | 'trash' | 'untrash' | 'delete' | 'leave'
+
+ // State patch per action — mirrors the web client's handlers: archiving
+ // clears trashed, trashing clears archived; delete/leave drop the project.
+ const ACTION_PATCH: Record<ProjectAction, ((p: OverleafProject) => OverleafProject) | null> = {
+ archive: (p) => ({ ...p, archived: true, trashed: false }),
+ unarchive: (p) => ({ ...p, archived: false }),
+ trash: (p) => ({ ...p, trashed: true, archived: false }),
+ untrash: (p) => ({ ...p, trashed: false }),
+ delete: null,
+ leave: null,
+ }
+
+ const transitionProjects = async (targets: OverleafProject[], actionFor: (p: OverleafProject) => ProjectAction) => {
+ const results = await Promise.allSettled(
+ targets.map(async (p) => {
+ const action = actionFor(p)
+ const r = await window.api.overleafSetProjectState(p.id, action)
+ if (!r.success) throw new Error(`Failed to ${action} "${p.name}": ${r.message}`)
+ return { project: p, action }
+ })
+ )
+
+ const succeeded = results.filter(
+ (r): r is PromiseFulfilledResult<{ project: OverleafProject; action: ProjectAction }> =>
+ r.status === 'fulfilled'
+ )
+ const failed = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
+
+ setSelectedIds((ids) => {
+ const next = new Set(ids)
+ for (const { value } of succeeded) next.delete(value.project.id)
+ return next
+ })
+ setProjects((list) => {
+ const patched = new Map(
+ succeeded.map(({ value }) => [value.project.id, ACTION_PATCH[value.action]] as const)
+ )
+ return list
+ .filter((p) => !(patched.has(p.id) && patched.get(p.id) === null))
+ .map((p) => {
+ const patch = patched.get(p.id)
+ return patch ? patch(p) : p
+ })
+ })
+ setError(failed.map((r) => String(r.reason?.message ?? r.reason)).join('; '))
+ }
+
+ const archiveProjects = (targets: OverleafProject[]) => transitionProjects(targets, () => 'archive')
+ const trashProjects = (targets: OverleafProject[]) => transitionProjects(targets, () => 'trash')
+ const unarchiveProjects = (targets: OverleafProject[]) => transitionProjects(targets, () => 'unarchive')
+ const untrashProjects = (targets: OverleafProject[]) => transitionProjects(targets, () => 'untrash')
+ const deleteOrLeaveProjects = (targets: OverleafProject[], mode: 'delete' | 'leave' | 'auto') =>
+ transitionProjects(targets, (p) =>
+ mode === 'auto' ? (p.accessLevel === 'owner' ? 'delete' : 'leave') : mode
+ )
+
+ // ── Tag membership (optimistic, like the web client) ──
+
+ const addProjectsToTagInView = (tagId: string, projectIds: string[]) => {
+ setTags((list) => list.map((t) =>
+ t._id === tagId
+ ? { ...t, project_ids: Array.from(new Set([...(t.project_ids || []), ...projectIds])) }
+ : t
+ ))
+ }
+
+ const removeProjectsFromTagInView = (tagId: string, projectIds: string[]) => {
+ const remove = new Set(projectIds)
+ setTags((list) => list.map((t) =>
+ t._id === tagId
+ ? { ...t, project_ids: (t.project_ids || []).filter((id) => !remove.has(id)) }
+ : t
+ ))
+ }
+
+ const toggleTagForSelected = async (tag: Tag) => {
+ const ids = selectedProjects.map((p) => p.id)
+ const allContained = ids.every((id) => tag.project_ids?.includes(id))
+ if (allContained) {
+ removeProjectsFromTagInView(tag._id, ids)
+ await window.api.overleafRemoveProjectsFromTag(tag._id, ids)
+ } else {
+ const missing = ids.filter((id) => !tag.project_ids?.includes(id))
+ addProjectsToTagInView(tag._id, missing)
+ await window.api.overleafAddProjectsToTag(tag._id, missing)
+ }
+ }
+
+ // ── Open project ──
const handleOpen = async (pid: string) => {
setError('')
@@ -81,24 +402,99 @@ export default function ProjectList({ onOpenProject }: Props) {
}
}
- const handleCreateProject = async () => {
- const name = newProjectName.trim()
- if (!name) return
+ // ── Modal actions ──
- setShowNewProject(false)
- setError('')
- setBusy(true)
- setBusyText('Creating project...')
+ const openModal = (m: NonNullable<typeof modal>) => {
+ setModalError('')
+ setModalBusy(false)
+ if (m.kind === 'rename') setModalInput(m.project.name)
+ else if (m.kind === 'clone') setModalInput(`${m.project.name} (Copy)`)
+ else if (m.kind === 'newProject') setModalInput('')
+ else if (m.kind === 'createTag') { setModalInput(''); setModalColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)].color) }
+ else if (m.kind === 'editTag') { setModalInput(m.tag.name); setModalColor(getTagColor(m.tag)) }
+ setModal(m)
+ }
- const result = await window.api.overleafCreateProject(name)
- setBusy(false)
+ const closeModal = () => { setModal(null); setModalError(''); setModalBusy(false) }
- if (result.success && result.projectId) {
- setStatusMessage(`Created "${name}"`)
- setNewProjectName('Untitled Project')
- loadProjects()
- } else {
- setError(result.message || 'Failed to create project')
+ const runModalAction = async () => {
+ if (!modal) return
+ setModalBusy(true)
+ setModalError('')
+
+ try {
+ if (modal.kind === 'newProject') {
+ const name = modalInput.trim()
+ if (!name) return
+ const r = await window.api.overleafCreateProject(name)
+ if (!r.success) { setModalError(r.message || 'Failed to create project'); return }
+ closeModal()
+ setStatusMessage(`Created "${name}"`)
+ loadData()
+ } else if (modal.kind === 'rename') {
+ const name = modalInput.trim()
+ if (!name || name === modal.project.name) return
+ const r = await window.api.overleafRenameProject(modal.project.id, name)
+ if (!r.success) { setModalError(r.message || 'Rename failed'); return }
+ deselect(modal.project.id)
+ updateProject({ ...modal.project, name })
+ closeModal()
+ } else if (modal.kind === 'clone') {
+ const name = modalInput.trim()
+ if (!name) return
+ const projectTags = tags.filter((t) => t.project_ids?.includes(modal.project.id)).map((t) => t._id)
+ const r = await window.api.overleafCloneProject(modal.project.id, name, projectTags)
+ if (!r.success) { setModalError(r.message || 'Copy failed'); return }
+ closeModal()
+ setStatusMessage(`Copied to "${name}"`)
+ loadData()
+ } else if (modal.kind === 'confirm') {
+ const { action, projects: targets } = modal
+ if (action === 'archive') await archiveProjects(targets)
+ else if (action === 'trash') await trashProjects(targets)
+ else if (action === 'delete') await deleteOrLeaveProjects(targets.filter((p) => p.accessLevel === 'owner'), 'delete')
+ else if (action === 'leave') await deleteOrLeaveProjects(targets.filter((p) => p.accessLevel !== 'owner'), 'leave')
+ closeModal()
+ } else if (modal.kind === 'createTag') {
+ const name = modalInput.trim()
+ if (!name) return
+ if (name.length > MAX_TAG_LENGTH) { setModalError('Tag name cannot exceed 50 characters'); return }
+ if (tags.some((t) => t.name === name)) { setModalError(`Tag "${name}" already exists`); return }
+ const r = await window.api.overleafCreateTag(name, modalColor)
+ if (!r.success || !r.tag) { setModalError(r.message || 'Failed to create tag'); return }
+ const newTag: Tag = r.tag
+ setTags((list) => [...list, newTag])
+ if (modal.forProjects?.length) {
+ addProjectsToTagInView(newTag._id, modal.forProjects)
+ await window.api.overleafAddProjectsToTag(newTag._id, modal.forProjects)
+ }
+ closeModal()
+ } else if (modal.kind === 'editTag') {
+ const name = modalInput.trim()
+ if (!name) return
+ if (name.length > MAX_TAG_LENGTH) { setModalError('Tag name cannot exceed 50 characters'); return }
+ if (tags.some((t) => t.name === name && t._id !== modal.tag._id)) { setModalError(`Tag "${name}" already exists`); return }
+ const r = await window.api.overleafEditTag(modal.tag._id, name, modalColor)
+ if (!r.success) { setModalError(r.message || 'Failed to update tag'); return }
+ setTags((list) => list.map((t) => (t._id === modal.tag._id ? { ...t, name, color: modalColor } : t)))
+ closeModal()
+ } else if (modal.kind === 'deleteTag') {
+ const r = await window.api.overleafDeleteTag(modal.tag._id)
+ if (!r.success) { setModalError(r.message || 'Failed to delete tag'); return }
+ setTags((list) => list.filter((t) => t._id !== modal.tag._id))
+ if (selectedTagId === modal.tag._id) setSelectedTagId(undefined)
+ closeModal()
+ } else if (modal.kind === 'apiKeys') {
+ const cleaned: Record<string, string> = {}
+ for (const [k, v] of Object.entries(apiKeys)) {
+ if (v.trim()) cleaned[k] = v.trim()
+ }
+ await window.api.setApiKeys(cleaned)
+ closeModal()
+ setStatusMessage('API keys saved')
+ }
+ } finally {
+ setModalBusy(false)
}
}
@@ -106,93 +502,44 @@ export default function ProjectList({ onOpenProject }: Props) {
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 {
+ loadData()
+ } else if (result.message !== 'cancelled') {
setError(result.message || 'Failed to upload project')
}
}
+ const handleDownload = async (targets: OverleafProject[]) => {
+ const name = targets.length === 1 ? targets[0].name : 'projects'
+ const r = await window.api.overleafDownloadProjectZip(targets.map((p) => p.id), name)
+ if (r.success) setStatusMessage(`Downloaded to ${r.path}`)
+ else if (r.message !== 'cancelled') setError(r.message || 'Download failed')
+ }
+
const handleLogout = async () => {
await window.api.otDisconnect()
useAppStore.getState().resetEditorState()
useAppStore.getState().setScreen('login')
}
- const openApiKeys = async () => {
+ const openApiKeysModal = async () => {
const keys = await window.api.getApiKeys()
setApiKeys(keys)
setApiKeysVisible({})
- setShowApiKeys(true)
- }
-
- const saveApiKeys = async () => {
- // Strip empty keys before saving
- const cleaned: Record<string, string> = {}
- for (const [k, v] of Object.entries(apiKeys)) {
- if (v.trim()) cleaned[k] = v.trim()
- }
- await window.api.setApiKeys(cleaned)
- setShowApiKeys(false)
- setStatusMessage('API keys saved')
+ openModal({ kind: 'apiKeys' })
}
- const API_KEY_FIELDS = [
- { id: 'openai', label: 'OpenAI', placeholder: 'sk-...' },
- { id: 'anthropic', label: 'Anthropic (Claude)', placeholder: 'sk-ant-...' },
- { id: 'openrouter', label: 'OpenRouter', placeholder: 'sk-or-...' },
- { id: 'gemini', label: 'Google Gemini', placeholder: 'AIza...' },
- { id: 'semanticScholar', label: 'Semantic Scholar', placeholder: 'API key (optional, avoids rate limits)' }
- ]
-
- 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])
+ // ── Formatting helpers ──
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 diffMs = Date.now() - date.getTime()
const diffDays = Math.floor(diffMs / 86400000)
if (diffDays === 0) {
const diffH = Math.floor(diffMs / 3600000)
@@ -209,178 +556,575 @@ export default function ProjectList({ onOpenProject }: Props) {
} catch { return '' }
}
- const personName = (p?: { firstName: string; lastName: string } | null) => {
+ const personName = (p?: { firstName: string; lastName: string; email?: string } | null) => {
if (!p) return ''
- return `${p.firstName} ${p.lastName}`.trim()
+ return `${p.firstName || ''} ${p.lastName || ''}`.trim() || p.email || ''
}
- const accessLabel = (level?: string) => {
- switch (level) {
- case 'owner': return 'Owner'
- case 'readAndWrite': return 'Can edit'
- case 'readOnly': return 'View only'
- default: return level || ''
- }
+ const ownerDisplay = (p: OverleafProject) => {
+ if (p.accessLevel === 'owner') return 'You'
+ return personName(p.owner)
+ }
+
+ const toggleSort = (key: SortKey) => {
+ setSort((s) => (s.by === key ? { by: key, order: s.order === 'asc' ? 'desc' : 'asc' } : { by: key, order: s.order }))
+ }
+
+ const sortIndicator = (key: SortKey) => (sort.by !== key ? '' : sort.order === 'asc' ? ' ↑' : ' ↓')
+
+ const toggleSelected = (id: string) => {
+ setSelectedIds((ids) => {
+ const next = new Set(ids)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const allVisibleSelected = visibleProjects.length > 0 && selectedProjects.length === visibleProjects.length
+
+ const toggleSelectAll = () => {
+ if (allVisibleSelected) setSelectedIds(new Set())
+ else setSelectedIds(new Set(visibleProjects.map((p) => p.id)))
+ }
+
+ const hasDeletableSelected = selectedProjects.some((p) => p.accessLevel === 'owner')
+ const hasLeavableSelected = selectedProjects.some((p) => p.accessLevel !== 'owner')
+
+ const sortedTags = useMemo(() => [...tags].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())), [tags])
+
+ // ── Render ──
+
+ const API_KEY_FIELDS = [
+ { id: 'openai', label: 'OpenAI', placeholder: 'sk-...' },
+ { id: 'anthropic', label: 'Anthropic (Claude)', placeholder: 'sk-ant-...' },
+ { id: 'openrouter', label: 'OpenRouter', placeholder: 'sk-or-...' },
+ { id: 'gemini', label: 'Google Gemini', placeholder: 'AIza...' },
+ { id: 'semanticScholar', label: 'Semantic Scholar', placeholder: 'API key (optional, avoids rate limits)' }
+ ]
+
+ const iconBtn = (title: string, icon: keyof typeof ICONS, onClick: () => void, danger = false) => (
+ <button
+ key={title}
+ className={`pl-icon-btn ${danger ? 'pl-icon-btn-danger' : ''}`}
+ title={title}
+ onClick={(e) => { e.stopPropagation(); onClick() }}
+ >
+ <Icon d={ICONS[icon]} />
+ </button>
+ )
+
+ const rowActions = (p: OverleafProject) => {
+ const isOwner = p.accessLevel === 'owner'
+ const buttons: JSX.Element[] = []
+ if (!p.archived && !p.trashed) buttons.push(iconBtn('Copy', 'copy', () => openModal({ kind: 'clone', project: p })))
+ buttons.push(iconBtn('Download .zip file', 'download', () => handleDownload([p])))
+ if (!p.archived) buttons.push(iconBtn('Archive', 'archive', () => openModal({ kind: 'confirm', action: 'archive', projects: [p] })))
+ if (!p.trashed) buttons.push(iconBtn('Trash', 'trash', () => openModal({ kind: 'confirm', action: 'trash', projects: [p] })))
+ if (p.archived) buttons.push(iconBtn('Restore', 'restore', () => unarchiveProjects([p])))
+ if (p.trashed) buttons.push(iconBtn('Restore', 'restore', () => untrashProjects([p])))
+ if (p.trashed && !isOwner) buttons.push(iconBtn('Leave', 'leave', () => openModal({ kind: 'confirm', action: 'leave', projects: [p] }), true))
+ if (p.trashed && isOwner) buttons.push(iconBtn('Delete', 'block', () => openModal({ kind: 'confirm', action: 'delete', projects: [p] }), true))
+ if (isOwner && !p.archived && !p.trashed) buttons.push(iconBtn('Rename', 'edit', () => openModal({ kind: 'rename', project: p })))
+ return buttons
}
- const sortIndicator = (key: SortKey) => {
- if (sortBy !== key) return ''
- return sortOrder === 'asc' ? ' ↑' : ' ↓'
+ const projectTags = (p: OverleafProject) =>
+ sortedTags.filter((t) => t.project_ids?.includes(p.id))
+
+ const confirmCopy: Record<string, { title: string; intro: string; note: string; danger: boolean; button: string }> = {
+ archive: {
+ title: 'Archive projects',
+ intro: 'You are about to archive the following projects:',
+ note: "Archiving projects won't affect your collaborators.",
+ danger: false,
+ button: 'Confirm',
+ },
+ trash: {
+ title: 'Trash projects',
+ intro: 'You are about to trash the following projects:',
+ note: "Trashing projects won't affect your collaborators.",
+ danger: false,
+ button: 'Confirm',
+ },
+ delete: {
+ title: 'Delete projects',
+ intro: 'You are about to delete the following projects:',
+ note: 'This action cannot be undone.',
+ danger: true,
+ button: 'Delete',
+ },
+ leave: {
+ title: 'Leave projects',
+ intro: 'You are about to leave the following projects:',
+ note: 'This action cannot be undone.',
+ danger: true,
+ button: 'Leave',
+ },
}
return (
<div className="projects-page">
<div className="projects-drag-bar" />
- <div className="projects-container">
- <div className="projects-header">
- <h1>Latte<span className="lattex-x">X</span></h1>
- <div className="projects-header-actions">
- <button className="btn btn-secondary btn-sm" onClick={openApiKeys}>
- API Keys
- </button>
- <button className="btn btn-secondary btn-sm" onClick={handleLogout}>
- Sign out
- </button>
+ <div className="pl-layout">
+ {/* ── Sidebar ── */}
+ <aside className="pl-sidebar">
+ <div className="pl-sidebar-brand">
+ <h1>Latte<span className="lattex-x">X</span></h1>
</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 className="pl-new-project-wrap" onClick={(e) => e.stopPropagation()}>
+ <button className="btn btn-primary pl-new-project-btn" onClick={() => setNewMenuOpen((v) => !v)}>
+ New project
+ </button>
+ {newMenuOpen && (
+ <div className="pl-dropdown">
+ <button className="pl-dropdown-item" onClick={() => { setNewMenuOpen(false); openModal({ kind: 'newProject' }) }}>
+ Blank project
+ </button>
+ <button className="pl-dropdown-item" onClick={() => { setNewMenuOpen(false); handleUploadProject() }}>
+ Upload project
+ </button>
+ </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={() => setShowNewProject(true)}>
- 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 ? '...' : '↻'}
+
+ <nav className="pl-filters">
+ {([
+ ['all', 'All projects'],
+ ['owned', 'Your projects'],
+ ['shared', 'Shared with you'],
+ ['archived', 'Archived projects'],
+ ['trashed', 'Trashed projects'],
+ ] as Array<[Filter, string]>).map(([f, label]) => (
+ <button
+ key={f}
+ className={`pl-filter-item ${selectedTagId === undefined && filter === f ? 'active' : ''}`}
+ onClick={() => selectFilter(f)}
+ >
+ {label}
</button>
- </div>
+ ))}
+ </nav>
- <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>
+ <hr className="pl-sidebar-divider" />
- <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="pl-tags-section">
+ <div className="pl-tags-header">Tags</div>
+ <button className="pl-filter-item pl-new-tag" onClick={() => openModal({ kind: 'createTag' })}>
+ <Icon d={ICONS.plus} size={13} /> New tag
+ </button>
+ {sortedTags.map((tag) => (
+ <div key={tag._id} className={`pl-tag-row ${selectedTagId === tag._id ? 'active' : ''}`}>
+ <button className="pl-tag-main" onClick={() => selectTag(tag._id)}>
+ <span className="pl-tag-dot" style={{ backgroundColor: getTagColor(tag) }} />
+ <span className="pl-tag-name">{tag.name}</span>
+ <span className="pl-tag-count">({projectsPerTag[tag._id] ?? 0})</span>
+ </button>
+ <div className="pl-tag-kebab-wrap" onClick={(e) => e.stopPropagation()}>
+ <button
+ className="pl-icon-btn pl-tag-kebab"
+ onClick={() => setTagKebabOpen(tagKebabOpen === tag._id ? null : tag._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)}
+ <Icon d={ICONS.kebab} size={14} />
+ </button>
+ {tagKebabOpen === tag._id && (
+ <div className="pl-dropdown pl-dropdown-right">
+ <button className="pl-dropdown-item" onClick={() => { setTagKebabOpen(null); openModal({ kind: 'editTag', tag }) }}>
+ Edit
+ </button>
+ <button className="pl-dropdown-item pl-danger" onClick={() => { setTagKebabOpen(null); openModal({ kind: 'deleteTag', tag }) }}>
+ Delete
+ </button>
</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>
+ ))}
+ {sortedTags.length > 0 && (
+ <button
+ className={`pl-filter-item pl-uncategorized ${selectedTagId === UNCATEGORIZED_KEY ? 'active' : ''}`}
+ onClick={() => selectTag(UNCATEGORIZED_KEY)}
+ >
+ Uncategorized ({untaggedCount})
+ </button>
+ )}
+ </div>
+
+ <div className="pl-sidebar-footer">
+ <button className="btn btn-secondary btn-sm" onClick={openApiKeysModal}>API Keys</button>
+ <button className="btn btn-secondary btn-sm" onClick={handleLogout}>Sign out</button>
+ </div>
+ </aside>
+
+ {/* ── Main column ── */}
+ <main className="pl-main">
+ {busy ? (
+ <div className="projects-busy">
+ <div className="overleaf-spinner" />
+ <div className="overleaf-log">{busyText}</div>
+ </div>
+ ) : (
+ <>
+ <div className="pl-main-header">
+ <h2 className="pl-title">{pageTitle}</h2>
+ <div className="pl-header-actions">
+ {selectedProjects.length > 0 ? (
+ <div className="pl-bulk-tools" onClick={(e) => e.stopPropagation()}>
+ {iconBtn('Download .zip', 'download', () => handleDownload(selectedProjects))}
+ {filter !== 'archived' && iconBtn('Archive', 'archive', () => openModal({ kind: 'confirm', action: 'archive', projects: selectedProjects }))}
+ {filter !== 'trashed' && iconBtn('Trash', 'trash', () => openModal({ kind: 'confirm', action: 'trash', projects: selectedProjects }))}
+ {filter === 'trashed' && (
+ <button className="btn btn-secondary btn-sm" onClick={() => untrashProjects(selectedProjects)}>Restore</button>
+ )}
+ {filter === 'archived' && (
+ <button className="btn btn-secondary btn-sm" onClick={() => unarchiveProjects(selectedProjects)}>Restore</button>
+ )}
+ {filter === 'trashed' && hasDeletableSelected && !hasLeavableSelected && (
+ <button className="btn btn-danger btn-sm" onClick={() => openModal({ kind: 'confirm', action: 'delete', projects: selectedProjects })}>Delete</button>
+ )}
+ {filter === 'trashed' && hasLeavableSelected && !hasDeletableSelected && (
+ <button className="btn btn-danger btn-sm" onClick={() => openModal({ kind: 'confirm', action: 'leave', projects: selectedProjects })}>Leave</button>
+ )}
+ {filter === 'trashed' && hasLeavableSelected && hasDeletableSelected && (
+ <>
+ <button className="btn btn-danger btn-sm" onClick={() => openModal({ kind: 'confirm', action: 'delete', projects: selectedProjects })}>Delete</button>
+ <button className="btn btn-danger btn-sm" onClick={() => openModal({ kind: 'confirm', action: 'leave', projects: selectedProjects })}>Leave</button>
+ </>
+ )}
+ {!['archived', 'trashed'].includes(filter) && (
+ <div className="pl-tags-dropdown-wrap">
+ <button className="pl-icon-btn" title="Add to tag" onClick={() => setTagsMenuOpen((v) => !v)}>
+ <Icon d={ICONS.tag} />
+ </button>
+ {tagsMenuOpen && (
+ <div className="pl-dropdown pl-dropdown-right">
+ <div className="pl-dropdown-header">Add to tag</div>
+ {sortedTags.map((tag) => {
+ const allIn = selectedProjects.every((p) => tag.project_ids?.includes(p.id))
+ return (
+ <button key={tag._id} className="pl-dropdown-item" onClick={() => toggleTagForSelected(tag)}>
+ <span className="pl-tag-dot" style={{ backgroundColor: getTagColor(tag) }} />
+ <span className="pl-dropdown-label">{tag.name}</span>
+ {allIn && <Icon d={ICONS.check} size={13} />}
+ </button>
+ )
+ })}
+ {sortedTags.length > 0 && <hr className="pl-dropdown-divider" />}
+ <button
+ className="pl-dropdown-item"
+ onClick={() => {
+ setTagsMenuOpen(false)
+ openModal({ kind: 'createTag', forProjects: selectedProjects.map((p) => p.id) })
+ }}
+ >
+ <Icon d={ICONS.plus} size={13} /> Create new tag
+ </button>
+ </div>
+ )}
+ </div>
)}
</div>
- </div>
- ))
+ ) : (
+ <button className="btn btn-secondary btn-sm" onClick={loadData} title="Refresh">
+ {loading ? '…' : '↻'}
+ </button>
+ )}
+ </div>
+ </div>
+
+ {error && <div className="overleaf-error" style={{ margin: '0 0 12px' }}>{error}</div>}
+
+ <div className="pl-search-row">
+ <span className="pl-search-icon"><Icon d={ICONS.search} size={14} /></span>
+ <input
+ className="projects-search pl-search-input"
+ type="text"
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ placeholder={searchPlaceholder}
+ />
+ {searchText && (
+ <button className="pl-icon-btn pl-search-clear" title="Clear search" onClick={() => setSearchText('')}>
+ <Icon d={ICONS.x} size={13} />
+ </button>
+ )}
+ </div>
+
+ <div className="pl-table">
+ <div className="pl-table-header">
+ <span className="pl-col-check">
+ <input
+ type="checkbox"
+ checked={allVisibleSelected}
+ ref={(el) => {
+ if (el) el.indeterminate = selectedProjects.length > 0 && !allVisibleSelected
+ }}
+ onChange={toggleSelectAll}
+ />
+ </span>
+ <span className="pl-col-name pl-sortable" onClick={() => toggleSort('title')}>
+ Title{sortIndicator('title')}
+ </span>
+ <span className="pl-col-owner pl-sortable" onClick={() => toggleSort('owner')}>
+ Owner{sortIndicator('owner')}
+ </span>
+ <span className="pl-col-updated pl-sortable" onClick={() => toggleSort('lastUpdated')}>
+ Last Modified{sortIndicator('lastUpdated')}
+ </span>
+ <span className="pl-col-actions">Actions</span>
+ </div>
+
+ <div className="pl-table-body">
+ {loading && projects.length === 0 ? (
+ <div className="projects-empty">Loading projects…</div>
+ ) : visibleProjects.length === 0 ? (
+ <div className="projects-empty">
+ {searchText ? 'No projects match your search' : 'No projects'}
+ </div>
+ ) : (
+ visibleProjects.map((p) => (
+ <div key={p.id} className={`pl-row ${selectedIds.has(p.id) ? 'selected' : ''}`}>
+ <span className="pl-col-check" onClick={(e) => e.stopPropagation()}>
+ <input
+ type="checkbox"
+ checked={selectedIds.has(p.id)}
+ onChange={() => toggleSelected(p.id)}
+ />
+ </span>
+ <span className="pl-col-name">
+ <button className="pl-project-link" onClick={() => handleOpen(p.id)}>{p.name}</button>
+ <span className="pl-row-tags">
+ {projectTags(p).map((tag) => (
+ <span key={tag._id} className="pl-chip">
+ <span className="pl-tag-dot" style={{ backgroundColor: getTagColor(tag) }} />
+ <button className="pl-chip-name" onClick={() => selectTag(tag._id)}>{tag.name}</button>
+ <button
+ className="pl-chip-x"
+ title={`Remove from ${tag.name}`}
+ onClick={() => {
+ removeProjectsFromTagInView(tag._id, [p.id])
+ window.api.overleafRemoveProjectsFromTag(tag._id, [p.id])
+ }}
+ >
+ <Icon d={ICONS.x} size={9} />
+ </button>
+ </span>
+ ))}
+ </span>
+ </span>
+ <span className="pl-col-owner">
+ {ownerDisplay(p)}
+ {p.source === 'token' && <span className="pl-link-icon" title="Link sharing"><Icon d={ICONS.link} size={12} /></span>}
+ </span>
+ <span className="pl-col-updated" title={new Date(p.lastUpdated).toLocaleString()}>
+ {formatDate(p.lastUpdated)}
+ {p.lastUpdatedBy && <span className="projects-updated-by"> by {personName(p.lastUpdatedBy)}</span>}
+ </span>
+ <span className="pl-col-actions">{rowActions(p)}</span>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+
+ {hiddenCount > 0 && (
+ <div className="pl-load-more">
+ <button className="btn btn-secondary btn-sm" onClick={() => setMaxVisible((m) => m + Math.min(hiddenCount, PAGE_SIZE))}>
+ Show {Math.min(hiddenCount, PAGE_SIZE)} more projects
+ </button>
+ <span className="pl-load-more-info">
+ Showing {visibleProjects.length} out of {filteredProjects.length} projects.{' '}
+ <button className="pl-link-btn" onClick={() => setMaxVisible(filteredProjects.length)}>Show all</button>
+ </span>
+ </div>
)}
- </div>
- </>
- )}
+ </>
+ )}
+ </main>
</div>
- {showNewProject && (
- <div className="modal-overlay" onClick={() => setShowNewProject(false)}>
- <div className="modal-box" onClick={(e) => e.stopPropagation()}>
- <h3 style={{ margin: '0 0 12px' }}>New Project</h3>
- <input
- type="text"
- className="projects-search"
- value={newProjectName}
- onChange={(e) => setNewProjectName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') handleCreateProject()
- if (e.key === 'Escape') setShowNewProject(false)
- }}
- autoFocus
- style={{ width: '100%', marginBottom: 12 }}
- />
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
- <button className="btn btn-secondary btn-sm" onClick={() => setShowNewProject(false)}>Cancel</button>
- <button className="btn btn-primary btn-sm" onClick={handleCreateProject}>Create</button>
- </div>
- </div>
- </div>
- )}
- {showApiKeys && (
- <div className="modal-overlay" onClick={() => setShowApiKeys(false)}>
- <div className="modal-box" onClick={(e) => e.stopPropagation()} style={{ minWidth: 460 }}>
- <h3 style={{ margin: '0 0 4px' }}>API Keys</h3>
- <p style={{ margin: '0 0 16px', fontSize: 12, color: 'var(--text-secondary)' }}>
- Keys are stored locally on this device.
- </p>
- {API_KEY_FIELDS.map((field) => (
- <div key={field.id} style={{ marginBottom: 12 }}>
- <label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 4, color: 'var(--text-secondary)' }}>
- {field.label}
- </label>
- <div style={{ display: 'flex', gap: 4 }}>
- <input
- type={apiKeysVisible[field.id] ? 'text' : 'password'}
- className="modal-input"
- value={apiKeys[field.id] || ''}
- onChange={(e) => setApiKeys({ ...apiKeys, [field.id]: e.target.value })}
- placeholder={field.placeholder}
- spellCheck={false}
- autoComplete="off"
- />
+
+ {/* ── Modals ── */}
+ {modal && (
+ <div className="modal-overlay" onClick={closeModal}>
+ <div className="modal-box" onClick={(e) => e.stopPropagation()} style={{ minWidth: modal.kind === 'apiKeys' ? 460 : 400 }}>
+ {modal.kind === 'newProject' && (
+ <>
+ <h3 className="pl-modal-title">New project</h3>
+ <label className="pl-modal-label">Project name</label>
+ <input
+ type="text" className="modal-input" value={modalInput} autoFocus
+ onChange={(e) => setModalInput(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') runModalAction(); if (e.key === 'Escape') closeModal() }}
+ />
+ {modalError && <div className="overleaf-error pl-modal-error">{modalError}</div>}
+ <div className="pl-modal-actions">
+ <button className="btn btn-secondary btn-sm" onClick={closeModal}>Cancel</button>
+ <button className="btn btn-primary btn-sm" disabled={!modalInput.trim() || modalBusy} onClick={runModalAction}>
+ {modalBusy ? 'Creating…' : 'Create'}
+ </button>
+ </div>
+ </>
+ )}
+
+ {modal.kind === 'rename' && (
+ <>
+ <h3 className="pl-modal-title">Rename project</h3>
+ <label className="pl-modal-label">New name</label>
+ <input
+ type="text" className="modal-input" value={modalInput} autoFocus
+ onChange={(e) => setModalInput(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') runModalAction(); if (e.key === 'Escape') closeModal() }}
+ />
+ {modalError && <div className="overleaf-error pl-modal-error">{modalError}</div>}
+ <div className="pl-modal-actions">
+ <button className="btn btn-secondary btn-sm" onClick={closeModal}>Cancel</button>
<button
- className="btn btn-secondary btn-sm"
- onClick={() => setApiKeysVisible({ ...apiKeysVisible, [field.id]: !apiKeysVisible[field.id] })}
- style={{ flexShrink: 0, padding: '6px 8px', fontSize: 11 }}
- title={apiKeysVisible[field.id] ? 'Hide' : 'Show'}
+ className="btn btn-primary btn-sm"
+ disabled={!modalInput.trim() || modalInput.trim() === modal.project.name || modalBusy}
+ onClick={runModalAction}
>
- {apiKeysVisible[field.id] ? 'Hide' : 'Show'}
+ {modalBusy ? 'Renaming…' : 'Rename'}
</button>
</div>
- </div>
- ))}
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16 }}>
- <button className="btn btn-secondary btn-sm" onClick={() => setShowApiKeys(false)}>Cancel</button>
- <button className="btn btn-primary btn-sm" onClick={saveApiKeys}>Save</button>
- </div>
+ </>
+ )}
+
+ {modal.kind === 'clone' && (
+ <>
+ <h3 className="pl-modal-title">Copy project</h3>
+ <label className="pl-modal-label">New name</label>
+ <input
+ type="text" className="modal-input" value={modalInput} autoFocus
+ onChange={(e) => setModalInput(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') runModalAction(); if (e.key === 'Escape') closeModal() }}
+ />
+ {modalError && <div className="overleaf-error pl-modal-error">{modalError}</div>}
+ <div className="pl-modal-actions">
+ <button className="btn btn-secondary btn-sm" onClick={closeModal}>Cancel</button>
+ <button className="btn btn-primary btn-sm" disabled={!modalInput.trim() || modalBusy} onClick={runModalAction}>
+ {modalBusy ? 'Copying…' : 'Copy'}
+ </button>
+ </div>
+ </>
+ )}
+
+ {modal.kind === 'confirm' && (
+ <>
+ <h3 className="pl-modal-title">{confirmCopy[modal.action].title}</h3>
+ <p className="pl-modal-text">{confirmCopy[modal.action].intro}</p>
+ <ul className="pl-modal-list">
+ {modal.projects.map((p) => <li key={p.id}><b>{p.name}</b></li>)}
+ </ul>
+ <p className={`pl-modal-note ${confirmCopy[modal.action].danger ? 'pl-danger' : ''}`}>
+ {confirmCopy[modal.action].note}
+ </p>
+ {modalError && <div className="overleaf-error pl-modal-error">{modalError}</div>}
+ <div className="pl-modal-actions">
+ <button className="btn btn-secondary btn-sm" onClick={closeModal}>Cancel</button>
+ <button
+ className={`btn btn-sm ${confirmCopy[modal.action].danger ? 'btn-danger' : 'btn-primary'}`}
+ disabled={modalBusy}
+ onClick={runModalAction}
+ >
+ {modalBusy ? 'Working…' : confirmCopy[modal.action].button}
+ </button>
+ </div>
+ </>
+ )}
+
+ {(modal.kind === 'createTag' || modal.kind === 'editTag') && (
+ <>
+ <h3 className="pl-modal-title">{modal.kind === 'createTag' ? 'Create new tag' : 'Edit tag'}</h3>
+ <label className="pl-modal-label">{modal.kind === 'createTag' ? 'New tag name' : 'Tag name'}</label>
+ <input
+ type="text" className="modal-input" value={modalInput} autoFocus maxLength={MAX_TAG_LENGTH + 1}
+ onChange={(e) => setModalInput(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') runModalAction(); if (e.key === 'Escape') closeModal() }}
+ />
+ <label className="pl-modal-label">Tag color</label>
+ <div className="pl-color-row">
+ {PRESET_COLORS.map(({ color, name }) => (
+ <button
+ key={color}
+ className={`pl-color-swatch ${modalColor === color ? 'selected' : ''}`}
+ style={{ backgroundColor: color }}
+ title={name}
+ onClick={() => setModalColor(color)}
+ >
+ {modalColor === color && <Icon d={ICONS.check} size={12} />}
+ </button>
+ ))}
+ <input
+ type="color"
+ className="pl-color-custom"
+ title="Custom color"
+ value={modalColor && /^#/.test(modalColor) ? modalColor : '#A7B1C2'}
+ onChange={(e) => setModalColor(e.target.value)}
+ />
+ </div>
+ {modalError && <div className="overleaf-error pl-modal-error">{modalError}</div>}
+ <div className="pl-modal-actions">
+ <button className="btn btn-secondary btn-sm" onClick={closeModal}>Cancel</button>
+ <button className="btn btn-primary btn-sm" disabled={!modalInput.trim() || modalBusy} onClick={runModalAction}>
+ {modalBusy ? (modal.kind === 'createTag' ? 'Creating…' : 'Saving…') : (modal.kind === 'createTag' ? 'Create' : 'Save')}
+ </button>
+ </div>
+ </>
+ )}
+
+ {modal.kind === 'deleteTag' && (
+ <>
+ <h3 className="pl-modal-title">Delete tag</h3>
+ <p className="pl-modal-text">
+ You are about to delete the following tag (any projects in it will not be deleted):
+ </p>
+ <ul className="pl-modal-list"><li><b>{modal.tag.name}</b></li></ul>
+ {modalError && <div className="overleaf-error pl-modal-error">{modalError}</div>}
+ <div className="pl-modal-actions">
+ <button className="btn btn-secondary btn-sm" onClick={closeModal}>Cancel</button>
+ <button className="btn btn-danger btn-sm" disabled={modalBusy} onClick={runModalAction}>
+ {modalBusy ? 'Deleting…' : 'Delete'}
+ </button>
+ </div>
+ </>
+ )}
+
+ {modal.kind === 'apiKeys' && (
+ <>
+ <h3 className="pl-modal-title">API Keys</h3>
+ <p className="pl-modal-text" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
+ Keys are stored locally on this device.
+ </p>
+ {API_KEY_FIELDS.map((field) => (
+ <div key={field.id} style={{ marginBottom: 12 }}>
+ <label className="pl-modal-label">{field.label}</label>
+ <div style={{ display: 'flex', gap: 4 }}>
+ <input
+ type={apiKeysVisible[field.id] ? 'text' : 'password'}
+ className="modal-input"
+ value={apiKeys[field.id] || ''}
+ onChange={(e) => setApiKeys({ ...apiKeys, [field.id]: e.target.value })}
+ placeholder={field.placeholder}
+ spellCheck={false}
+ autoComplete="off"
+ />
+ <button
+ className="btn btn-secondary btn-sm"
+ onClick={() => setApiKeysVisible({ ...apiKeysVisible, [field.id]: !apiKeysVisible[field.id] })}
+ style={{ flexShrink: 0, padding: '6px 8px', fontSize: 11 }}
+ >
+ {apiKeysVisible[field.id] ? 'Hide' : 'Show'}
+ </button>
+ </div>
+ </div>
+ ))}
+ <div className="pl-modal-actions">
+ <button className="btn btn-secondary btn-sm" onClick={closeModal}>Cancel</button>
+ <button className="btn btn-primary btn-sm" onClick={runModalAction}>Save</button>
+ </div>
+ </>
+ )}
</div>
</div>
)}
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx
index b89385a..7cbaa7f 100644
--- a/src/renderer/src/components/Toolbar.tsx
+++ b/src/renderer/src/components/Toolbar.tsx
@@ -108,8 +108,9 @@ export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarPr
{showUsersPopover && (
<div className="users-popover">
<div className="users-popover-title">Online Users</div>
- {Array.from(remoteCursors.values()).map((u) => (
- <div key={u.userId} className="users-popover-item">
+ {/* Key by client id (map key) — one user can have several clients */}
+ {Array.from(remoteCursors.entries()).map(([clientId, u]) => (
+ <div key={clientId} className="users-popover-item">
<span className="users-popover-dot" style={{ background: u.color }} />
<span className="users-popover-name">{u.name}</span>
</div>
diff --git a/src/renderer/src/data/latexClassesAndStyles.ts b/src/renderer/src/data/latexClassesAndStyles.ts
new file mode 100644
index 0000000..5c4d011
--- /dev/null
+++ b/src/renderer/src/data/latexClassesAndStyles.ts
@@ -0,0 +1,58 @@
+// Ported verbatim from Overleaf (bibliography-styles.ts + class-names.ts), AGPL-3.0.
+export const bibliographyStyles: Record<string, string[]> = {
+ // https://www.overleaf.com/learn/latex/Bibtex_bibliography_styles
+ bibtex: [
+ 'abbrv',
+ 'acm',
+ 'alpha',
+ 'apalike',
+ 'ieeetr',
+ 'plain',
+ 'siam',
+ 'unsrt',
+ ],
+ // https://www.overleaf.com/learn/latex/Natbib_bibliography_styles
+ natbib: ['dinat', 'plainnat', 'abbrvnat', 'unsrtnat', 'rusnat', 'ksfh_nat'],
+ // https://www.overleaf.com/learn/latex/Biblatex_bibliography_styles
+ biblatex: [
+ 'numeric',
+ 'alphabetic',
+ 'authoryear',
+ 'authortitle',
+ 'verbose',
+ 'reading',
+ 'draft',
+ 'authoryear-icomp',
+ 'apa',
+ 'bwl-FU',
+ 'chem-acs',
+ 'chem-angew',
+ 'chem-biochem',
+ 'chem-rsc',
+ 'ieee',
+ 'mla',
+ 'musuos',
+ 'nature',
+ 'nejm',
+ 'phys',
+ 'science',
+ 'geschichtsfrkl',
+ 'oscola',
+ ],
+ // https://ctan.org/tex-archive/macros/latex/contrib/biblatex-contrib
+ 'biblatex-contrib': [
+ // TODO
+ ],
+}
+
+// https://www.overleaf.com/learn/latex/Creating_a_document_in_LaTeX#Reference_guide
+
+// TODO: more class names
+export const classNames = [
+ 'article',
+ 'report',
+ 'book',
+ 'letter',
+ // 'slides',
+ 'beamer',
+]
diff --git a/src/renderer/src/data/latexCommands.ts b/src/renderer/src/data/latexCommands.ts
deleted file mode 100644
index 2364e4b..0000000
--- a/src/renderer/src/data/latexCommands.ts
+++ /dev/null
@@ -1,304 +0,0 @@
-// Copyright (c) 2026 Yuren Hao
-// Licensed under AGPL-3.0 - see LICENSE file
-
-// Static LaTeX command/snippet completions
-// Each entry: [command, snippet (with $1 tab stops), detail]
-// Snippet syntax: $1, $2 etc are tab stops, ${1:placeholder} has default text
-
-export interface LatexCommand {
- label: string // e.g. "\\frac"
- snippet?: string // e.g. "\\frac{$1}{$2}" — if absent, label is used as-is
- detail?: string // short description
- section?: string // category for grouping
- symbol?: string // Unicode symbol preview (e.g. "α" for \alpha)
-}
-
-export const latexCommands: LatexCommand[] = [
- // ── Document structure ──
- { label: '\\documentclass', snippet: '\\documentclass{${1:article}}', detail: 'Document class', section: 'structure' },
- { label: '\\usepackage', snippet: '\\usepackage{$1}', detail: 'Load package', section: 'structure' },
- { label: '\\usepackage[]', snippet: '\\usepackage[${1:options}]{${2:package}}', detail: 'Load package with options', section: 'structure' },
- { label: '\\title', snippet: '\\title{$1}', detail: 'Document title', section: 'structure' },
- { label: '\\author', snippet: '\\author{$1}', detail: 'Document author', section: 'structure' },
- { label: '\\date', snippet: '\\date{$1}', detail: 'Document date', section: 'structure' },
- { label: '\\maketitle', detail: 'Print title block', section: 'structure' },
- { label: '\\tableofcontents', detail: 'Table of contents', section: 'structure' },
- { label: '\\listoffigures', detail: 'List of figures', section: 'structure' },
- { label: '\\listoftables', detail: 'List of tables', section: 'structure' },
- { label: '\\appendix', detail: 'Start appendix', section: 'structure' },
- { label: '\\bibliography', snippet: '\\bibliography{$1}', detail: 'Bibliography file', section: 'structure' },
- { label: '\\bibliographystyle', snippet: '\\bibliographystyle{${1:plain}}', detail: 'Bibliography style', section: 'structure' },
-
- // ── Sectioning ──
- { label: '\\part', snippet: '\\part{$1}', detail: 'Part heading', section: 'sectioning' },
- { label: '\\chapter', snippet: '\\chapter{$1}', detail: 'Chapter heading', section: 'sectioning' },
- { label: '\\section', snippet: '\\section{$1}', detail: 'Section heading', section: 'sectioning' },
- { label: '\\subsection', snippet: '\\subsection{$1}', detail: 'Subsection heading', section: 'sectioning' },
- { label: '\\subsubsection', snippet: '\\subsubsection{$1}', detail: 'Subsubsection heading', section: 'sectioning' },
- { label: '\\paragraph', snippet: '\\paragraph{$1}', detail: 'Paragraph heading', section: 'sectioning' },
- { label: '\\subparagraph', snippet: '\\subparagraph{$1}', detail: 'Subparagraph heading', section: 'sectioning' },
- { label: '\\section*', snippet: '\\section*{$1}', detail: 'Unnumbered section', section: 'sectioning' },
- { label: '\\subsection*', snippet: '\\subsection*{$1}', detail: 'Unnumbered subsection', section: 'sectioning' },
-
- // ── Text formatting ──
- { label: '\\textbf', snippet: '\\textbf{$1}', detail: 'Bold text', section: 'formatting' },
- { label: '\\textit', snippet: '\\textit{$1}', detail: 'Italic text', section: 'formatting' },
- { label: '\\texttt', snippet: '\\texttt{$1}', detail: 'Monospace text', section: 'formatting' },
- { label: '\\textsc', snippet: '\\textsc{$1}', detail: 'Small caps', section: 'formatting' },
- { label: '\\textrm', snippet: '\\textrm{$1}', detail: 'Roman text', section: 'formatting' },
- { label: '\\textsf', snippet: '\\textsf{$1}', detail: 'Sans serif text', section: 'formatting' },
- { label: '\\textsl', snippet: '\\textsl{$1}', detail: 'Slanted text', section: 'formatting' },
- { label: '\\emph', snippet: '\\emph{$1}', detail: 'Emphasized text', section: 'formatting' },
- { label: '\\underline', snippet: '\\underline{$1}', detail: 'Underline', section: 'formatting' },
- { label: '\\textcolor', snippet: '\\textcolor{${1:color}}{${2:text}}', detail: 'Colored text', section: 'formatting' },
- { label: '\\colorbox', snippet: '\\colorbox{${1:color}}{${2:text}}', detail: 'Color box', section: 'formatting' },
- { label: '\\tiny', detail: 'Tiny size', section: 'formatting' },
- { label: '\\scriptsize', detail: 'Script size', section: 'formatting' },
- { label: '\\footnotesize', detail: 'Footnote size', section: 'formatting' },
- { label: '\\small', detail: 'Small size', section: 'formatting' },
- { label: '\\normalsize', detail: 'Normal size', section: 'formatting' },
- { label: '\\large', detail: 'Large size', section: 'formatting' },
- { label: '\\Large', detail: 'Larger size', section: 'formatting' },
- { label: '\\LARGE', detail: 'Even larger', section: 'formatting' },
- { label: '\\huge', detail: 'Huge size', section: 'formatting' },
- { label: '\\Huge', detail: 'Hugest size', section: 'formatting' },
-
- // ── References & citations ──
- { label: '\\label', snippet: '\\label{$1}', detail: 'Set label', section: 'ref' },
- { label: '\\ref', snippet: '\\ref{$1}', detail: 'Reference', section: 'ref' },
- { label: '\\eqref', snippet: '\\eqref{$1}', detail: 'Equation reference', section: 'ref' },
- { label: '\\pageref', snippet: '\\pageref{$1}', detail: 'Page reference', section: 'ref' },
- { label: '\\autoref', snippet: '\\autoref{$1}', detail: 'Auto reference (hyperref)', section: 'ref' },
- { label: '\\cref', snippet: '\\cref{$1}', detail: 'Clever reference (cleveref)', section: 'ref' },
- { label: '\\Cref', snippet: '\\Cref{$1}', detail: 'Clever ref capitalized', section: 'ref' },
- { label: '\\cite', snippet: '\\cite{$1}', detail: 'Citation', section: 'ref' },
- { label: '\\cite[]', snippet: '\\cite[${1:note}]{${2:key}}', detail: 'Citation with note', section: 'ref' },
- { label: '\\citep', snippet: '\\citep{$1}', detail: 'Parenthetical citation', section: 'ref' },
- { label: '\\citet', snippet: '\\citet{$1}', detail: 'Textual citation', section: 'ref' },
- { label: '\\citep[]', snippet: '\\citep[${1:note}]{${2:key}}', detail: 'Parenthetical cite+note', section: 'ref' },
- { label: '\\citeauthor', snippet: '\\citeauthor{$1}', detail: 'Cite author', section: 'ref' },
- { label: '\\citeyear', snippet: '\\citeyear{$1}', detail: 'Cite year', section: 'ref' },
- { label: '\\footnote', snippet: '\\footnote{$1}', detail: 'Footnote', section: 'ref' },
-
- // ── File inclusion ──
- { label: '\\input', snippet: '\\input{$1}', detail: 'Input file', section: 'include' },
- { label: '\\include', snippet: '\\include{$1}', detail: 'Include file', section: 'include' },
- { label: '\\includegraphics', snippet: '\\includegraphics{$1}', detail: 'Include image', section: 'include' },
- { label: '\\includegraphics[]', snippet: '\\includegraphics[${1:width=\\textwidth}]{$2}', detail: 'Include image with options', section: 'include' },
- { label: '\\includeonly', snippet: '\\includeonly{$1}', detail: 'Include only', section: 'include' },
-
- // ── Math ──
- { label: '\\frac', snippet: '\\frac{$1}{$2}', detail: 'Fraction', section: 'math' },
- { label: '\\dfrac', snippet: '\\dfrac{$1}{$2}', detail: 'Display fraction', section: 'math' },
- { label: '\\tfrac', snippet: '\\tfrac{$1}{$2}', detail: 'Text fraction', section: 'math' },
- { label: '\\sqrt', snippet: '\\sqrt{$1}', detail: 'Square root', section: 'math' },
- { label: '\\sqrt[]', snippet: '\\sqrt[${1:n}]{$2}', detail: 'Nth root', section: 'math' },
- { label: '\\sum', snippet: '\\sum_{${1:i=1}}^{${2:n}}', detail: 'Summation', section: 'math', symbol: '∑' },
- { label: '\\prod', snippet: '\\prod_{${1:i=1}}^{${2:n}}', detail: 'Product', section: 'math', symbol: '∏' },
- { label: '\\int', snippet: '\\int_{${1:a}}^{${2:b}}', detail: 'Integral', section: 'math', symbol: '∫' },
- { label: '\\iint', snippet: '\\iint_{$1}', detail: 'Double integral', section: 'math', symbol: '∬' },
- { label: '\\iiint', snippet: '\\iiint_{$1}', detail: 'Triple integral', section: 'math', symbol: '∭' },
- { label: '\\oint', snippet: '\\oint_{$1}', detail: 'Contour integral', section: 'math', symbol: '∮' },
- { label: '\\lim', snippet: '\\lim_{${1:x \\to \\infty}}', detail: 'Limit', section: 'math' },
- { label: '\\infty', detail: 'Infinity', section: 'math', symbol: '∞' },
- { label: '\\partial', detail: 'Partial derivative', section: 'math', symbol: '∂' },
- { label: '\\nabla', detail: 'Nabla/Del', section: 'math', symbol: '∇' },
- { label: '\\forall', detail: 'For all', section: 'math', symbol: '∀' },
- { label: '\\exists', detail: 'Exists', section: 'math', symbol: '∃' },
- { label: '\\nexists', detail: 'Not exists', section: 'math', symbol: '∄' },
- { label: '\\in', detail: 'Element of', section: 'math', symbol: '∈' },
- { label: '\\notin', detail: 'Not element of', section: 'math', symbol: '∉' },
- { label: '\\subset', detail: 'Subset', section: 'math', symbol: '⊂' },
- { label: '\\subseteq', detail: 'Subset or equal', section: 'math', symbol: '⊆' },
- { label: '\\supset', detail: 'Superset', section: 'math', symbol: '⊃' },
- { label: '\\supseteq', detail: 'Superset or equal', section: 'math', symbol: '⊇' },
- { label: '\\cup', detail: 'Union', section: 'math', symbol: '∪' },
- { label: '\\cap', detail: 'Intersection', section: 'math', symbol: '∩' },
- { label: '\\emptyset', detail: 'Empty set', section: 'math', symbol: '∅' },
- { label: '\\varnothing', detail: 'Empty set (variant)', section: 'math', symbol: '∅' },
- { label: '\\mathbb', snippet: '\\mathbb{$1}', detail: 'Blackboard bold', section: 'math' },
- { label: '\\mathcal', snippet: '\\mathcal{$1}', detail: 'Calligraphic', section: 'math' },
- { label: '\\mathfrak', snippet: '\\mathfrak{$1}', detail: 'Fraktur', section: 'math' },
- { label: '\\mathrm', snippet: '\\mathrm{$1}', detail: 'Roman in math', section: 'math' },
- { label: '\\mathbf', snippet: '\\mathbf{$1}', detail: 'Bold in math', section: 'math' },
- { label: '\\mathit', snippet: '\\mathit{$1}', detail: 'Italic in math', section: 'math' },
- { label: '\\text', snippet: '\\text{$1}', detail: 'Text in math', section: 'math' },
- { label: '\\hat', snippet: '\\hat{$1}', detail: 'Hat accent', section: 'math' },
- { label: '\\bar', snippet: '\\bar{$1}', detail: 'Bar accent', section: 'math' },
- { label: '\\vec', snippet: '\\vec{$1}', detail: 'Vector accent', section: 'math' },
- { label: '\\dot', snippet: '\\dot{$1}', detail: 'Dot accent', section: 'math' },
- { label: '\\ddot', snippet: '\\ddot{$1}', detail: 'Double dot accent', section: 'math' },
- { label: '\\tilde', snippet: '\\tilde{$1}', detail: 'Tilde accent', section: 'math' },
- { label: '\\overline', snippet: '\\overline{$1}', detail: 'Overline', section: 'math' },
- { label: '\\overbrace', snippet: '\\overbrace{$1}^{$2}', detail: 'Overbrace', section: 'math' },
- { label: '\\underbrace', snippet: '\\underbrace{$1}_{$2}', detail: 'Underbrace', section: 'math' },
- { label: '\\binom', snippet: '\\binom{$1}{$2}', detail: 'Binomial', section: 'math' },
- { label: '\\left', snippet: '\\left${1:(} $2 \\right${3:)}', detail: 'Left delimiter', section: 'math' },
- { label: '\\right', detail: 'Right delimiter', section: 'math' },
- { label: '\\bigl', detail: 'Big left', section: 'math' },
- { label: '\\bigr', detail: 'Big right', section: 'math' },
- { label: '\\cdot', detail: 'Center dot', section: 'math', symbol: '·' },
- { label: '\\cdots', detail: 'Center dots', section: 'math', symbol: '⋯' },
- { label: '\\ldots', detail: 'Low dots', section: 'math', symbol: '…' },
- { label: '\\vdots', detail: 'Vertical dots', section: 'math', symbol: '⋮' },
- { label: '\\ddots', detail: 'Diagonal dots', section: 'math', symbol: '⋱' },
- { label: '\\times', detail: 'Times', section: 'math', symbol: '×' },
- { label: '\\div', detail: 'Division', section: 'math', symbol: '÷' },
- { label: '\\pm', detail: 'Plus-minus', section: 'math', symbol: '±' },
- { label: '\\mp', detail: 'Minus-plus', section: 'math', symbol: '∓' },
- { label: '\\leq', detail: 'Less or equal', section: 'math', symbol: '≤' },
- { label: '\\geq', detail: 'Greater or equal', section: 'math', symbol: '≥' },
- { label: '\\neq', detail: 'Not equal', section: 'math', symbol: '≠' },
- { label: '\\approx', detail: 'Approximately', section: 'math', symbol: '≈' },
- { label: '\\equiv', detail: 'Equivalent', section: 'math', symbol: '≡' },
- { label: '\\sim', detail: 'Similar', section: 'math', symbol: '∼' },
- { label: '\\propto', detail: 'Proportional to', section: 'math', symbol: '∝' },
- { label: '\\ll', detail: 'Much less', section: 'math', symbol: '≪' },
- { label: '\\gg', detail: 'Much greater', section: 'math', symbol: '≫' },
- { label: '\\to', detail: 'Right arrow', section: 'math', symbol: '→' },
- { label: '\\rightarrow', detail: 'Right arrow', section: 'math', symbol: '→' },
- { label: '\\leftarrow', detail: 'Left arrow', section: 'math', symbol: '←' },
- { label: '\\leftrightarrow', detail: 'Left-right arrow', section: 'math', symbol: '↔' },
- { label: '\\Rightarrow', detail: 'Double right arrow', section: 'math', symbol: '⇒' },
- { label: '\\Leftarrow', detail: 'Double left arrow', section: 'math', symbol: '⇐' },
- { label: '\\Leftrightarrow', detail: 'Double left-right arrow', section: 'math', symbol: '⇔' },
- { label: '\\mapsto', detail: 'Maps to', section: 'math', symbol: '↦' },
- { label: '\\uparrow', detail: 'Up arrow', section: 'math', symbol: '↑' },
- { label: '\\downarrow', detail: 'Down arrow', section: 'math', symbol: '↓' },
- { label: '\\alpha', detail: 'Greek alpha', section: 'greek', symbol: 'α' },
- { label: '\\beta', detail: 'Greek beta', section: 'greek', symbol: 'β' },
- { label: '\\gamma', detail: 'Greek gamma', section: 'greek', symbol: 'γ' },
- { label: '\\Gamma', detail: 'Greek Gamma', section: 'greek', symbol: 'Γ' },
- { label: '\\delta', detail: 'Greek delta', section: 'greek', symbol: 'δ' },
- { label: '\\Delta', detail: 'Greek Delta', section: 'greek', symbol: 'Δ' },
- { label: '\\epsilon', detail: 'Greek epsilon', section: 'greek', symbol: 'ϵ' },
- { label: '\\varepsilon', detail: 'Greek varepsilon', section: 'greek', symbol: 'ε' },
- { label: '\\zeta', detail: 'Greek zeta', section: 'greek', symbol: 'ζ' },
- { label: '\\eta', detail: 'Greek eta', section: 'greek', symbol: 'η' },
- { label: '\\theta', detail: 'Greek theta', section: 'greek', symbol: 'θ' },
- { label: '\\Theta', detail: 'Greek Theta', section: 'greek', symbol: 'Θ' },
- { label: '\\vartheta', detail: 'Greek vartheta', section: 'greek', symbol: 'ϑ' },
- { label: '\\iota', detail: 'Greek iota', section: 'greek', symbol: 'ι' },
- { label: '\\kappa', detail: 'Greek kappa', section: 'greek', symbol: 'κ' },
- { label: '\\lambda', detail: 'Greek lambda', section: 'greek', symbol: 'λ' },
- { label: '\\Lambda', detail: 'Greek Lambda', section: 'greek', symbol: 'Λ' },
- { label: '\\mu', detail: 'Greek mu', section: 'greek', symbol: 'μ' },
- { label: '\\nu', detail: 'Greek nu', section: 'greek', symbol: 'ν' },
- { label: '\\xi', detail: 'Greek xi', section: 'greek', symbol: 'ξ' },
- { label: '\\Xi', detail: 'Greek Xi', section: 'greek', symbol: 'Ξ' },
- { label: '\\pi', detail: 'Greek pi', section: 'greek', symbol: 'π' },
- { label: '\\Pi', detail: 'Greek Pi', section: 'greek', symbol: 'Π' },
- { label: '\\rho', detail: 'Greek rho', section: 'greek', symbol: 'ρ' },
- { label: '\\varrho', detail: 'Greek varrho', section: 'greek', symbol: 'ϱ' },
- { label: '\\sigma', detail: 'Greek sigma', section: 'greek', symbol: 'σ' },
- { label: '\\Sigma', detail: 'Greek Sigma', section: 'greek', symbol: 'Σ' },
- { label: '\\tau', detail: 'Greek tau', section: 'greek', symbol: 'τ' },
- { label: '\\upsilon', detail: 'Greek upsilon', section: 'greek', symbol: 'υ' },
- { label: '\\phi', detail: 'Greek phi', section: 'greek', symbol: 'ϕ' },
- { label: '\\Phi', detail: 'Greek Phi', section: 'greek', symbol: 'Φ' },
- { label: '\\varphi', detail: 'Greek varphi', section: 'greek', symbol: 'φ' },
- { label: '\\chi', detail: 'Greek chi', section: 'greek', symbol: 'χ' },
- { label: '\\psi', detail: 'Greek psi', section: 'greek', symbol: 'ψ' },
- { label: '\\Psi', detail: 'Greek Psi', section: 'greek', symbol: 'Ψ' },
- { label: '\\omega', detail: 'Greek omega', section: 'greek', symbol: 'ω' },
- { label: '\\Omega', detail: 'Greek Omega', section: 'greek', symbol: 'Ω' },
-
- // ── Environments (as commands) ──
- { label: '\\begin', snippet: '\\begin{$1}\n\t$2\n\\end{$1}', detail: 'Begin environment', section: 'env' },
- { label: '\\end', snippet: '\\end{$1}', detail: 'End environment', section: 'env' },
- { label: '\\item', snippet: '\\item $1', detail: 'List item', section: 'env' },
-
- // ── Floats & figures ──
- { label: '\\caption', snippet: '\\caption{$1}', detail: 'Caption', section: 'float' },
- { label: '\\centering', detail: 'Center content', section: 'float' },
- { label: '\\hfill', detail: 'Horizontal fill', section: 'float' },
- { label: '\\vfill', detail: 'Vertical fill', section: 'float' },
- { label: '\\hspace', snippet: '\\hspace{$1}', detail: 'Horizontal space', section: 'float' },
- { label: '\\vspace', snippet: '\\vspace{$1}', detail: 'Vertical space', section: 'float' },
- { label: '\\newline', detail: 'New line', section: 'float' },
- { label: '\\linebreak', detail: 'Line break', section: 'float' },
- { label: '\\pagebreak', detail: 'Page break', section: 'float' },
- { label: '\\newpage', detail: 'New page', section: 'float' },
- { label: '\\clearpage', detail: 'Clear page', section: 'float' },
- { label: '\\noindent', detail: 'No indent', section: 'float' },
-
- // ── Tables ──
- { label: '\\hline', detail: 'Horizontal line', section: 'table' },
- { label: '\\cline', snippet: '\\cline{${1:i}-${2:j}}', detail: 'Partial horizontal line', section: 'table' },
- { label: '\\multicolumn', snippet: '\\multicolumn{${1:cols}}{${2:align}}{${3:text}}', detail: 'Multi column', section: 'table' },
- { label: '\\multirow', snippet: '\\multirow{${1:rows}}{${2:width}}{${3:text}}', detail: 'Multi row', section: 'table' },
- { label: '\\toprule', detail: 'Top rule (booktabs)', section: 'table' },
- { label: '\\midrule', detail: 'Mid rule (booktabs)', section: 'table' },
- { label: '\\bottomrule', detail: 'Bottom rule (booktabs)', section: 'table' },
-
- // ── Miscellaneous ──
- { label: '\\newcommand', snippet: '\\newcommand{\\${1:name}}[${2:args}]{$3}', detail: 'Define command', section: 'misc' },
- { label: '\\renewcommand', snippet: '\\renewcommand{\\${1:name}}[${2:args}]{$3}', detail: 'Redefine command', section: 'misc' },
- { label: '\\newenvironment', snippet: '\\newenvironment{${1:name}}{$2}{$3}', detail: 'Define environment', section: 'misc' },
- { label: '\\def', snippet: '\\def\\${1:name}{$2}', detail: 'TeX definition', section: 'misc' },
- { label: '\\let', snippet: '\\let\\${1:new}\\${2:old}', detail: 'TeX let', section: 'misc' },
- { label: '\\url', snippet: '\\url{$1}', detail: 'URL', section: 'misc' },
- { label: '\\href', snippet: '\\href{${1:url}}{${2:text}}', detail: 'Hyperlink', section: 'misc' },
- { label: '\\hyperref', snippet: '\\hyperref[${1:label}]{${2:text}}', detail: 'Hyperref link', section: 'misc' },
- { label: '\\phantom', snippet: '\\phantom{$1}', detail: 'Invisible space', section: 'misc' },
- { label: '\\mbox', snippet: '\\mbox{$1}', detail: 'Horizontal box', section: 'misc' },
- { label: '\\makebox', snippet: '\\makebox[${1:width}]{$2}', detail: 'Make box', section: 'misc' },
- { label: '\\framebox', snippet: '\\framebox{$1}', detail: 'Framed box', section: 'misc' },
- { label: '\\fbox', snippet: '\\fbox{$1}', detail: 'Framed box', section: 'misc' },
- { label: '\\parbox', snippet: '\\parbox{${1:width}}{$2}', detail: 'Paragraph box', section: 'misc' },
- { label: '\\minipage', snippet: '\\begin{minipage}{${1:width}}\n\t$2\n\\end{minipage}', detail: 'Mini page', section: 'misc' },
-
- // ── Math operators ──
- { label: '\\sin', detail: 'Sine', section: 'mathop' },
- { label: '\\cos', detail: 'Cosine', section: 'mathop' },
- { label: '\\tan', detail: 'Tangent', section: 'mathop' },
- { label: '\\sec', detail: 'Secant', section: 'mathop' },
- { label: '\\csc', detail: 'Cosecant', section: 'mathop' },
- { label: '\\cot', detail: 'Cotangent', section: 'mathop' },
- { label: '\\arcsin', detail: 'Arcsine', section: 'mathop' },
- { label: '\\arccos', detail: 'Arccosine', section: 'mathop' },
- { label: '\\arctan', detail: 'Arctangent', section: 'mathop' },
- { label: '\\sinh', detail: 'Hyperbolic sine', section: 'mathop' },
- { label: '\\cosh', detail: 'Hyperbolic cosine', section: 'mathop' },
- { label: '\\tanh', detail: 'Hyperbolic tangent', section: 'mathop' },
- { label: '\\log', detail: 'Logarithm', section: 'mathop' },
- { label: '\\ln', detail: 'Natural log', section: 'mathop' },
- { label: '\\exp', detail: 'Exponential', section: 'mathop' },
- { label: '\\det', detail: 'Determinant', section: 'mathop' },
- { label: '\\dim', detail: 'Dimension', section: 'mathop' },
- { label: '\\ker', detail: 'Kernel', section: 'mathop' },
- { label: '\\hom', detail: 'Homomorphism', section: 'mathop' },
- { label: '\\deg', detail: 'Degree', section: 'mathop' },
- { label: '\\max', detail: 'Maximum', section: 'mathop' },
- { label: '\\min', detail: 'Minimum', section: 'mathop' },
- { label: '\\sup', detail: 'Supremum', section: 'mathop' },
- { label: '\\inf', detail: 'Infimum', section: 'mathop' },
- { label: '\\arg', detail: 'Argument', section: 'mathop' },
- { label: '\\gcd', detail: 'GCD', section: 'mathop' },
- { label: '\\mod', detail: 'Modulo', section: 'mathop' },
- { label: '\\operatorname', snippet: '\\operatorname{$1}', detail: 'Custom operator', section: 'mathop' },
-
- // ── Math environments shortcuts ──
- { label: '\\[', snippet: '\\[\n\t$1\n\\]', detail: 'Display math', section: 'math' },
- { label: '\\(', snippet: '\\($1\\)', detail: 'Inline math', section: 'math' },
-
- // ── AMS math ──
- { label: '\\align', snippet: '\\begin{align}\n\t$1\n\\end{align}', detail: 'Align environment', section: 'ams' },
- { label: '\\equation', snippet: '\\begin{equation}\n\t$1\n\\end{equation}', detail: 'Equation environment', section: 'ams' },
- { label: '\\gather', snippet: '\\begin{gather}\n\t$1\n\\end{gather}', detail: 'Gather environment', section: 'ams' },
- { label: '\\cases', snippet: '\\begin{cases}\n\t$1\n\\end{cases}', detail: 'Cases', section: 'ams' },
- { label: '\\matrix', snippet: '\\begin{matrix}\n\t$1\n\\end{matrix}', detail: 'Matrix', section: 'ams' },
- { label: '\\pmatrix', snippet: '\\begin{pmatrix}\n\t$1\n\\end{pmatrix}', detail: 'Parenthesized matrix', section: 'ams' },
- { label: '\\bmatrix', snippet: '\\begin{bmatrix}\n\t$1\n\\end{bmatrix}', detail: 'Bracketed matrix', section: 'ams' },
-
- // ── TikZ basics ──
- { label: '\\draw', snippet: '\\draw $1;', detail: 'TikZ draw', section: 'tikz' },
- { label: '\\fill', snippet: '\\fill $1;', detail: 'TikZ fill', section: 'tikz' },
- { label: '\\node', snippet: '\\node[${1:options}] at (${2:0,0}) {$3};', detail: 'TikZ node', section: 'tikz' },
- { label: '\\coordinate', snippet: '\\coordinate (${1:name}) at (${2:0,0});', detail: 'TikZ coordinate', section: 'tikz' },
- { label: '\\path', snippet: '\\path $1;', detail: 'TikZ path', section: 'tikz' },
-
- // ── Theorem-like ──
- { label: '\\newtheorem', snippet: '\\newtheorem{${1:name}}{${2:Theorem}}', detail: 'New theorem', section: 'thm' },
- { label: '\\proof', snippet: '\\begin{proof}\n\t$1\n\\end{proof}', detail: 'Proof environment', section: 'thm' },
-]
diff --git a/src/renderer/src/data/latexEnvironmentTemplates.ts b/src/renderer/src/data/latexEnvironmentTemplates.ts
new file mode 100644
index 0000000..e60d7c0
--- /dev/null
+++ b/src/renderer/src/data/latexEnvironmentTemplates.ts
@@ -0,0 +1,85 @@
+// Ported verbatim from Overleaf (services/web/frontend/js/features/source-editor/
+// languages/latex/completions/data/environments.ts), AGPL-3.0.
+export const snippet = (name: string) => `\\begin{${name}}
+\t$1
+\\end{${name}}`
+
+export const snippetNoIndent = (name: string) => `\\begin{${name}}
+$1
+\\end{${name}}`
+
+export const environments = new Map([
+ ['abstract', snippet('abstract')],
+ ['align', snippet('align')],
+ ['align*', snippet('align*')],
+ [
+ 'array',
+ `\\begin{array}{\${1:cc}}
+\t$2 & $3 \\\\
+\t$4 & $5
+\\end{array}`,
+ ],
+ ['center', snippet('center')],
+ [
+ 'description',
+ `\\begin{description}
+\t\\item[$1] $2
+\\end{description}`,
+ ],
+ ['document', snippetNoIndent('document')],
+ ['equation', snippet('equation')],
+ ['equation*', snippet('equation*')],
+ [
+ 'enumerate',
+ `\\begin{enumerate}
+\t\\item $1
+\\end{enumerate}`,
+ ],
+ [
+ 'figure',
+ `\\begin{figure}
+\t\\centering
+\t\\includegraphics[width=0.5\\linewidth]{$1}
+\t\\caption{\${2:Caption}}
+\t\\label{\${3:fig:placeholder}}
+\\end{figure}`,
+ ],
+ [
+ 'frame',
+ `\\begin{frame}{\${1:Frame Title}}
+\t$2
+\\end{frame}`,
+ ],
+ ['gather', snippet('gather')],
+ ['gather*', snippet('gather*')],
+ [
+ 'itemize',
+ `\\begin{itemize}
+\t\\item $1
+\\end{itemize}`,
+ ],
+ ['multline', snippet('multline')],
+ ['multline*', snippet('multline*')],
+ ['quote', snippet('quote')],
+ ['split', snippet('split')],
+ [
+ 'table',
+ `\\begin{table}[$1]
+\t\\centering
+\t\\begin{tabular}{\${2:c|c}}
+\t\t$3 & $4 \\\\
+\t\t$5 & $6
+\t\\end{tabular}
+\t\\caption{\${7:Caption}}
+\t\\label{\${8:tab:placeholder}}
+\\end{table}`,
+ ],
+ [
+ 'tabular',
+ `\\begin{tabular}{\${1:c|c}}
+\t$2 & $3 \\\\
+\t$4 & $5
+\\end{tabular}`,
+ ],
+ ['verbatim', snippet('verbatim')],
+])
diff --git a/src/renderer/src/data/latexEnvironments.ts b/src/renderer/src/data/latexEnvironments.ts
deleted file mode 100644
index 2838ad5..0000000
--- a/src/renderer/src/data/latexEnvironments.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-// Copyright (c) 2026 Yuren Hao
-// Licensed under AGPL-3.0 - see LICENSE file
-
-// Static LaTeX environment names for \begin{} completion
-// Each entry: [name, detail, snippet body (optional)]
-
-export interface LatexEnvironment {
- name: string
- detail?: string
- body?: string // default body inside the environment (e.g. column spec for tabular)
-}
-
-export const latexEnvironments: LatexEnvironment[] = [
- // ── Document ──
- { name: 'document', detail: 'Main document body' },
-
- // ── Lists ──
- { name: 'itemize', detail: 'Unordered list', body: '\\item $1' },
- { name: 'enumerate', detail: 'Ordered list', body: '\\item $1' },
- { name: 'description', detail: 'Description list', body: '\\item[$1] $2' },
-
- // ── Math ──
- { name: 'equation', detail: 'Numbered equation' },
- { name: 'equation*', detail: 'Unnumbered equation' },
- { name: 'align', detail: 'Aligned equations' },
- { name: 'align*', detail: 'Aligned equations (unnumbered)' },
- { name: 'gather', detail: 'Gathered equations' },
- { name: 'gather*', detail: 'Gathered equations (unnumbered)' },
- { name: 'multline', detail: 'Multi-line equation' },
- { name: 'multline*', detail: 'Multi-line (unnumbered)' },
- { name: 'split', detail: 'Split equation' },
- { name: 'flalign', detail: 'Full-width align' },
- { name: 'flalign*', detail: 'Full-width align (unnumbered)' },
- { name: 'alignat', detail: 'Align at columns' },
- { name: 'alignat*', detail: 'Align at (unnumbered)' },
- { name: 'math', detail: 'Inline math environment' },
- { name: 'displaymath', detail: 'Display math environment' },
- { name: 'cases', detail: 'Piecewise cases' },
-
- // ── Matrices ──
- { name: 'matrix', detail: 'Plain matrix' },
- { name: 'pmatrix', detail: 'Parenthesized matrix' },
- { name: 'bmatrix', detail: 'Bracketed matrix' },
- { name: 'Bmatrix', detail: 'Braced matrix' },
- { name: 'vmatrix', detail: 'Determinant matrix' },
- { name: 'Vmatrix', detail: 'Double-bar matrix' },
- { name: 'smallmatrix', detail: 'Small inline matrix' },
-
- // ── Tables ──
- { name: 'tabular', detail: 'Table', body: '{${1:lll}}\n\\hline\n$2 \\\\\\\\\n\\hline' },
- { name: 'tabular*', detail: 'Table with width' },
- { name: 'tabularx', detail: 'Table with X columns' },
- { name: 'longtable', detail: 'Multi-page table' },
- { name: 'array', detail: 'Math array', body: '{${1:lll}}\n$2' },
-
- // ── Floats ──
- { name: 'figure', detail: 'Figure float', body: '\\centering\n\\includegraphics[width=\\textwidth]{$1}\n\\caption{$2}\n\\label{fig:$3}' },
- { name: 'figure*', detail: 'Full-width figure' },
- { name: 'table', detail: 'Table float', body: '\\centering\n\\caption{$1}\n\\label{tab:$2}\n\\begin{tabular}{${3:lll}}\n\\hline\n$4 \\\\\\\\\n\\hline\n\\end{tabular}' },
- { name: 'table*', detail: 'Full-width table float' },
-
- // ── Text layout ──
- { name: 'center', detail: 'Centered text' },
- { name: 'flushleft', detail: 'Left-aligned text' },
- { name: 'flushright', detail: 'Right-aligned text' },
- { name: 'minipage', detail: 'Mini page', body: '{${1:\\textwidth}}\n$2' },
- { name: 'quote', detail: 'Indented quote' },
- { name: 'quotation', detail: 'Indented quotation' },
- { name: 'verse', detail: 'Verse' },
- { name: 'abstract', detail: 'Abstract' },
- { name: 'verbatim', detail: 'Verbatim text' },
-
- // ── Frames / boxes ──
- { name: 'frame', detail: 'Beamer frame', body: '{${1:Title}}\n$2' },
- { name: 'block', detail: 'Beamer block', body: '{${1:Title}}\n$2' },
- { name: 'columns', detail: 'Beamer columns' },
- { name: 'column', detail: 'Beamer column', body: '{${1:0.5\\textwidth}}\n$2' },
-
- // ── Code / listings ──
- { name: 'lstlisting', detail: 'Code listing' },
- { name: 'minted', detail: 'Minted code', body: '{${1:python}}\n$2' },
-
- // ── TikZ ──
- { name: 'tikzpicture', detail: 'TikZ picture' },
- { name: 'scope', detail: 'TikZ scope' },
-
- // ── Theorem-like ──
- { name: 'theorem', detail: 'Theorem' },
- { name: 'lemma', detail: 'Lemma' },
- { name: 'corollary', detail: 'Corollary' },
- { name: 'proposition', detail: 'Proposition' },
- { name: 'definition', detail: 'Definition' },
- { name: 'example', detail: 'Example' },
- { name: 'remark', detail: 'Remark' },
- { name: 'proof', detail: 'Proof' },
-
- // ── Misc ──
- { name: 'thebibliography', detail: 'Bibliography', body: '{${1:99}}\n\\bibitem{$2} $3' },
- { name: 'appendix', detail: 'Appendix' },
- { name: 'titlepage', detail: 'Title page' },
-]
diff --git a/src/renderer/src/data/latexPackageNames.ts b/src/renderer/src/data/latexPackageNames.ts
new file mode 100644
index 0000000..14e99b4
--- /dev/null
+++ b/src/renderer/src/data/latexPackageNames.ts
@@ -0,0 +1,103 @@
+// Ported verbatim from Overleaf (services/web/frontend/js/features/source-editor/
+// languages/latex/completions/data/package-names.ts), AGPL-3.0.
+export const packageNames: string[] = [
+ 'inputenc',
+ 'graphicx',
+ 'amsmath',
+ 'geometry',
+ 'amssymb',
+ 'hyperref',
+ 'babel',
+ 'color',
+ 'xcolor',
+ 'url',
+ 'natbib',
+ 'fontenc',
+ 'fancyhdr',
+ 'amsfonts',
+ 'booktabs',
+ 'amsthm',
+ 'float',
+ 'tikz',
+ 'caption',
+ 'setspace',
+ 'multirow',
+ 'array',
+ 'multicol',
+ 'titlesec',
+ 'enumitem',
+ 'ifthen',
+ 'listings',
+ 'blindtext',
+ 'subcaption',
+ 'times',
+ 'bm',
+ 'subfigure',
+ 'algorithm',
+ 'fontspec',
+ 'biblatex',
+ 'tabularx',
+ 'microtype',
+ 'etoolbox',
+ 'parskip',
+ 'calc',
+ 'verbatim',
+ 'mathtools',
+ 'epsfig',
+ 'wrapfig',
+ 'lipsum',
+ 'cite',
+ 'textcomp',
+ 'longtable',
+ 'textpos',
+ 'algpseudocode',
+ 'enumerate',
+ 'subfig',
+ 'pdfpages',
+ 'epstopdf',
+ 'latexsym',
+ 'lmodern',
+ 'pifont',
+ 'ragged2e',
+ 'rotating',
+ 'dcolumn',
+ 'xltxtra',
+ 'marvosym',
+ 'indentfirst',
+ 'xspace',
+ 'csquotes',
+ 'xparse',
+ 'changepage',
+ 'soul',
+ 'xunicode',
+ 'comment',
+ 'mathrsfs',
+ 'tocbibind',
+ 'lastpage',
+ 'algorithm2e',
+ 'pgfplots',
+ 'lineno',
+ 'algorithmic',
+ 'fullpage',
+ 'mathptmx',
+ 'todonotes',
+ 'ulem',
+ 'tweaklist',
+ 'moderncvstyleclassic',
+ 'collection',
+ 'moderncvcompatibility',
+ 'gensymb',
+ 'helvet',
+ 'siunitx',
+ 'adjustbox',
+ 'placeins',
+ 'colortbl',
+ 'appendix',
+ 'makeidx',
+ 'supertabular',
+ 'ifpdf',
+ 'framed',
+ 'aliascnt',
+ 'layaureo',
+ 'authblk',
+]
diff --git a/src/renderer/src/data/latexTopHundred.ts b/src/renderer/src/data/latexTopHundred.ts
new file mode 100644
index 0000000..cb91289
--- /dev/null
+++ b/src/renderer/src/data/latexTopHundred.ts
@@ -0,0 +1,701 @@
+// Ported verbatim from Overleaf (services/web/frontend/js/features/source-editor/
+// languages/latex/completions/data/top-hundred-snippets.ts), AGPL-3.0.
+// Snippets use Ace-style $N placeholders, converted at apply time.
+export default [
+ {
+ caption: '\\begin{}',
+ snippet: '\\begin{$1}',
+ meta: 'env',
+ score: 7.849662248028187,
+ },
+ {
+ caption: '\\end{}',
+ snippet: '\\end{$1}',
+ meta: 'env',
+ score: 7.847906405228455,
+ },
+ {
+ caption: '\\usepackage[]{}',
+ snippet: '\\usepackage[$1]{$2}',
+ meta: 'pkg',
+ score: 5.427890758130527,
+ },
+ {
+ caption: '\\item',
+ snippet: '\\item ',
+ meta: 'cmd',
+ score: 3.800886892251021,
+ },
+ {
+ caption: '\\item[]',
+ snippet: '\\item[$1] ',
+ meta: 'cmd',
+ score: 3.800886892251021,
+ },
+ {
+ caption: '\\section{}',
+ snippet: '\\section{$1}',
+ meta: 'cmd',
+ score: 3.0952612541683835,
+ },
+ {
+ caption: '\\textbf{}',
+ snippet: '\\textbf{$1}',
+ meta: 'cmd',
+ score: 2.627755982816738,
+ },
+ {
+ caption: '\\cite{}',
+ snippet: '\\cite{$1}',
+ meta: 'cmd',
+ score: 2.341195220791228,
+ },
+ {
+ caption: '\\label{}',
+ snippet: '\\label{$1}',
+ meta: 'cmd',
+ score: 1.897791904799601,
+ },
+ {
+ caption: '\\textit{}',
+ snippet: '\\textit{$1}',
+ meta: 'cmd',
+ score: 1.6842996195493385,
+ },
+ {
+ caption: '\\includegraphics[]{}',
+ snippet: '\\includegraphics[$1]{$2}',
+ meta: 'cmd',
+ score: 1.4595731795525781,
+ },
+ {
+ caption: '\\documentclass[]{}',
+ snippet: '\\documentclass[$1]{$2}',
+ meta: 'cmd',
+ score: 1.4425339817971206,
+ },
+ {
+ caption: '\\documentclass{}',
+ snippet: '\\documentclass{$1}',
+ meta: 'cmd',
+ score: 1.4425339817971206,
+ },
+ {
+ caption: '\\ref{}',
+ snippet: '\\ref{$1}',
+ meta: 'cross-reference',
+ score: 0.014379554883991673,
+ },
+ {
+ caption: '\\frac{}{}',
+ snippet: '\\frac{$1}{$2}',
+ meta: 'cmd',
+ score: 1.4341091141105058,
+ },
+ {
+ caption: '\\subsection{}',
+ snippet: '\\subsection{$1}',
+ meta: 'cmd',
+ score: 1.3890912739512353,
+ },
+ {
+ caption: '\\hline',
+ snippet: '\\hline',
+ meta: 'cmd',
+ score: 1.3209538327406387,
+ },
+ {
+ caption: '\\caption{}',
+ snippet: '\\caption{$1}',
+ meta: 'cmd',
+ score: 1.2569477427490174,
+ },
+ {
+ caption: '\\centering',
+ snippet: '\\centering',
+ meta: 'cmd',
+ score: 1.1642881814937829,
+ },
+ {
+ caption: '\\vspace{}',
+ snippet: '\\vspace{$1}',
+ meta: 'cmd',
+ score: 0.9533807826673939,
+ },
+ {
+ caption: '\\title{}',
+ snippet: '\\title{$1}',
+ meta: 'cmd',
+ score: 0.9202908262245683,
+ },
+ {
+ caption: '\\author{}',
+ snippet: '\\author{$1}',
+ meta: 'cmd',
+ score: 0.8973590434087177,
+ },
+ {
+ caption: '\\author[]{}',
+ snippet: '\\author[$1]{$2}',
+ meta: 'cmd',
+ score: 0.8973590434087177,
+ },
+ {
+ caption: '\\maketitle',
+ snippet: '\\maketitle',
+ meta: 'cmd',
+ score: 0.7504160124360846,
+ },
+ {
+ caption: '\\textwidth',
+ snippet: '\\textwidth',
+ meta: 'cmd',
+ score: 0.7355328080889112,
+ },
+ {
+ caption: '\\newcommand{}{}',
+ snippet: '\\newcommand{$1}{$2}',
+ meta: 'cmd',
+ score: 0.7264891987129375,
+ },
+ {
+ caption: '\\newcommand{}[]{}',
+ snippet: '\\newcommand{$1}[$2]{$3}',
+ meta: 'cmd',
+ score: 0.7264891987129375,
+ },
+ {
+ caption: '\\date{}',
+ snippet: '\\date{$1}',
+ meta: 'cmd',
+ score: 0.7225518453076786,
+ },
+ {
+ caption: '\\emph{}',
+ snippet: '\\emph{$1}',
+ meta: 'cmd',
+ score: 0.7060308784832261,
+ },
+ {
+ caption: '\\textsc{}',
+ snippet: '\\textsc{$1}',
+ meta: 'cmd',
+ score: 0.6926466355384758,
+ },
+ {
+ caption: '\\multicolumn{}{}{}',
+ snippet: '\\multicolumn{$1}{$2}{$3}',
+ meta: 'cmd',
+ score: 0.5473606021405326,
+ },
+ {
+ caption: '\\input{}',
+ snippet: '\\input{$1}',
+ meta: 'cmd',
+ score: 0.4966021927742672,
+ },
+ {
+ caption: '\\alpha',
+ snippet: '\\alpha',
+ meta: 'cmd',
+ score: 0.49520006391384913,
+ },
+ {
+ caption: '\\in',
+ snippet: '\\in',
+ meta: 'cmd',
+ score: 0.4716039670146658,
+ },
+ {
+ caption: '\\mathbf{}',
+ snippet: '\\mathbf{$1}',
+ meta: 'cmd',
+ score: 0.4682018419466319,
+ },
+ {
+ caption: '\\right',
+ snippet: '\\right',
+ meta: 'cmd',
+ score: 0.4299239459457309,
+ },
+ {
+ caption: '\\left',
+ snippet: '\\left',
+ meta: 'cmd',
+ score: 0.42937815279867964,
+ },
+ {
+ caption: '\\sum',
+ snippet: '\\sum',
+ meta: 'cmd',
+ score: 0.42607994509619934,
+ },
+ {
+ caption: '\\chapter{}',
+ snippet: '\\chapter{$1}',
+ meta: 'cmd',
+ score: 0.422097569591803,
+ },
+ {
+ caption: '\\par',
+ snippet: '\\par',
+ meta: 'cmd',
+ score: 0.413853376001159,
+ },
+ {
+ caption: '\\lambda',
+ snippet: '\\lambda',
+ meta: 'cmd',
+ score: 0.39389600578684125,
+ },
+ {
+ caption: '\\subsubsection{}',
+ snippet: '\\subsubsection{$1}',
+ meta: 'cmd',
+ score: 0.3727781330132016,
+ },
+ {
+ caption: '\\bibitem{}',
+ snippet: '\\bibitem{$1}',
+ meta: 'cmd',
+ score: 0.3689547570562042,
+ },
+ {
+ caption: '\\bibitem[]{}',
+ snippet: '\\bibitem[$1]{$2}',
+ meta: 'cmd',
+ score: 0.3689547570562042,
+ },
+ {
+ caption: '\\text{}',
+ snippet: '\\text{$1}',
+ meta: 'cmd',
+ score: 0.3608680734736821,
+ },
+ {
+ caption: '\\setlength{}{}',
+ snippet: '\\setlength{$1}{$2}',
+ meta: 'cmd',
+ score: 0.354445763583904,
+ },
+ {
+ caption: '\\mathcal{}',
+ snippet: '\\mathcal{$1}',
+ meta: 'cmd',
+ score: 0.35084018920966636,
+ },
+ {
+ caption: '\\newpage',
+ snippet: '\\newpage',
+ meta: 'cmd',
+ score: 0.3277033727934986,
+ },
+ {
+ caption: '\\renewcommand{}{}',
+ snippet: '\\renewcommand{$1}{$2}',
+ meta: 'cmd',
+ score: 0.3267437011085663,
+ },
+ {
+ caption: '\\theta',
+ snippet: '\\theta',
+ meta: 'cmd',
+ score: 0.3210417159232142,
+ },
+ {
+ caption: '\\hspace{}',
+ snippet: '\\hspace{$1}',
+ meta: 'cmd',
+ score: 0.3147206476372336,
+ },
+ {
+ caption: '\\beta',
+ snippet: '\\beta',
+ meta: 'cmd',
+ score: 0.3061799530337638,
+ },
+ {
+ caption: '\\texttt{}',
+ snippet: '\\texttt{$1}',
+ meta: 'cmd',
+ score: 0.3019066753744355,
+ },
+ {
+ caption: '\\times',
+ snippet: '\\times',
+ meta: 'cmd',
+ score: 0.2957960629411553,
+ },
+ {
+ caption: '\\color{}',
+ snippet: '\\color{$1}',
+ meta: 'cmd',
+ score: 0.2864294797053033,
+ },
+ {
+ caption: '\\mu',
+ snippet: '\\mu',
+ meta: 'cmd',
+ score: 0.27635652476799255,
+ },
+ {
+ caption: '\\bibliography{}',
+ snippet: '\\bibliography{$1}',
+ meta: 'cmd',
+ score: 0.2659628337907604,
+ },
+ {
+ caption: '\\linewidth',
+ snippet: '\\linewidth',
+ meta: 'cmd',
+ score: 0.2639498312518439,
+ },
+ {
+ caption: '\\delta',
+ snippet: '\\delta',
+ meta: 'cmd',
+ score: 0.2620578600722735,
+ },
+ {
+ caption: '\\sigma',
+ snippet: '\\sigma',
+ meta: 'cmd',
+ score: 0.25940147926344487,
+ },
+ {
+ caption: '\\pi',
+ snippet: '\\pi',
+ meta: 'cmd',
+ score: 0.25920934567729714,
+ },
+ {
+ caption: '\\hat{}',
+ snippet: '\\hat{$1}',
+ meta: 'cmd',
+ score: 0.25264309033778715,
+ },
+ {
+ caption: '\\bibliographystyle{}',
+ snippet: '\\bibliographystyle{$1}',
+ meta: 'cmd',
+ score: 0.25122317941387773,
+ },
+ {
+ caption: '\\small',
+ snippet: '\\small',
+ meta: 'cmd',
+ score: 0.2447632045426295,
+ },
+ {
+ caption: '\\LaTeX',
+ snippet: '\\LaTeX',
+ meta: 'cmd',
+ score: 0.2334089308452787,
+ },
+ {
+ caption: '\\cdot',
+ snippet: '\\cdot',
+ meta: 'cmd',
+ score: 0.23029085545522762,
+ },
+ {
+ caption: '\\footnote{}',
+ snippet: '\\footnote{$1}',
+ meta: 'cmd',
+ score: 0.2253056071787701,
+ },
+ {
+ caption: '\\newtheorem{}{}',
+ snippet: '\\newtheorem{$1}{$2}',
+ meta: 'cmd',
+ score: 0.215689795055434,
+ },
+ {
+ caption: '\\Delta',
+ snippet: '\\Delta',
+ meta: 'cmd',
+ score: 0.21386475063892618,
+ },
+ {
+ caption: '\\tau',
+ snippet: '\\tau',
+ meta: 'cmd',
+ score: 0.21236188205859796,
+ },
+ {
+ caption: '\\hfill',
+ snippet: '\\hfill',
+ meta: 'cmd',
+ score: 0.2058248088519886,
+ },
+ {
+ caption: '\\leq',
+ snippet: '\\leq',
+ meta: 'cmd',
+ score: 0.20498894440637172,
+ },
+ {
+ caption: '\\footnotesize',
+ snippet: '\\footnotesize',
+ meta: 'cmd',
+ score: 0.2038592081252624,
+ },
+ {
+ caption: '\\large',
+ snippet: '\\large',
+ meta: 'cmd',
+ score: 0.20377416734108866,
+ },
+ {
+ caption: '\\sqrt{}',
+ snippet: '\\sqrt{$1}',
+ meta: 'cmd',
+ score: 0.20240160977404634,
+ },
+ {
+ caption: '\\epsilon',
+ snippet: '\\epsilon',
+ meta: 'cmd',
+ score: 0.2005136761359043,
+ },
+ {
+ caption: '\\Large',
+ snippet: '\\Large',
+ meta: 'cmd',
+ score: 0.1987771081149759,
+ },
+ {
+ caption: '\\rho',
+ snippet: '\\rho',
+ meta: 'cmd',
+ score: 0.1959287380541684,
+ },
+ {
+ caption: '\\omega',
+ snippet: '\\omega',
+ meta: 'cmd',
+ score: 0.19326783415115262,
+ },
+ {
+ caption: '\\mathrm{}',
+ snippet: '\\mathrm{$1}',
+ meta: 'cmd',
+ score: 0.19117752976172653,
+ },
+ {
+ caption: '\\boldsymbol{}',
+ snippet: '\\boldsymbol{$1}',
+ meta: 'cmd',
+ score: 0.18137737738638837,
+ },
+ {
+ caption: '\\gamma',
+ snippet: '\\gamma',
+ meta: 'cmd',
+ score: 0.17940276535431304,
+ },
+ {
+ caption: '\\clearpage',
+ snippet: '\\clearpage',
+ meta: 'cmd',
+ score: 0.1789117552185788,
+ },
+ {
+ caption: '\\infty',
+ snippet: '\\infty',
+ meta: 'cmd',
+ score: 0.17837290019711305,
+ },
+ {
+ caption: '\\phi',
+ snippet: '\\phi',
+ meta: 'cmd',
+ score: 0.17405809173097808,
+ },
+ {
+ caption: '\\partial',
+ snippet: '\\partial',
+ meta: 'cmd',
+ score: 0.17168102367966637,
+ },
+ {
+ caption: '\\include{}',
+ snippet: '\\include{$1}',
+ meta: 'cmd',
+ score: 0.1547080054979312,
+ },
+ {
+ caption: '\\address{}',
+ snippet: '\\address{$1}',
+ meta: 'cmd',
+ score: 0.1525055392611109,
+ },
+ {
+ caption: '\\quad',
+ snippet: '\\quad',
+ meta: 'cmd',
+ score: 0.15242755832392743,
+ },
+ {
+ caption: '\\paragraph{}',
+ snippet: '\\paragraph{$1}',
+ meta: 'cmd',
+ score: 0.152074250347974,
+ },
+ {
+ caption: '\\subparagraph{}',
+ snippet: '\\subparagraph{$1}',
+ meta: 'cmd',
+ score: 0.13,
+ },
+ {
+ caption: '\\varepsilon',
+ snippet: '\\varepsilon',
+ meta: 'cmd',
+ score: 0.05411564201390573,
+ },
+ {
+ caption: '\\zeta',
+ snippet: '\\zeta',
+ meta: 'cmd',
+ score: 0.023330249803752954,
+ },
+ {
+ caption: '\\eta',
+ snippet: '\\eta',
+ meta: 'cmd',
+ score: 0.11088718379889091,
+ },
+ {
+ caption: '\\vartheta',
+ snippet: '\\vartheta',
+ meta: 'cmd',
+ score: 0.0025822992078068712,
+ },
+ {
+ caption: '\\iota',
+ snippet: '\\iota',
+ meta: 'cmd',
+ score: 0.0024774003791525486,
+ },
+ {
+ caption: '\\kappa',
+ snippet: '\\kappa',
+ meta: 'cmd',
+ score: 0.04887876299369008,
+ },
+ {
+ caption: '\\nu',
+ snippet: '\\nu',
+ meta: 'cmd',
+ score: 0.09206962821059342,
+ },
+ {
+ caption: '\\xi',
+ snippet: '\\xi',
+ meta: 'cmd',
+ score: 0.06496042899265699,
+ },
+ {
+ caption: '\\varpi',
+ snippet: '\\varpi',
+ meta: 'cmd',
+ score: 0.0007039358167790341,
+ },
+ {
+ caption: '\\varrho',
+ snippet: '\\varrho',
+ meta: 'cmd',
+ score: 0.0011279491613898612,
+ },
+ {
+ caption: '\\varsigma',
+ snippet: '\\varsigma',
+ meta: 'cmd',
+ score: 0.0010424880711234978,
+ },
+ {
+ caption: '\\upsilon',
+ snippet: '\\upsilon',
+ meta: 'cmd',
+ score: 0.00420715572598688,
+ },
+ {
+ caption: '\\varphi',
+ snippet: '\\varphi',
+ meta: 'cmd',
+ score: 0.03351251516668212,
+ },
+ {
+ caption: '\\chi',
+ snippet: '\\chi',
+ meta: 'cmd',
+ score: 0.043373492287805675,
+ },
+ {
+ caption: '\\psi',
+ snippet: '\\psi',
+ meta: 'cmd',
+ score: 0.09994508706163642,
+ },
+ {
+ caption: '\\Gamma',
+ snippet: '\\Gamma',
+ meta: 'cmd',
+ score: 0.04801549269801977,
+ },
+ {
+ caption: '\\Theta',
+ snippet: '\\Theta',
+ meta: 'cmd',
+ score: 0.038090902146599444,
+ },
+ {
+ caption: '\\Lambda',
+ snippet: '\\Lambda',
+ meta: 'cmd',
+ score: 0.032206594305977686,
+ },
+ {
+ caption: '\\Xi',
+ snippet: '\\Xi',
+ meta: 'cmd',
+ score: 0.01060997225400494,
+ },
+ {
+ caption: '\\Pi',
+ snippet: '\\Pi',
+ meta: 'cmd',
+ score: 0.021264671817473237,
+ },
+ {
+ caption: '\\Sigma',
+ snippet: '\\Sigma',
+ meta: 'cmd',
+ score: 0.05769642802079917,
+ },
+ {
+ caption: '\\Upsilon',
+ snippet: '\\Upsilon',
+ meta: 'cmd',
+ score: 0.00032875192955749566,
+ },
+ {
+ caption: '\\Phi',
+ snippet: '\\Phi',
+ meta: 'cmd',
+ score: 0.0538724950042562,
+ },
+ {
+ caption: '\\Psi',
+ snippet: '\\Psi',
+ meta: 'cmd',
+ score: 0.03056589143021648,
+ },
+ {
+ caption: '\\Omega',
+ snippet: '\\Omega',
+ meta: 'cmd',
+ score: 0.09490387997853639,
+ },
+]
diff --git a/src/renderer/src/extensions/latexAutocomplete.ts b/src/renderer/src/extensions/latexAutocomplete.ts
index df00bb4..b0ee597 100644
--- a/src/renderer/src/extensions/latexAutocomplete.ts
+++ b/src/renderer/src/extensions/latexAutocomplete.ts
@@ -1,353 +1,886 @@
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
+// LaTeX autocomplete — a port of Overleaf's source-editor completion system
+// (services/web/frontend/js/features/source-editor/languages/latex, AGPL-3.0)
+// onto regex-based context detection (no lezer grammar):
+// - same trigger rules (getCompletionMatches), same argument detection
+// - same snippet data (top-hundred + environment templates, verbatim)
+// - same apply behavior (extendOverUnpairedClosingBrace /
+// extendRequiredParameter brace handling)
+// - project-wide labels/citations/commands via the sync bridge and the
+// official /project/:id/metadata endpoint (package command snippets)
import {
autocompletion,
+ snippet,
+ clearSnippet,
+ startCompletion,
+ closeCompletion,
+ acceptCompletion,
+ moveCompletionSelection,
+ pickedCompletion,
+ type Completion,
type CompletionContext,
type CompletionResult,
- type Completion,
- snippetCompletion,
+ type CompletionSource,
} from '@codemirror/autocomplete'
-import { latexCommands } from '../data/latexCommands'
-import { latexEnvironments } from '../data/latexEnvironments'
+import { EditorView, keymap } from '@codemirror/view'
+import { Prec, type EditorState, type Text } from '@codemirror/state'
+import { remoteUpdateAnnotation } from './otSyncExtension'
+import topHundredSnippets from '../data/latexTopHundred'
+import { packageNames } from '../data/latexPackageNames'
+import { environments as environmentTemplates, snippet as envSnippet } from '../data/latexEnvironmentTemplates'
+import { bibliographyStyles, classNames } from '../data/latexClassesAndStyles'
import { useAppStore } from '../stores/appStore'
-// ── Helpers ──────────────────────────────────────────────────────────
+// ── Project-wide completion data (bridge + official metadata endpoint) ──
+
+interface PackageCommand {
+ caption: string
+ snippet: string
+ meta: string
+ score: number
+}
+
+interface ProjectData {
+ docs: Array<{ path: string; content: string }>
+ serverLabels: Set<string>
+ packageCommands: PackageCommand[]
+ serverPackageNames: Set<string>
+ version: number
+}
+
+const projectData: ProjectData = {
+ docs: [],
+ serverLabels: new Set(),
+ packageCommands: [],
+ serverPackageNames: new Set(),
+ version: 0,
+}
-/** Check if cursor is inside a \begin{...} or \end{...} brace */
-function getEnvironmentContext(context: CompletionContext): { from: number; typed: string } | null {
- const line = context.state.doc.lineAt(context.pos)
- const textBefore = line.text.slice(0, context.pos - line.from)
- const match = textBefore.match(/\\(?:begin|end)\{([^}]*)$/)
- if (match) {
- return { from: context.pos - match[1].length, typed: match[1] }
+let syncTimer: ReturnType<typeof setInterval> | null = null
+let refreshTimer: ReturnType<typeof setTimeout> | null = null
+// Bumped by start/stop; in-flight refreshes from a previous project discard
+// their results instead of overwriting the current project's data.
+let syncGeneration = 0
+let lastDataFingerprint = ''
+
+function fingerprintDocs(docs: Array<{ path: string; content: string }>, metaKey: string): string {
+ let hash = 0
+ for (const { path, content } of docs) {
+ const s = `${path}:${content.length}:${content.slice(0, 64)}:${content.slice(-64)}`
+ for (let i = 0; i < s.length; i++) hash = (Math.imul(31, hash) + s.charCodeAt(i)) | 0
}
- return null
+ return `${docs.length}|${hash}|${metaKey}`
}
-/** Check if cursor is inside a \ref-like{...} brace */
-function getRefContext(context: CompletionContext): { from: number; typed: string } | null {
- const line = context.state.doc.lineAt(context.pos)
- const textBefore = line.text.slice(0, context.pos - line.from)
- const match = textBefore.match(/\\(?:ref|eqref|pageref|autoref|cref|Cref|nameref|vref)\{([^}]*)$/)
- if (match) {
- return { from: context.pos - match[1].length, typed: match[1] }
+async function refreshProjectData(projectId: string): Promise<void> {
+ const generation = syncGeneration
+ try {
+ const [docs, metadata] = await Promise.all([
+ window.api.syncGetAllDocContents(),
+ window.api.overleafGetMetadata(projectId),
+ ])
+ if (generation !== syncGeneration) return // stale — project changed
+
+ let metaKey = ''
+ let labels = projectData.serverLabels
+ let commands = projectData.packageCommands
+ let pkgNames = projectData.serverPackageNames
+ if (metadata.success && metadata.data?.projectMeta) {
+ labels = new Set<string>()
+ commands = []
+ pkgNames = new Set<string>()
+ const seenPkgs = new Set<string>()
+ for (const docMeta of Object.values(metadata.data.projectMeta)) {
+ for (const label of docMeta.labels || []) labels.add(label)
+ for (const name of docMeta.packageNames || []) pkgNames.add(name)
+ for (const [pkg, cmds] of Object.entries(docMeta.packages || {})) {
+ if (seenPkgs.has(pkg)) continue
+ seenPkgs.add(pkg)
+ commands.push(...cmds)
+ }
+ }
+ metaKey = `${labels.size}|${commands.length}|${pkgNames.size}`
+ }
+
+ // Skip the cache-invalidating version bump when nothing changed —
+ // otherwise every 30s tick forces a full project rescan on next popup.
+ const fingerprint = fingerprintDocs(docs, metaKey)
+ projectData.docs = docs
+ projectData.serverLabels = labels
+ projectData.packageCommands = commands
+ projectData.serverPackageNames = pkgNames
+ if (fingerprint !== lastDataFingerprint) {
+ lastDataFingerprint = fingerprint
+ projectData.version++
+ }
+ } catch {
+ // non-fatal — completions fall back to open-file data
}
- return null
}
-/** Check if cursor is inside a \cite-like{...} brace (supports multiple keys: \cite{a,b,...}) */
-function getCiteContext(context: CompletionContext): { from: number; typed: string } | null {
- const line = context.state.doc.lineAt(context.pos)
- const textBefore = line.text.slice(0, context.pos - line.from)
- // Match \cite{key1,key2,partial or \cite[note]{partial or \citep{partial etc.
- const match = textBefore.match(/\\(?:cite|citep|citet|citealt|citealp|citeauthor|citeyear|Cite|parencite|textcite|autocite|fullcite|footcite|nocite)(?:\[[^\]]*\])?\{([^}]*)$/)
- if (match) {
- const inside = match[1]
- // Find the last comma to support multi-key citations
- const lastComma = inside.lastIndexOf(',')
- const typed = lastComma >= 0 ? inside.slice(lastComma + 1).trimStart() : inside
- const from = lastComma >= 0
- ? context.pos - inside.length + lastComma + 1 + (inside.slice(lastComma + 1).length - inside.slice(lastComma + 1).trimStart().length)
- : context.pos - inside.length
- return { from, typed }
+/** Start background refresh of project-wide completion data */
+export function startAutocompleteSync(projectId: string): void {
+ stopAutocompleteSync()
+ refreshProjectData(projectId)
+ syncTimer = setInterval(() => refreshProjectData(projectId), 30_000)
+}
+
+export function stopAutocompleteSync(): void {
+ syncGeneration++
+ if (syncTimer) { clearInterval(syncTimer); syncTimer = null }
+ if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null }
+ projectData.docs = []
+ projectData.serverLabels = new Set()
+ projectData.packageCommands = []
+ projectData.serverPackageNames = new Set()
+ lastDataFingerprint = ''
+ projectData.version++
+}
+
+/** Debounced refresh — call after entity changes / external edits */
+export function scheduleAutocompleteRefresh(projectId: string): void {
+ if (refreshTimer) clearTimeout(refreshTimer)
+ refreshTimer = setTimeout(() => refreshProjectData(projectId), 2000)
+}
+
+/** All doc contents: bridge data plus any newer open-editor contents */
+function allDocContents(): Array<{ path: string; content: string }> {
+ const { fileContents } = useAppStore.getState()
+ const merged = new Map<string, string>()
+ for (const { path, content } of projectData.docs) merged.set(path, content)
+ for (const [path, content] of Object.entries(fileContents)) merged.set(path, content)
+ return Array.from(merged.entries()).map(([path, content]) => ({ path, content }))
+}
+
+// ── Snippet template handling (port of snippets.ts) ─────────────────
+
+// Convert Ace-style `$1` placeholders to CM `#{1}` and add a final
+// tab-stop so Shift-Tab from the last field works.
+const prepareSnippetTemplate = (template: string): string =>
+ template.replace(/\$(\d+)/g, '#{$1}') + '${}'
+
+const nextChar = (doc: Text, pos: number): string => doc.sliceString(pos, pos + 1)
+
+// Count unclosed opening braces on the line up to `from`, minus closing
+// braces after `to` (port of apply.ts countUnclosedBraces, with escaped
+// braces `\{`/`\}` stripped first so they don't count as structural).
+const countUnclosedBraces = (doc: Text, from: number, to: number): number => {
+ const line = doc.lineAt(from)
+ const textBefore = doc.sliceString(line.from, from).replace(/\\[{}]/g, '')
+ const textAfter = doc.sliceString(to, line.to)
+ const textAfterMatch = textAfter.match(/^[^\\]*/)
+ const openBraces =
+ (textBefore.match(/\{/g) || []).length - (textBefore.match(/}/g) || []).length
+ const closedBraces = textAfterMatch
+ ? (textAfterMatch[0].match(/}/g) || []).length - (textAfterMatch[0].match(/\{/g) || []).length
+ : 0
+ return openBraces - closedBraces
+}
+
+// Port of extendOverUnpairedClosingBrace: swallow a stray `}` directly
+// after the completed range when the line has an unpaired closing brace.
+const extendedTo = (state: EditorState, from: number, to: number): number => {
+ if (nextChar(state.doc, to) === '}') {
+ if (countUnclosedBraces(state.doc, from, to) < 0) return to + 1
}
- return null
+ return to
}
-/** Check if cursor is inside a file-include command brace */
-function getFileContext(context: CompletionContext): { from: number; typed: string; isGraphics: boolean } | null {
- const line = context.state.doc.lineAt(context.pos)
- const textBefore = line.text.slice(0, context.pos - line.from)
- const match = textBefore.match(/\\(input|include|includegraphics|subfile|subfileinclude)(?:\[[^\]]*\])?\{([^}]*)$/)
- if (match) {
- const isGraphics = match[1] === 'includegraphics'
- return { from: context.pos - match[2].length, typed: match[2], isGraphics }
+/** Apply a snippet template with Overleaf's brace-swallowing behavior */
+const applySnippet = (template: string, clear = false) => {
+ return (view: EditorView, completion: Completion, from: number, to: number) => {
+ const end = extendedTo(view.state, from, to)
+ snippet(prepareSnippetTemplate(template))(view, completion, from, end)
+ if (clear) clearSnippet(view)
}
- return null
}
-// ── Scan documents for labels ────────────────────────────────────────
+const longestCommonPrefix = (...strs: string[]): number => {
+ if (strs.length === 0) return 0
+ const minLength = Math.min(...strs.map((str) => str.length))
+ let prefixLength = 0
+ for (; prefixLength < minLength; prefixLength++) {
+ const char = strs[0][prefixLength]
+ if (!strs.every((str) => str[prefixLength] === char)) break
+ }
+ return prefixLength
+}
-function scanLabels(): string[] {
- const { fileContents } = useAppStore.getState()
- const labels = new Set<string>()
- const labelRegex = /\\label\{([^}]+)\}/g
- for (const content of Object.values(fileContents)) {
- let m: RegExpExecArray | null
- while ((m = labelRegex.exec(content)) !== null) {
- labels.add(m[1])
+// Port of extendRequiredParameter: insert a parameter value, reusing or
+// adding the closing brace and replacing any partially-typed key.
+const applyParameter = (view: EditorView, completion: Completion, from: number, to: number) => {
+ const state = view.state
+ const doc = state.doc
+ let insert = completion.label
+ let end = to
+
+ if (nextChar(doc, end) === '}') {
+ // include the existing closing brace, so the cursor moves after it
+ insert += '}'
+ end++
+ } else {
+ if (countUnclosedBraces(doc, from, end) > 0) {
+ insert += '}'
+ }
+ const line = doc.lineAt(from)
+ const rest = doc.sliceString(end, line.to)
+ const closeIdx = rest.indexOf('}')
+ if (closeIdx !== -1) {
+ // well-formed argument — replace subsequent text that isn't a
+ // brace, space, or comma
+ const match = rest.slice(0, closeIdx).match(/^[^}\s,]+/)
+ if (match) end += match[0].length
+ } else {
+ // don't swallow a closing brace from unrelated text
+ const restOfLine = doc.sliceString(end, Math.min(line.to, from + insert.length)).split('}')[0]
+ end += longestCommonPrefix(insert.slice(end - from), restOfLine)
}
}
- return Array.from(labels)
+
+ view.dispatch({
+ changes: { from, to: end, insert },
+ selection: { anchor: from + insert.length },
+ userEvent: 'input.complete',
+ annotations: pickedCompletion.of(completion),
+ })
}
-// ── Scan .bib files for citation keys ────────────────────────────────
+// ── Command classification (port of lezer-latex tokens.mjs lists) ────
+
+const REF_COMMANDS = new Set([
+ 'fullref', 'Vref', 'autopageref', 'autoref', 'eqref', 'labelcpageref',
+ 'labelcref', 'lcnamecref', 'lcnamecrefs', 'namecref', 'nameCref',
+ 'namecrefs', 'nameCrefs', 'thnameref', 'thref', 'titleref', 'vrefrange',
+ 'Crefrange', 'Crefrang', 'fref', 'pref', 'tref', 'Aref', 'Bref', 'Pref',
+ 'Sref', 'vref', 'nameref',
+ 'vpageref', 'zcpageref', 'zcref', 'zfullref', 'zref', 'zvpageref',
+ 'zvref', 'cref', 'Cref', 'pageref', 'ref', 'Ref', 'subref', 'zpageref',
+ 'ztitleref', 'vpagerefrange', 'zvpagerefrange', 'zvrefrange', 'crefrange',
+])
+
+const isCiteCommand = (name: string) => name.toLowerCase().includes('cite')
+const isRefCommand = (name: string) => REF_COMMANDS.has(name.replace(/\*$/, ''))
+const INPUT_COMMANDS = new Set(['input', 'include', 'subfile', 'subfileinclude'])
+const PACKAGE_COMMANDS = new Set(['usepackage', 'RequirePackage'])
+const BIBLIOGRAPHY_COMMANDS = new Set(['bibliography', 'addbibresource'])
+
+// ── Trigger detection (port of complete.ts getCompletionMatches) ─────
+
+interface CompletionMatches {
+ match: RegExpMatchArray | null
+ matchBefore: { from: number; to: number; text: string }
+}
-function scanCitations(): { key: string; type: string; title?: string }[] {
- const { fileContents } = useAppStore.getState()
- const entries: { key: string; type: string; title?: string }[] = []
+// Match `\command[opt]{existingKey1, existingKey2, prefix` before the cursor.
+// Differs from Overleaf's original in the `existing` group: keys are matched
+// as `[^},]+` (a key cannot contain the comma separator) instead of `[^}]+`,
+// which removes the exponential backtracking the original exhibits on lines
+// like `\cite{k1, k2, …, k15} tail` when the overall match must fail.
+const multipleArgumentMatcher =
+ /^(?<before>\\(?<command>\w+)\*?(?<arguments>(\[[^\]]*?]|\{[^}]*?})+)?{)(?<existing>(?:[^},]+,\s*)+)?(?<prefix>[^},]+)?$/
+
+function getCompletionMatches(context: CompletionContext): CompletionMatches | null {
+ const matchBefore = context.explicit
+ ? context.matchBefore(/(?:^|\\)[^\\]*(\[[^\]]*])?[^\\]*/)
+ : context.matchBefore(/\\?\\[^\\]*(\[[^\]]*])?[^\\]*/)
+
+ if (!matchBefore) return null
+
+ if (!context.explicit) {
+ // \\ is a line break, not a command prefix
+ if (/\\\\$/.test(matchBefore.text)) return null
+ // trailing whitespace ends the command, unless after a comma
+ if (/[^,\s]\s+$/.test(matchBefore.text)) return null
+ }
+
+ const match = matchBefore.text.match(multipleArgumentMatcher)
+ return { match, matchBefore }
+}
+
+interface ArgumentDetails {
+ command: string
+ from: number
+ validFor: RegExp
+ existingKeys: string[]
+ matchBefore: { from: number; to: number; text: string }
+}
+
+function getArgumentDetails(context: CompletionContext): ArgumentDetails | null {
+ const matches = getCompletionMatches(context)
+ if (!matches?.match?.groups) return null
+ const { match, matchBefore } = matches
+ const groups = match.groups as {
+ before: string; command: string; existing?: string; prefix?: string
+ }
+ const existing = groups.existing ?? ''
+ return {
+ command: groups.command,
+ from: matchBefore.from + groups.before.length + existing.length,
+ // Excludes ',' so typing a comma invalidates the result and the source
+ // re-runs with a fresh `from` after the separator (multi-key support).
+ validFor: /[^}\s,]*/,
+ existingKeys: existing.split(',').map((k) => k.trim()).filter(Boolean),
+ matchBefore,
+ }
+}
+
+// ── Document scans (regex ports of doc-commands / doc-environments) ──
+//
+// Full-project scans are memoized for a short TTL (and invalidated by data
+// refreshes) so opening the popup doesn't rescan every doc on each trigger.
+
+function memoScan<T>(compute: () => T, ttlMs = 3000): () => T {
+ let cached: T | undefined
+ let cachedVersion = -1
+ let expires = 0
+ return () => {
+ const now = Date.now()
+ if (cached !== undefined && cachedVersion === projectData.version && now < expires) {
+ return cached
+ }
+ cached = compute()
+ cachedVersion = projectData.version
+ expires = now + ttlMs
+ return cached
+ }
+}
+
+const scanLabels = memoScan(scanLabelsUncached)
+const scanCitationKeys = memoScan(scanCitationKeysUncached)
+const scanDocEnvironments = memoScan(scanDocEnvironmentsUncached)
+
+function scanLabelsUncached(): Set<string> {
+ const labels = new Set<string>(projectData.serverLabels)
+ const labelRe = /\\(?:label|thlabel|zlabel)\{([^}]{1,80})\}/g
+ const labelOptRe = /\blabel=\{?(.{1,80}?)[\s},\]]/g
+ for (const { content } of allDocContents()) {
+ let m: RegExpExecArray | null
+ while ((m = labelRe.exec(content)) !== null) labels.add(m[1])
+ while ((m = labelOptRe.exec(content)) !== null) labels.add(m[1])
+ }
+ return labels
+}
+
+function scanCitationKeysUncached(): Array<{ key: string; type: string; title?: string }> {
+ const entries: Array<{ key: string; type: string; title?: string }> = []
const seen = new Set<string>()
- for (const [path, content] of Object.entries(fileContents)) {
+ for (const { path, content } of allDocContents()) {
if (!path.endsWith('.bib')) continue
- // Match @type{key, patterns
- const entryRegex = /@(\w+)\s*\{([^,\s]+)/g
+ const entryRe = /@(\w+)\s*\{\s*([^,\s}]+)/g
let m: RegExpExecArray | null
- while ((m = entryRegex.exec(content)) !== null) {
+ while ((m = entryRe.exec(content)) !== null) {
const type = m[1].toLowerCase()
if (type === 'string' || type === 'comment' || type === 'preamble') continue
const key = m[2].trim()
- if (!seen.has(key)) {
- seen.add(key)
- // Try to extract title
- const afterKey = content.slice(m.index)
- const titleMatch = afterKey.match(/title\s*=\s*[{"]([^}"]+)/i)
- entries.push({ key, type, title: titleMatch?.[1] })
- }
+ if (seen.has(key)) continue
+ seen.add(key)
+ const afterKey = content.slice(m.index, m.index + 2000)
+ const titleMatch = afterKey.match(/\btitle\s*=\s*[{"]+([^}"]+)/i)
+ entries.push({ key, type, title: titleMatch?.[1] })
}
}
return entries
}
-// ── Get file paths from project tree ─────────────────────────────────
+function scanExistingPackages(context: CompletionContext): Set<string> {
+ const names = new Set<string>()
+ const re = /\\usepackage(?:\[.*?])?\{(\w+)\}/g
+ const { activeTab } = useAppStore.getState()
+ // Scan every doc EXCEPT the one being edited — its store copy may still
+ // contain the package name on the very line the user is retyping.
+ for (const { path, content } of allDocContents()) {
+ if (path === activeTab) continue
+ let m: RegExpExecArray | null
+ while ((m = re.exec(content)) !== null) names.add(m[1])
+ }
+ // For the active doc, use the live buffer and skip the line being typed
+ const doc = context.state.doc
+ const cursorLine = context.state.doc.lineAt(context.pos).number
+ for (let i = 1; i <= doc.lines; i++) {
+ if (i === cursorLine) continue
+ const line = doc.line(i)
+ let m: RegExpExecArray | null
+ const lineRe = /\\usepackage(?:\[.*?])?\{(\w+)\}/g
+ while ((m = lineRe.exec(line.text)) !== null) names.add(m[1])
+ }
+ return names
+}
+
+interface DocCommand {
+ title: string
+ optionalArgCount: number
+ requiredArgCount: number
+ count: number
+}
-function getFilePaths(isGraphics: boolean): string[] {
- const { files } = useAppStore.getState()
- const paths: string[] = []
+function scanDocCommands(): DocCommand[] {
+ const commands = new Map<string, DocCommand>()
+
+ const record = (name: string, optional: number, required: number) => {
+ const existing = commands.get(name)
+ if (existing) {
+ existing.count++
+ existing.optionalArgCount = Math.max(existing.optionalArgCount, optional)
+ existing.requiredArgCount = Math.max(existing.requiredArgCount, required)
+ } else {
+ commands.set(name, { title: `\\${name}`, optionalArgCount: optional, requiredArgCount: required, count: 1 })
+ }
+ }
+
+ for (const { path, content } of allDocContents()) {
+ if (!/\.(tex|sty|cls|ltx|tikz)$/i.test(path) && path.includes('.')) continue
+
+ // Definitions: \newcommand{\name}[n][default]{...}
+ const defRe = /\\(?:re)?newcommand\*?\s*\{?\\(\w+)\}?((?:\[[^\]]*\])*)/g
+ let m: RegExpExecArray | null
+ while ((m = defRe.exec(content)) !== null) {
+ const name = m[1]
+ const argSpecs = (m[2] || '').match(/\[[^\]]*\]/g) || []
+ const total = argSpecs.length > 0 ? parseInt(argSpecs[0]!.slice(1, -1), 10) || 0 : 0
+ const hasOptional = argSpecs.length > 1
+ const optional = hasOptional ? 1 : 0
+ const required = Math.max(0, total - optional)
+ record(name, optional, required)
+ }
+
+ // Usages: \name[opt]{req} — records commands seen anywhere in the project
+ const useRe = /\\([a-zA-Z]+)((?:\[[^\]\n]*\]|\{[^}\n]*\})*)/g
+ while ((m = useRe.exec(content)) !== null) {
+ const name = m[1]
+ const args = m[2] || ''
+ const optional = (args.match(/\[/g) || []).length
+ const required = (args.match(/\{/g) || []).length
+ record(name, optional, required)
+ }
+ }
+
+ return Array.from(commands.values())
+}
+
+function scanDocEnvironmentsUncached(): Map<string, number> {
+ const envs = new Map<string, number>()
+ const re = /\\(?:begin|newenvironment\*?\s*\{|newtheorem\*?\s*\{)\{?([^}]+)\}/g
+ for (const { content } of allDocContents()) {
+ let m: RegExpExecArray | null
+ while ((m = re.exec(content)) !== null) {
+ const name = m[1]
+ envs.set(name, (envs.get(name) || 0) + 1)
+ }
+ }
+ return envs
+}
- const imageExts = new Set(['.png', '.jpg', '.jpeg', '.pdf', '.eps', '.svg', '.gif', '.bmp', '.tiff'])
- const texExts = new Set(['.tex', '.sty', '.cls', '.bib', '.bbl'])
+// ── Command list assembly (cached per data version) ──────────────────
- function walk(nodes: typeof files, prefix: string) {
+let cachedCommands: Completion[] | null = null
+let cachedCommandsVersion = -1
+let cachedFilesKey = ''
+
+const IMAGE_RE = /\.(eps|jpe?g|gif|png|tiff?|pdf|svg)$/i
+
+function walkFileTree(): { texPaths: string[]; imagePaths: string[]; bibPaths: string[] } {
+ const { files } = useAppStore.getState()
+ const texPaths: string[] = []
+ const imagePaths: string[] = []
+ const bibPaths: string[] = []
+ const walk = (nodes: typeof files, prefix: string) => {
for (const node of nodes) {
+ const full = prefix ? `${prefix}/${node.name}` : node.name
if (node.isDir) {
- if (node.children) walk(node.children, prefix ? prefix + '/' + node.name : node.name)
- } else {
- const fullPath = prefix ? prefix + '/' + node.name : node.name
- if (isGraphics) {
- const ext = '.' + node.name.split('.').pop()?.toLowerCase()
- if (imageExts.has(ext)) {
- // For graphics, also offer path without extension
- paths.push(fullPath)
- const noExt = fullPath.replace(/\.[^.]+$/, '')
- if (noExt !== fullPath) paths.push(noExt)
- }
- } else {
- const ext = '.' + node.name.split('.').pop()?.toLowerCase()
- if (texExts.has(ext)) {
- paths.push(fullPath)
- // Also offer without .tex extension (common for \input)
- if (ext === '.tex') {
- paths.push(fullPath.replace(/\.tex$/, ''))
- }
- }
- }
+ if (node.children) walk(node.children, full)
+ } else if (/\.(tex|txt)$/i.test(node.name)) {
+ texPaths.push(full)
+ } else if (IMAGE_RE.test(node.name)) {
+ imagePaths.push(full)
+ } else if (/\.bib$/i.test(node.name)) {
+ bibPaths.push(full)
}
}
}
walk(files, '')
- return paths
-}
-
-// ── Completion Sources ───────────────────────────────────────────────
-
-/** Source 1: LaTeX commands — triggered by \ */
-function commandSource(context: CompletionContext): CompletionResult | null {
- // Don't complete inside \begin{} or \end{} braces
- const envCtx = getEnvironmentContext(context)
- if (envCtx) return null
-
- // Don't complete inside \ref{}, \cite{}, etc.
- const refCtx = getRefContext(context)
- if (refCtx) return null
- const citeCtx = getCiteContext(context)
- if (citeCtx) return null
- const fileCtx = getFileContext(context)
- if (fileCtx) return null
-
- // Match \word at cursor
- const word = context.matchBefore(/\\[a-zA-Z*]*/)
- if (!word) return null
- // Need at least \ + 1 char, or explicit activation
- if (word.text.length < 2 && !context.explicit) return null
-
- const options: Completion[] = latexCommands.map((cmd) => {
- const detail = cmd.symbol ? `${cmd.symbol} ${cmd.detail || ''}` : cmd.detail
- if (cmd.snippet) {
- return snippetCompletion(cmd.snippet, {
- label: cmd.label,
- detail,
- type: 'function',
- boost: cmd.section === 'structure' || cmd.section === 'sectioning' ? 2 : 0,
- })
- }
- return {
- label: cmd.label,
- detail,
- type: 'function',
+ return { texPaths, imagePaths, bibPaths }
+}
+
+/** Deduplicate by label — prefer entries with apply, then higher boost */
+function dedupeByLabel(options: Completion[]): Completion[] {
+ const byLabel = new Map<string, Completion>()
+ for (const option of options) {
+ const existing = byLabel.get(option.label)
+ if (!existing) {
+ byLabel.set(option.label, option)
+ continue
}
+ const score = (c: Completion) => ((c.boost || 0) * 100) + (c.apply ? 10 : 0) + (c.info ? 5 : 0) + (c.type ? 1 : 0)
+ if (score(option) > score(existing)) byLabel.set(option.label, option)
+ }
+ return Array.from(byLabel.values())
+}
+
+function buildCommandCompletions(): Completion[] {
+ const { texPaths, imagePaths } = walkFileTree()
+ const filesKey = texPaths.join('|') + '#' + imagePaths.join('|')
+ if (cachedCommands && cachedCommandsVersion === projectData.version && cachedFilesKey === filesKey) {
+ return cachedCommands
+ }
+
+ const options: Completion[] = []
+
+ // 1. Static top-hundred snippets (official usage-scored data)
+ for (const item of topHundredSnippets) {
+ options.push({
+ type: item.meta,
+ label: item.caption,
+ boost: item.score,
+ apply: item.snippet === item.caption ? undefined : applySnippet(item.snippet),
+ })
+ }
+ options.push({ type: 'cmd', label: '\\verb||', apply: applySnippet('\\verb|#{}|') })
+
+ // 2. Environments as whole begin/end snippets
+ for (const [name, template] of environmentTemplates) {
+ const clear = name === 'abstract' || name === 'itemize' || name === 'enumerate'
+ options.push({
+ type: 'env',
+ label: `\\begin{${name}} …`,
+ apply: applySnippet(template, clear),
+ })
+ }
+
+ // 3. \usepackage — boosted empty snippet + one entry per unused package
+ options.push({
+ type: 'pkg',
+ label: '\\usepackage{}',
+ boost: 10,
+ apply: applySnippet('\\usepackage{#{}}'),
})
+ for (const name of packageNames) {
+ options.push({ type: 'pkg', label: `\\usepackage{${name}}` })
+ }
- return {
- from: word.from,
- options,
- validFor: /^\\[a-zA-Z*]*$/,
+ // 4. Commands provided by used packages (official metadata endpoint)
+ for (const command of projectData.packageCommands) {
+ options.push({
+ type: command.meta,
+ label: command.caption,
+ apply: command.snippet === command.caption ? undefined : applySnippet(command.snippet),
+ })
}
-}
-/** Source 2: Environment names inside \begin{} and \end{} */
-function environmentSource(context: CompletionContext): CompletionResult | null {
- const envCtx = getEnvironmentContext(context)
- if (!envCtx) return null
+ // 5. File-based include commands
+ for (const path of texPaths) {
+ const stripped = path.replace(/\.tex$/i, '')
+ options.push({ type: 'cmd', label: `\\input{${stripped}}` })
+ options.push({ type: 'cmd', label: `\\include{${stripped}}` })
+ }
+ for (const path of imagePaths) {
+ options.push({
+ type: 'cmd',
+ label: `\\includegraphics{${path}}`,
+ apply: applySnippet(`\\includegraphics[width=0.5\\linewidth]{${path.replace(/([\\{}$])/g, '\\$1')}}`),
+ })
+ }
- // For \end{}, try to match the most recent unclosed \begin{}
- const line = context.state.doc.lineAt(context.pos)
- const textBefore = line.text.slice(0, context.pos - line.from)
- const isEnd = /\\end\{[^}]*$/.test(textBefore)
+ // 6. Commands seen in the project (definitions + usages)
+ const staticPrefixes = new Set<string>()
+ for (const option of options) {
+ const m = option.label.match(/^\\\w+/)
+ if (m) staticPrefixes.add(m[0])
+ }
+ for (const item of scanDocCommands()) {
+ if (staticPrefixes.has(item.title)) continue
+ const label = [item.title, '[]'.repeat(item.optionalArgCount), '{}'.repeat(item.requiredArgCount)].join('')
+ const snippetStr = [
+ item.title,
+ ...Array.from({ length: item.optionalArgCount }, () => '[#{}]'),
+ ...Array.from({ length: item.requiredArgCount }, () => '{#{}}'),
+ ].join('')
+ options.push({
+ type: 'cmd',
+ label,
+ boost: Math.max(0, item.count - 10),
+ apply: label === item.title ? undefined : applySnippet(snippetStr),
+ })
+ }
- const options: Completion[] = []
+ cachedCommands = dedupeByLabel(options)
+ cachedCommandsVersion = projectData.version
+ cachedFilesKey = filesKey
+ return cachedCommands
+}
- if (isEnd) {
- // Find the most recent unclosed \begin{} and suggest it first
- const docText = context.state.doc.sliceString(0, context.pos)
- const opens: string[] = []
- const beginRe = /\\begin\{([^}]+)\}/g
- const endRe = /\\end\{([^}]+)\}/g
- let m: RegExpExecArray | null
- while ((m = beginRe.exec(docText)) !== null) opens.push(m[1])
- while ((m = endRe.exec(docText)) !== null) {
- const idx = opens.lastIndexOf(m[1])
- if (idx >= 0) opens.splice(idx, 1)
- }
- if (opens.length > 0) {
- const last = opens[opens.length - 1]
- options.push({ label: last, detail: 'Close environment', type: 'keyword', boost: 100 })
- }
+/** Custom environments from the project, as full begin/end snippets */
+function customEnvironmentCompletions(): Completion[] {
+ const options: Completion[] = []
+ for (const name of scanDocEnvironments().keys()) {
+ if (environmentTemplates.has(name)) continue
+ options.push({
+ type: 'env',
+ label: `\\begin{${name}} …`,
+ apply: applySnippet(envSnippet(name)),
+ })
}
+ return options
+}
- // Also add all known environments
- for (const env of latexEnvironments) {
- // For \begin{}, use snippet with body
- if (!isEnd && env.body) {
- // We can't use snippetCompletion here since we're only completing the name
- // The body will be handled by the \begin snippet in commands
- options.push({
- label: env.name,
- detail: env.detail,
- type: 'type',
- })
- } else {
- options.push({
- label: env.name,
- detail: env.detail,
- type: 'type',
- })
- }
+// ── Completion sources ───────────────────────────────────────────────
+
+/** Commands — active on `\prefix` (or explicit trigger) */
+const commandSource: CompletionSource = (context) => {
+ const matches = getCompletionMatches(context)
+ if (!matches) return null
+ const { match, matchBefore } = matches
+ // inside a command argument — argument sources handle it
+ if (match) return null
+
+ const options = [...buildCommandCompletions(), ...customEnvironmentCompletions()]
+
+ const prefixMatcher = /^\\[^{\s]*$/
+ if (prefixMatcher.test(matchBefore.text)) {
+ return { from: matchBefore.from, validFor: prefixMatcher, options }
}
+ if (!context.explicit) return null
+ return { from: matchBefore.to, options }
+}
+
+/** Environment names inside \begin{...} / \end{...} */
+const environmentNameSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details) return null
- // Also scan the document for custom environments (defined with \newenvironment or \newtheorem)
- const docText = context.state.doc.toString()
- const customEnvRe = /\\(?:newenvironment|newtheorem)\{([^}]+)\}/g
- let m: RegExpExecArray | null
- const seen = new Set(latexEnvironments.map((e) => e.name))
- while ((m = customEnvRe.exec(docText)) !== null) {
- if (!seen.has(m[1])) {
- seen.add(m[1])
- options.push({ label: m[1], detail: 'Custom', type: 'type' })
+ if (details.command === 'begin') {
+ // Replace the whole `\begin{prefix` with a full environment snippet.
+ // validFor excludes `}` so the popup closes once the name is complete —
+ // Enter on an already-closed `\begin{env}` then inserts a newline
+ // (handled by latexClosing) instead of re-applying the snippet.
+ return {
+ from: details.matchBefore.from,
+ validFor: /^\\begin\{[^}\s]*$/,
+ options: [...buildCommandCompletions(), ...customEnvironmentCompletions()],
}
}
- // Also scan all open files for custom environments
- const { fileContents } = useAppStore.getState()
- for (const content of Object.values(fileContents)) {
- const re = /\\(?:newenvironment|newtheorem)\{([^}]+)\}/g
- while ((m = re.exec(content)) !== null) {
- if (!seen.has(m[1])) {
- seen.add(m[1])
- options.push({ label: m[1], detail: 'Custom', type: 'type' })
+ if (details.command === 'end') {
+ // Suggest currently-open environments, most recent first
+ const doc = context.state.doc.sliceString(0, context.pos)
+ const open: string[] = []
+ const re = /\\(begin|end)\{([^}]+)\}/g
+ let m: RegExpExecArray | null
+ while ((m = re.exec(doc)) !== null) {
+ if (m[1] === 'begin') {
+ open.push(m[2])
+ } else {
+ const idx = open.lastIndexOf(m[2])
+ if (idx >= 0) open.splice(idx, 1)
}
}
+ let boost = 10
+ const options: Completion[] = []
+ const seen = new Set<string>()
+ for (const env of open) {
+ if (seen.has(env)) continue
+ seen.add(env)
+ options.push({ type: 'env', label: env, boost: boost++ })
+ }
+ for (const name of environmentTemplates.keys()) {
+ if (!seen.has(name)) options.push({ type: 'env', label: name })
+ }
+ return { from: details.from, validFor: /^[^}]*/, options }
}
- return {
- from: envCtx.from,
- options,
- validFor: /^[a-zA-Z*]*$/,
- }
+ return null
}
-/** Source 3: Label references inside \ref{}, \eqref{}, etc. */
-function labelSource(context: CompletionContext): CompletionResult | null {
- const refCtx = getRefContext(context)
- if (!refCtx) return null
+/** Citation keys inside \cite{...} and friends (multi-key aware) */
+const citationSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || !isCiteCommand(details.command)) return null
+
+ const options: Completion[] = scanCitationKeys()
+ .filter((entry) => !details.existingKeys.includes(entry.key))
+ .map((entry) => ({
+ type: 'reference',
+ label: entry.key,
+ detail: `@${entry.type}`,
+ info: entry.title,
+ apply: applyParameter,
+ }))
+ return { from: details.from, validFor: details.validFor, options }
+}
- const labels = scanLabels()
- const options: Completion[] = labels.map((label) => ({
- label,
- type: 'variable',
- detail: 'label',
- }))
+/** Labels inside \ref{...} and friends (multi-key aware) */
+const labelSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || !isRefCommand(details.command)) return null
- return {
- from: refCtx.from,
- options,
- validFor: /^[a-zA-Z0-9_:.-]*$/,
- }
+ const options: Completion[] = Array.from(scanLabels())
+ .filter((label) => !details.existingKeys.includes(label))
+ .map((label) => ({ type: 'label', label, apply: applyParameter }))
+ return { from: details.from, validFor: details.validFor, options }
}
-/** Source 4: Citation keys inside \cite{}, \citep{}, etc. */
-function citationSource(context: CompletionContext): CompletionResult | null {
- const citeCtx = getCiteContext(context)
- if (!citeCtx) return null
+/** Package names inside \usepackage{...} */
+const packageSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || !PACKAGE_COMMANDS.has(details.command)) return null
- const entries = scanCitations()
- const options: Completion[] = entries.map((entry) => ({
- label: entry.key,
- detail: `@${entry.type}`,
- info: entry.title,
- type: 'text',
- }))
+ const existing = scanExistingPackages(context)
+ const names = new Set<string>([...packageNames, ...projectData.serverPackageNames])
+ const options: Completion[] = []
+ for (const name of names) {
+ if (existing.has(name) || details.existingKeys.includes(name)) continue
+ options.push({ type: 'pkg', label: name, apply: applyParameter })
+ }
+ return { from: details.from, validFor: details.validFor, options }
+}
+/** Class names inside \documentclass{...} */
+const documentClassSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || details.command !== 'documentclass') return null
return {
- from: citeCtx.from,
- options,
- validFor: /^[a-zA-Z0-9_:.-]*$/,
+ from: details.from,
+ validFor: details.validFor,
+ options: classNames.map((name) => ({ type: 'cls', label: name, apply: applyParameter })),
}
}
-/** Source 5: File paths inside \input{}, \include{}, \includegraphics{} */
-function filePathSource(context: CompletionContext): CompletionResult | null {
- const fileCtx = getFileContext(context)
- if (!fileCtx) return null
+/** Bibliography styles inside \bibliographystyle{...} */
+const bibliographyStyleSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || details.command !== 'bibliographystyle') return null
+ const options: Completion[] = []
+ for (const styles of Object.values(bibliographyStyles)) {
+ for (const style of styles) {
+ options.push({ type: 'bib', label: style, apply: applyParameter })
+ }
+ }
+ return { from: details.from, validFor: details.validFor, options }
+}
- const paths = getFilePaths(fileCtx.isGraphics)
- const options: Completion[] = paths.map((p) => ({
- label: p,
- type: 'text',
- detail: fileCtx.isGraphics ? 'image' : 'file',
- }))
+/** .bib files inside \bibliography{...} / \addbibresource{...} */
+const bibliographySource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || !BIBLIOGRAPHY_COMMANDS.has(details.command)) return null
+ const { bibPaths } = walkFileTree()
+ const keepExtension = details.command === 'addbibresource'
+ const options: Completion[] = bibPaths
+ .map((path) => (keepExtension ? path : path.replace(/\.bib$/i, '')))
+ .filter((path) => !details.existingKeys.includes(path))
+ .map((path) => ({ type: 'file', label: path, apply: applyParameter }))
+ return { from: details.from, validFor: details.validFor, options }
+}
+/** File paths inside \input{...} / \include{...} / \subfile{...} */
+const inputFileSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || !INPUT_COMMANDS.has(details.command)) return null
+ const { texPaths } = walkFileTree()
+ const options: Completion[] = []
+ for (const path of texPaths) {
+ const stripped = path.replace(/\.tex$/i, '')
+ options.push({ type: 'file', label: stripped, apply: applyParameter })
+ }
+ return { from: details.from, validFor: /^[^}\s]*/, options }
+}
+
+/** Graphics paths inside \includegraphics{...} */
+const graphicsSource: CompletionSource = (context) => {
+ const details = getArgumentDetails(context)
+ if (!details || details.command !== 'includegraphics') return null
+ const { imagePaths } = walkFileTree()
return {
- from: fileCtx.from,
- options,
- validFor: /^[a-zA-Z0-9_/.-]*$/,
+ from: details.from,
+ validFor: /^[^}\s]*/,
+ options: imagePaths.map((path) => ({ type: 'file', label: path, apply: applyParameter })),
}
}
-// ── Export extension ─────────────────────────────────────────────────
+// ── Auto-open on empty argument braces (port of open-autocomplete.ts) ──
+
+const AUTO_OPEN_RE =
+ /\\(?:begin|end|usepackage|RequirePackage|documentclass|bibliography|addbibresource|bibliographystyle|input|include|subfile|includegraphics|[a-zA-Z]*[cC]ite[a-zA-Z]*|[a-zA-Z]*ref(?:range)?|ref)\*?(?:\[[^\]]*\])*\{$/
+
+const autoOpenOnEmptyBraces = EditorView.updateListener.of((update) => {
+ // Only open on local typing (or a completion landing the cursor in a
+ // snippet field) — not on plain cursor movement, so Escape + arrowing back
+ // through `\cmd{|}` doesn't force the popup to reopen.
+ if (!update.docChanged) return
+ if (!update.transactions.some((tr) => tr.isUserEvent('input'))) return
+ // Ignore remote OT updates — only open for local edits
+ if (update.transactions.every((tr) => tr.annotation(remoteUpdateAnnotation) !== undefined)) return
+
+ const state = update.state
+ const main = state.selection.main
+ if (!main.empty) return
+ const pos = main.head
+ if (state.doc.sliceString(pos, pos + 1) !== '}') return
+ const before = state.doc.sliceString(state.doc.lineAt(pos).from, pos)
+ const m = before.match(AUTO_OPEN_RE)
+ if (!m) return
+ // Verify the command is one we complete (ref list is exact, cite is fuzzy)
+ const cmd = m[0].match(/^\\([a-zA-Z]+)/)?.[1]
+ if (!cmd) return
+ const known =
+ ['begin', 'end', 'usepackage', 'RequirePackage', 'documentclass', 'bibliography',
+ 'addbibresource', 'bibliographystyle', 'includegraphics'].includes(cmd) ||
+ INPUT_COMMANDS.has(cmd) || isCiteCommand(cmd) || isRefCommand(cmd)
+ if (!known) return
+ startCompletion(update.view)
+})
+
+// ── Extension export ─────────────────────────────────────────────────
export function latexAutocomplete() {
- return autocompletion({
- override: [
- environmentSource,
- labelSource,
- citationSource,
- filePathSource,
- commandSource,
- ],
- defaultKeymap: true,
- icons: true,
- optionClass: () => 'cm-latex-completion',
- activateOnTyping: true,
- })
+ return [
+ autocompletion({
+ override: [
+ citationSource,
+ labelSource,
+ packageSource,
+ inputFileSource,
+ graphicsSource,
+ environmentNameSource,
+ documentClassSource,
+ bibliographySource,
+ bibliographyStyleSource,
+ commandSource,
+ ],
+ icons: false,
+ defaultKeymap: false,
+ addToOptions: [
+ {
+ // display the completion "type" at the end of the suggestion
+ render: (completion: Completion) => {
+ const span = document.createElement('span')
+ span.classList.add('ol-cm-completionType')
+ if (completion.type) span.textContent = completion.type
+ return span
+ },
+ position: 400,
+ },
+ ],
+ optionClass: (completion: Completion) => `ol-cm-completion-${completion.type}`,
+ interactionDelay: 0,
+ }),
+ Prec.highest(
+ keymap.of([
+ { key: 'Escape', run: closeCompletion },
+ { key: 'ArrowDown', run: moveCompletionSelection(true) },
+ { key: 'ArrowUp', run: moveCompletionSelection(false) },
+ { key: 'PageDown', run: moveCompletionSelection(true, 'page') },
+ { key: 'PageUp', run: moveCompletionSelection(false, 'page') },
+ { key: 'Enter', run: acceptCompletion },
+ { key: 'Tab', run: acceptCompletion },
+ ])
+ ),
+ Prec.high(
+ keymap.of([
+ { key: 'Ctrl-Space', run: startCompletion },
+ { key: 'Alt-Space', run: startCompletion },
+ ])
+ ),
+ autoOpenOnEmptyBraces,
+ ]
}
diff --git a/src/renderer/src/extensions/latexClosing.ts b/src/renderer/src/extensions/latexClosing.ts
index 99da35c..cff4063 100644
--- a/src/renderer/src/extensions/latexClosing.ts
+++ b/src/renderer/src/extensions/latexClosing.ts
@@ -2,7 +2,7 @@
// Licensed under AGPL-3.0 - see LICENSE file
import { EditorView, keymap } from '@codemirror/view'
-import { EditorSelection } from '@codemirror/state'
+import { EditorSelection, Prec } from '@codemirror/state'
// ── Helpers ──────────────────────────────────────────────────────────
@@ -223,8 +223,11 @@ const deletePairKeymap = keymap.of([
*/
export function latexClosing() {
return [
- beginEnvEnterKeymap,
+ // Prec.high so these win over defaultKeymap's Enter/Backspace (which are
+ // registered earlier in the editor's extension list) but stay below the
+ // completion keymap's Prec.highest bindings while the popup is open.
+ Prec.high(beginEnvEnterKeymap),
latexInputHandler,
- deletePairKeymap,
+ Prec.high(deletePairKeymap),
]
}
diff --git a/src/renderer/src/extensions/remoteCursors.ts b/src/renderer/src/extensions/remoteCursors.ts
index cf07857..c1152b4 100644
--- a/src/renderer/src/extensions/remoteCursors.ts
+++ b/src/renderer/src/extensions/remoteCursors.ts
@@ -1,14 +1,37 @@
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
-// CM6 extension for rendering remote collaborator cursors
-import { StateEffect, StateField } from '@codemirror/state'
-import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view'
+// CM6 extension for rendering remote collaborator cursors.
+//
+// Ported from Overleaf's cursor-highlights extension
+// (services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts,
+// AGPL-3.0): cursors are drawn in a separate layer() above the text so they
+// never participate in text layout — no extra DOM in the contenteditable
+// content, no line-wrap opportunities, no IME interference.
+import {
+ MapMode,
+ RangeSet,
+ RangeValue,
+ StateEffect,
+ StateField,
+ type Text,
+ type TransactionSpec,
+} from '@codemirror/state'
+import {
+ Direction,
+ EditorView,
+ hoverTooltip,
+ layer,
+ RectangleMarker,
+ type Rect,
+ type Tooltip,
+} from '@codemirror/view'
+import { remoteUpdateAnnotation } from './otSyncExtension'
export interface RemoteCursor {
userId: string
name: string
- color: string
+ color: string // kept for API compat; layer rendering derives hue from userId
row: number // 0-based
column: number // 0-based
}
@@ -16,99 +39,297 @@ export interface RemoteCursor {
/** Effect to update all remote cursors for the current doc */
export const setRemoteCursorsEffect = StateEffect.define<RemoteCursor[]>()
-const CURSOR_COLORS = [
- '#E06C75', '#61AFEF', '#98C379', '#E5C07B',
- '#C678DD', '#56B6C2', '#BE5046', '#D19A66'
-]
+export const setRemoteCursors = (cursors: RemoteCursor[]): TransactionSpec => ({
+ effects: setRemoteCursorsEffect.of(cursors),
+})
-export function colorForUser(userId: string): string {
+// ── Hue assignment (port of Overleaf's shared/utils/colors.ts) ────────
+//
+// Overleaf hashes the user id and maps it onto 0–360, avoiding the band
+// around the local user's own hue (OWN_HUE) so remote carets are never
+// confused with your own.
+
+const OWN_HUE = 200
+const OWN_HUE_BLOCKED_SIZE = 20
+const TOTAL_HUES = 360
+
+export function hashString(id: string): number {
let hash = 0
- for (let i = 0; i < userId.length; i++) {
- hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
+ for (let i = 0; i < id.length; i++) {
+ hash = (Math.imul(31, hash) + id.charCodeAt(i)) | 0
}
- return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]
+ return Math.abs(hash)
}
-class CursorWidget extends WidgetType {
- constructor(private name: string, private color: string, private id: string) {
- super()
+export function getHueForUserId(userId: string): number {
+ let hue = hashString(userId) % TOTAL_HUES
+ if (hue > OWN_HUE - OWN_HUE_BLOCKED_SIZE && hue < OWN_HUE + OWN_HUE_BLOCKED_SIZE) {
+ hue = hue - OWN_HUE
+ hue = hue + TOTAL_HUES - OWN_HUE_BLOCKED_SIZE
}
+ return hue
+}
- toDOM(): HTMLElement {
- const wrapper = document.createElement('span')
- wrapper.className = 'cm-remote-cursor'
- wrapper.setAttribute('data-cursor-id', this.id)
-
- const line = document.createElement('span')
- line.className = 'cm-remote-cursor-line'
- line.style.borderLeftColor = this.color
- wrapper.appendChild(line)
+/** Legacy helper kept for callers that want a concrete CSS color */
+export function colorForUser(userId: string): string {
+ return `hsl(${getHueForUserId(userId)}, 70%, 50%)`
+}
- const label = document.createElement('span')
- label.className = 'cm-remote-cursor-label'
- label.style.backgroundColor = this.color
- label.textContent = this.name.split(' ')[0] // first name only
- wrapper.appendChild(label)
+// ── Position helpers (port of Overleaf's utils/position.ts + utils/layer.ts) ──
- // Fade label after 2s
- setTimeout(() => label.classList.add('faded'), 2000)
+/** Clamp a (1-based line, 0-based column) to a valid doc position */
+export function findValidPosition(doc: Text, lineNumber: number, columnNumber = 0): number {
+ if (lineNumber < 1) return 0
+ if (lineNumber > doc.lines) return doc.length
+ const line = doc.line(lineNumber)
+ return Math.min(line.from + columnNumber, line.to)
+}
- return wrapper
+function getBase(view: EditorView) {
+ const rect = view.scrollDOM.getBoundingClientRect()
+ const left =
+ view.textDirection === Direction.LTR
+ ? rect.left
+ : rect.right - view.scrollDOM.clientWidth
+ return {
+ left: left - view.scrollDOM.scrollLeft,
+ top: rect.top - view.scrollDOM.scrollTop,
}
+}
+
+const round2 = (n: number) => Math.round(n * 100) / 100
- eq(other: CursorWidget): boolean {
- return this.name === other.name && this.color === other.color && this.id === other.id
+// Like coordsAtPos, but top/bottom span the full height of the visual line
+// (assumes uniform line heights — true in source mode).
+function fullHeightCoordsAtPos(view: EditorView, pos: number): Rect | null {
+ const coords = view.coordsAtPos(pos)
+ if (!coords) return null
+
+ const halfLeading = (view.defaultLineHeight - (coords.bottom - coords.top)) / 2
+
+ return {
+ left: coords.left,
+ right: coords.right,
+ top: round2(coords.top - halfLeading),
+ bottom: round2(coords.bottom + halfLeading),
}
+}
- get estimatedHeight(): number { return 0 }
+// ── State ─────────────────────────────────────────────────────────────
- ignoreEvent(): boolean { return true }
+interface CursorHighlight {
+ userId: string
+ label: string
+ hue: number
+}
+
+class HighlightRangeValue extends RangeValue {
+ mapMode = MapMode.Simple
+
+ constructor(public highlight: CursorHighlight) {
+ super()
+ }
+
+ eq(other: HighlightRangeValue): boolean {
+ return (
+ other.highlight.userId === this.highlight.userId &&
+ other.highlight.label === this.highlight.label &&
+ other.highlight.hue === this.highlight.hue
+ )
+ }
}
-const remoteCursorsField = StateField.define<DecorationSet>({
+const remoteCursorsField = StateField.define<RangeSet<HighlightRangeValue>>({
create() {
- return Decoration.none
+ return RangeSet.empty
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setRemoteCursorsEffect)) {
- const cursors = effect.value
- const decorations: { pos: number; widget: CursorWidget }[] = []
-
- for (const c of cursors) {
- const lineNum = c.row + 1 // CM6 is 1-based
- if (lineNum < 1 || lineNum > tr.state.doc.lines) continue
- const line = tr.state.doc.line(lineNum)
- const pos = line.from + Math.min(c.column, line.length)
- decorations.push({
- pos,
- widget: new CursorWidget(c.name, c.color, c.userId)
- })
+ const ranges = []
+ for (const c of effect.value) {
+ try {
+ const pos = findValidPosition(tr.state.doc, c.row + 1, c.column)
+ ranges.push(
+ new HighlightRangeValue({
+ userId: c.userId,
+ label: c.name,
+ hue: getHueForUserId(c.userId),
+ }).range(pos)
+ )
+ } catch {
+ // ignore invalid positions
+ }
}
-
- // Sort by position
- decorations.sort((a, b) => a.pos - b.pos)
-
- return Decoration.set(
- decorations.map(d =>
- Decoration.widget({ widget: d.widget, side: 1 }).range(d.pos)
- )
- )
+ return RangeSet.of(ranges, true)
}
}
- // Map through document changes
- if (tr.docChanged) {
+ // Map through local changes only. Remote changes come with a fresh
+ // clientTracking update from the server, matching Overleaf's behavior.
+ if (tr.docChanged && !tr.annotation(remoteUpdateAnnotation)) {
value = value.map(tr.changes)
}
return value
},
+})
+
+// ── Layer rendering ───────────────────────────────────────────────────
- provide: f => EditorView.decorations.from(f)
+class CursorMarker extends RectangleMarker {
+ constructor(
+ public highlight: CursorHighlight,
+ className: string,
+ left: number,
+ top: number,
+ width: number | null,
+ height: number
+ ) {
+ super(className, left, top, width, height)
+ }
+
+ draw(): HTMLDivElement {
+ const element = super.draw()
+ element.style.setProperty('--hue', String(this.highlight.hue))
+ return element
+ }
+
+ update(element: HTMLDivElement, prev: CursorMarker): boolean {
+ if (!super.update(element, prev)) return false
+ element.style.setProperty('--hue', String(this.highlight.hue))
+ return true
+ }
+
+ eq(other: CursorMarker): boolean {
+ return super.eq(other) && this.highlight.hue === other.highlight.hue
+ }
+}
+
+// Draw the collaborator cursors in a separate layer, so they don't affect
+// word wrapping (per Overleaf's cursor-highlights).
+const remoteCursorsLayer = layer({
+ above: true,
+ class: 'cm-remoteCursorsLayer',
+ update: update => {
+ return (
+ update.docChanged ||
+ update.selectionSet ||
+ update.viewportChanged ||
+ update.geometryChanged ||
+ update.transactions.some(tr =>
+ tr.effects.some(effect => effect.is(setRemoteCursorsEffect))
+ )
+ )
+ },
+ markers(view) {
+ const markers: CursorMarker[] = []
+ const highlightRanges = view.state.field(remoteCursorsField)
+ const base = getBase(view)
+ const { from, to } = view.viewport
+ highlightRanges.between(from, to, (rangeFrom, _rangeTo, { highlight }) => {
+ const pos = fullHeightCoordsAtPos(view, rangeFrom)
+ if (pos) {
+ markers.push(
+ new CursorMarker(
+ highlight,
+ 'cm-remoteCursor',
+ pos.left - base.left,
+ pos.top - base.top,
+ null,
+ pos.bottom - pos.top
+ )
+ )
+ }
+ })
+ return markers
+ },
+})
+
+// ── Hover tooltip with collaborator name(s) ───────────────────────────
+
+const cursorTooltip = (view: EditorView, pos: number): Tooltip | null => {
+ const highlights: CursorHighlight[] = []
+
+ view.state.field(remoteCursorsField).between(pos, pos, (_from, _to, value) => {
+ highlights.push(value.highlight)
+ })
+
+ if (highlights.length === 0) return null
+
+ return {
+ pos,
+ end: pos,
+ above: true,
+ create: () => {
+ const dom = document.createElement('div')
+ dom.classList.add('cm-remoteCursorTooltip')
+ for (const highlight of highlights) {
+ const label = document.createElement('div')
+ label.classList.add('cm-remoteCursorLabel')
+ label.style.setProperty('--hue', String(highlight.hue))
+ label.textContent = highlight.label
+ dom.appendChild(label)
+ }
+ return { dom }
+ },
+ }
+}
+
+const remoteCursorsTheme = EditorView.theme({
+ '.cm-remoteCursorsLayer': {
+ zIndex: 100,
+ contain: 'size style',
+ pointerEvents: 'none',
+ },
+ '.cm-remoteCursor': {
+ color: 'hsl(var(--hue), 70%, 50%)',
+ borderLeft: '2px solid hsl(var(--hue), 70%, 50%)',
+ display: 'inline-block',
+ height: '1.6em',
+ position: 'absolute',
+ pointerEvents: 'none',
+ },
+ '.cm-remoteCursor:before': {
+ content: "''",
+ position: 'absolute',
+ left: '-2px',
+ top: '-5px',
+ height: '5px',
+ width: '5px',
+ borderWidth: '3px 3px 2px 2px',
+ borderStyle: 'solid',
+ borderColor: 'inherit',
+ },
+ '.cm-tooltip.cm-tooltip-hover:has(.cm-remoteCursorTooltip)': {
+ border: 'none',
+ backgroundColor: 'transparent',
+ },
+ '.cm-remoteCursorTooltip': {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '2px',
+ },
+ '.cm-remoteCursorLabel': {
+ lineHeight: 1,
+ backgroundColor: 'hsl(var(--hue), 70%, 50%)',
+ padding: '4px 6px',
+ borderRadius: '3px',
+ fontSize: '11px',
+ fontFamily: 'var(--font-sans, sans-serif)',
+ color: 'white',
+ fontWeight: 600,
+ whiteSpace: 'nowrap',
+ pointerEvents: 'none',
+ },
})
export function remoteCursorsExtension() {
- return [remoteCursorsField]
+ return [
+ remoteCursorsField,
+ remoteCursorsLayer,
+ remoteCursorsTheme,
+ hoverTooltip(cursorTooltip, { hoverTime: 1 }),
+ ]
}