diff options
| author | haoyuren <13851610112@163.com> | 2026-07-28 05:16:20 +0800 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-07-28 05:16:20 +0800 |
| commit | 2fbcf18010bf79d3c1517c920325f12b122ec96e (patch) | |
| tree | a4bea44646553dbb1e987c3a6ac299a9702d6abe /src | |
| parent | e5e25d0e13b2e054cc7e2ca206b7ea70ee3bf4c2 (diff) | |
Overleaf web-client parity: dashboard, editor, and sync overhaul
Uses the Overleaf open-source code as golden reference throughout.
Project dashboard (official /tag + /project endpoints):
- Sidebar filters (all/yours/shared/archived/trashed) with the web
client's exact filtering predicates
- Tags: create/edit/delete with the official preset palette, per-row
chips, bulk add/remove, uncategorized view
- Archive/trash/restore/delete-forever/leave, rename, copy, zip download,
multi-select bulk actions (parallelized)
Editor:
- Remote cursors rewritten as a layer() overlay ported from Overleaf's
cursor-highlights — no more inline widgets disturbing line wrapping,
cursor motion, or IME composition
- Autocomplete ported from Overleaf's completion system: official
top-hundred snippet data, environment templates, package/class/bib
style data (verbatim, AGPL), project-wide \ref and \cite completion,
package commands via the official /project/:id/metadata endpoint,
brace-aware apply behavior, official keymap precedence
- Fixed completion popup keybindings (Enter/arrows previously fell
through to the default keymap) and dead Enter/Backspace handlers in
latexClosing
File sync reliability:
- Two-tier compile-artifact detection: pure build noise is ignored,
while pdf/docx/gz/bbl sync unless there is concrete local-compile
evidence — agent-generated figure PDFs now sync (previously all PDFs
were blanket-ignored)
- Local compile outputs are registered with the bridge so nested-root
projects don't upload the root PDF as junk
- Upload retry with exponential backoff and permanent-failure parking,
CSRF refresh with session validation and single-flight guard,
periodic orphan rescan, sync status surfaced in the status bar
- Official textExtensions list (plus legacy extras) for doc-vs-file
classification
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/fileSyncBridge.ts | 291 | ||||
| -rw-r--r-- | src/main/index.ts | 207 | ||||
| -rw-r--r-- | src/preload/index.ts | 102 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 625 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 57 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 8 | ||||
| -rw-r--r-- | src/renderer/src/components/PdfViewer.tsx | 8 | ||||
| -rw-r--r-- | src/renderer/src/components/ProjectList.tsx | 1218 | ||||
| -rw-r--r-- | src/renderer/src/components/Toolbar.tsx | 5 | ||||
| -rw-r--r-- | src/renderer/src/data/latexClassesAndStyles.ts | 58 | ||||
| -rw-r--r-- | src/renderer/src/data/latexCommands.ts | 304 | ||||
| -rw-r--r-- | src/renderer/src/data/latexEnvironmentTemplates.ts | 85 | ||||
| -rw-r--r-- | src/renderer/src/data/latexEnvironments.ts | 101 | ||||
| -rw-r--r-- | src/renderer/src/data/latexPackageNames.ts | 103 | ||||
| -rw-r--r-- | src/renderer/src/data/latexTopHundred.ts | 701 | ||||
| -rw-r--r-- | src/renderer/src/extensions/latexAutocomplete.ts | 1061 | ||||
| -rw-r--r-- | src/renderer/src/extensions/latexClosing.ts | 9 | ||||
| -rw-r--r-- | src/renderer/src/extensions/remoteCursors.ts | 347 |
18 files changed, 4157 insertions, 1133 deletions
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 }), + ] } |
