1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
|
// Diff logic for comparing local files against the last-pulled snapshot
import { join, relative, sep } from 'path'
import { readFile, readdir, stat } from 'fs/promises'
import { createHash } from 'crypto'
export type ChangeType = 'added' | 'modified' | 'deleted'
export interface FileChange {
path: string // relative path
type: ChangeType
isText: boolean
}
// Text extensions matching Overleaf's set (from fileSyncBridge.ts)
const TEXT_EXTENSIONS = new Set([
'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'
])
const EDITABLE_FILENAMES = new Set(['latexmkrc', '.latexmkrc', 'makefile', 'gnumakefile'])
export function isTextFile(relPath: string): boolean {
const name = relPath.split('/').pop()?.toLowerCase() || ''
if (EDITABLE_FILENAMES.has(name)) return true
const ext = name.split('.').pop() || ''
return TEXT_EXTENSIONS.has(ext)
}
/** Hash file contents with sha256 */
export async function hashFile(absPath: string): Promise<string> {
const data = await readFile(absPath)
return createHash('sha256').update(data).digest('hex')
}
/** Walk a directory, returning relative paths (forward slashes, no leading /) */
export async function walkDir(dir: string, base?: string): Promise<string[]> {
const root = base || dir
const results: string[] = []
try {
const entries = await readdir(dir, { withFileTypes: true })
for (const entry of entries) {
// Skip the state file and hidden files
if (entry.name === '.lattex-cli.json') continue
if (entry.name.startsWith('.')) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) {
results.push(...await walkDir(full, root))
} else {
results.push(relative(root, full).split(sep).join('/'))
}
}
} catch { /* directory may not exist */ }
return results
}
/** Compute diff between local files and stored hashes from last pull */
export async function computeDiff(
dir: string,
storedHashes: Record<string, string>,
knownPaths: Set<string>
): Promise<FileChange[]> {
const changes: FileChange[] = []
const localFiles = await walkDir(dir)
const localSet = new Set(localFiles)
// Check for modified and added files
for (const relPath of localFiles) {
const absPath = join(dir, relPath)
const hash = await hashFile(absPath)
const storedHash = storedHashes[relPath]
if (!storedHash && !knownPaths.has(relPath)) {
changes.push({ path: relPath, type: 'added', isText: isTextFile(relPath) })
} else if (storedHash && hash !== storedHash) {
changes.push({ path: relPath, type: 'modified', isText: isTextFile(relPath) })
}
}
// Check for deleted files
for (const relPath of Object.keys(storedHashes)) {
if (!localSet.has(relPath)) {
changes.push({ path: relPath, type: 'deleted', isText: isTextFile(relPath) })
}
}
return changes
}
|