diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c8a9f26..aea9271 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,7 +19,7 @@ src/ after every run, read by terminal coding agents; see plugin/) update/ auto-update: UpdateProvider seam, electron-updater-backed provider, pure semver/channel/reducer/gate logic (update-core.ts), orchestration (updater.ts) - repo/ repository list, file-system watcher (recursive fs.watch with polling fallback), + repo/ repository list, Git-pruned directory watcher in a worker thread, watched folders: a pure bounded directory walker (scan.ts) plus the scan orchestration, exclusions and folder validation (watched-folders.ts); path comparison with the platform's case rules lives in paths.ts @@ -35,4 +35,10 @@ src/ Renderer and main process communicate over one typed channel (`src/shared/ipc.ts`). All git commands run with `GIT_TERMINAL_PROMPT=0`, so nothing ever blocks on a hidden prompt; errors are classified (auth, network, non-fast-forward, conflicts, protected branch, …) to drive the right dialog. +Repository watching runs in `repo/watcher-worker.ts`; `watcher.ts` only owns the worker and forwards coalesced change notifications. Git enumerates cached and non-ignored files, preserving tracked files inside ignored directories and honoring nested, negated, global and repository-local ignore rules. Only their containing directories and relevant Git metadata directories receive non-recursive `fs.watch` subscriptions; ignored output trees and Git object storage are not recursively scanned. Git commands, file metadata comparisons, directory subscription updates and polling all stay off the Electron main thread. + +Native events trigger bounded 120 ms reconciliation. A four-second reconciliation also detects missed events, changes to external ignore rules and files added inside previously empty directories; submodule dirty status is refreshed on reconciliation. If native watching fails, the same worker continues polling. File metadata includes nanosecond modification/change times, so editing an already-modified file still refreshes its diff. Switching repositories stops the old worker and suppresses late notifications; shutdown aborts its Git child. electron-vite's `?nodeWorker` import bundles the worker alongside the main entry, including in ASAR packages. + +History, changes, and text diffs window their rendered rows. `TextDiff` computes word-level changes only when either row of a paired deletion/addition renders, caches both sides, and invalidates them when the hunks or word-highlighting setting change. Syntax highlighting remains lazy per 400-line block. + See [DEVELOPMENT.md](DEVELOPMENT.md) for how to build and test this, and [RELEASING.md](RELEASING.md) for how packaged builds and releases work. diff --git a/src/main/git/branches.ts b/src/main/git/branches.ts index c674869..7a2b874 100644 --- a/src/main/git/branches.ts +++ b/src/main/git/branches.ts @@ -55,11 +55,11 @@ export async function getMergedBranchNames(git: GitClient, repoPath: string, ref export async function getDefaultBranch(git: GitClient, repoPath: string): Promise { const head = await git.tryRun(repoPath, ['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'], { readOnly: true }); if (head && head.stdout.trim()) return head.stdout.trim().replace(/^refs\/remotes\/origin\//, ''); - for (const candidate of ['main', 'master', 'develop', 'trunk']) { - const exists = await git.tryRun(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], { readOnly: true }); - if (exists) return candidate; - const remoteExists = await git.tryRun(repoPath, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${candidate}`], { readOnly: true }); - if (remoteExists) return candidate; + const candidates = ['main', 'master', 'develop', 'trunk']; + const refs = await git.tryRun(repoPath, ['for-each-ref', '--format=%(refname)', ...candidates.flatMap((c) => [`refs/heads/${c}`, `refs/remotes/origin/${c}`])], { readOnly: true }); + const present = new Set(refs?.stdout.split('\n').map((l) => l.trim()) ?? []); + for (const candidate of candidates) { + if (present.has(`refs/heads/${candidate}`) || present.has(`refs/remotes/origin/${candidate}`)) return candidate; } const configured = await git.tryRun(repoPath, ['config', '--get', 'init.defaultBranch'], { readOnly: true }); return configured?.stdout.trim() || null; diff --git a/src/main/git/git.ts b/src/main/git/git.ts index 43f9d50..a8d8553 100644 --- a/src/main/git/git.ts +++ b/src/main/git/git.ts @@ -80,6 +80,10 @@ export interface GitRunOptions { export class GitClient { constructor(private readonly tools: ToolLocator) {} + async executable(): Promise { + await this.tools.ensureLocated(); + return this.tools.gitPath(); + } async baseEnv(extra?: NodeJS.ProcessEnv): Promise { const env: NodeJS.ProcessEnv = { ...(await this.tools.env()) }; env.GIT_TERMINAL_PROMPT = '0'; @@ -99,8 +103,7 @@ export class GitClient { * Runs git in the given repository. Throws GitError on failure. */ async run(repoPath: string | null, args: string[], opts: GitRunOptions = {}): Promise { - await this.tools.ensureLocated(); - const gitPath = this.tools.gitPath(); + const gitPath = await this.executable(); const env = await this.baseEnv(opts.env); if (opts.readOnly) env.GIT_OPTIONAL_LOCKS = '0'; const fullArgs = ['-c', 'core.quotePath=false', '-c', 'color.ui=never', '-c', 'advice.detachedHead=false']; diff --git a/src/main/repo/manager.ts b/src/main/repo/manager.ts index 6521eaa..550712f 100644 --- a/src/main/repo/manager.ts +++ b/src/main/repo/manager.ts @@ -68,6 +68,8 @@ export function repositoryId(path: string): string { export class RepositoryManager { private watchers = new Map(); private githubCache = new Map(); + private watchGeneration = 0; + private watchingPath: string | null = null; constructor( private readonly store: Store, @@ -484,26 +486,33 @@ export class RepositoryManager { async watch(repoPath: string): Promise { if (this.watchers.has(repoPath)) return; - // Only the active repository is watched; stop the others. - for (const [p, w] of this.watchers) { - if (p !== repoPath) { - w.stop(); - this.watchers.delete(p); - } - } + const generation = ++this.watchGeneration; + this.watchingPath = repoPath; + for (const w of this.watchers.values()) void w.stop(); + this.watchers.clear(); + let watcher: RepositoryWatcher | undefined; try { const gitDir = await getGitDir(this.git, repoPath); const commonDir = await getCommonDir(this.git, repoPath).catch(() => gitDir); - const watcher = new RepositoryWatcher(repoPath, gitDir, (reason) => this.send('repo.changed', { repoPath, reason }), { commonDir }); - watcher.start(); + const gitPath = await this.git.executable(); + const env = await this.git.baseEnv(); + if (generation !== this.watchGeneration) return; + watcher = new RepositoryWatcher(repoPath, gitDir, (reason) => this.send('repo.changed', { repoPath, reason }), { commonDir, gitPath, env }); this.watchers.set(repoPath, watcher); + await watcher.start(); } catch (err) { - log.warn(`Failed to watch ${repoPath}: ${(err as Error).message}`); + if (watcher && this.watchers.get(repoPath) === watcher) this.watchers.delete(repoPath); + if (watcher) void watcher.stop(); + if (generation === this.watchGeneration) log.warn(`Failed to watch ${repoPath}: ${(err as Error).message}`); } } stopWatching(repoPath: string): void { - this.watchers.get(repoPath)?.stop(); + if (this.watchingPath === repoPath) { + this.watchingPath = null; + this.watchGeneration++; + } + void this.watchers.get(repoPath)?.stop(); this.watchers.delete(repoPath); } @@ -582,7 +591,9 @@ export class RepositoryManager { } dispose(): void { - for (const w of this.watchers.values()) w.stop(); + this.watchGeneration++; + this.watchingPath = null; + for (const w of this.watchers.values()) void w.stop(); this.watchers.clear(); } } diff --git a/src/main/repo/watcher-worker.ts b/src/main/repo/watcher-worker.ts new file mode 100644 index 0000000..477d7d9 --- /dev/null +++ b/src/main/repo/watcher-worker.ts @@ -0,0 +1,191 @@ +import { lstatSync, watch, type FSWatcher } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { parentPort, workerData } from 'node:worker_threads'; +import { exec } from '../exec'; +import type { WatcherData, WatcherMessage } from './watcher'; + +const port = parentPort!; +const { repoPath, gitDir, commonDir = gitDir, gitPath, env, pollIntervalMs = 4000, forcePolling } = workerData as WatcherData; +const abort = new AbortController(); +const watchers = new Map(); +let native = !forcePolling; +let stopped = false; +let paused = 0; +let running = false; +let again = false; +let ready = false; +let lastError = ''; +let timer: ReturnType | undefined; +let worktree = new Map(); +let refs = new Map(); + +function send(message: WatcherMessage): void { + if (!stopped) port.postMessage(message); +} + +function warn(err: unknown): void { + const message = err instanceof Error ? err.message : String(err); + if (message !== lastError) send({ type: 'error', message }); + lastError = message; +} + +function stat(path: string) { + try { + return lstatSync(path, { bigint: true }); + } catch (err) { + if (['ENOENT', 'ENOTDIR'].includes((err as NodeJS.ErrnoException).code ?? '')) return null; + throw err; + } +} + +function stamp(s: ReturnType): string { + return s ? `${s.dev}:${s.ino}:${s.mode}:${s.size}:${s.mtimeNs}:${s.ctimeNs}` : 'missing'; +} + +function addParents(file: string, root: string, directories: Set): void { + for (let dir = dirname(file); ; dir = dirname(dir)) { + if (directories.has(dir)) break; + directories.add(dir); + if (dir === root || dir === dirname(dir)) break; + } +} + +async function git(args: string[]): Promise { + return (await exec(gitPath, ['-c', 'core.quotePath=false', '-c', 'core.longpaths=true', '--no-pager', ...args], { + cwd: repoPath, env: { ...env, GIT_OPTIONAL_LOCKS: '0' }, signal: abort.signal, timeoutMs: 30000, + })).stdout; +} + +function differs(before: Map, after: Map): boolean { + if (before.size !== after.size) return true; + for (const [key, value] of after) if (before.get(key) !== value) return true; + return false; +} + +function schedule(): void { + if (stopped || timer) return; + // Bound event bursts without postponing refresh indefinitely during continuous writes. + timer = setTimeout(() => { timer = undefined; void scan(); }, 120); +} + +function closeWatchers(): void { + for (const { watcher } of watchers.values()) watcher.close(); + watchers.clear(); +} + +function updateWatchers(directories: Set): void { + if (!native || stopped) return; + for (const [dir, entry] of watchers) { + if (!directories.has(dir)) { entry.watcher.close(); watchers.delete(dir); } + } + for (const dir of directories) { + const s = stat(dir); + const identity = s?.isDirectory() ? `${s.dev}:${s.ino}` : ''; + const previous = watchers.get(dir); + if (previous?.identity === identity) continue; + previous?.watcher.close(); + watchers.delete(dir); + if (!identity) continue; + try { + // Never use recursive fs.watch: on Linux its synchronous rescans amplify rename bursts. + const watcher = watch(dir, { persistent: false }, schedule); + watchers.set(dir, { watcher, identity }); + watcher.on('error', (err) => { + if (stopped) return; + native = false; + closeWatchers(); + warn(new Error(`Native watch failed; using polling: ${err.message}`)); + }); + } catch (err) { + if (['ENOENT', 'ENOTDIR'].includes((err as NodeJS.ErrnoException).code ?? '')) continue; + native = false; + closeWatchers(); + warn(new Error(`Native watch unavailable; using polling: ${(err as Error).message}`)); + break; + } + } +} + +async function scan(): Promise { + if (stopped) return; + if (running) { again = true; return; } + running = true; + try { + // Git handles nested/negated/global ignores and info/exclude. Cached paths retain + // force-added files even when their containing tree is otherwise ignored. + const files = await git(['ls-files', '--cached', '--others', '--exclude-standard', '--deduplicate', '-z']); + const refList = await git(['for-each-ref', '--format=%(refname) %(objectname)']); + if (stopped) return; + const directories = new Set([repoPath, gitDir, commonDir]); + const nextWorktree = new Map(); + const nextRefs = new Map(); + let hasSubmodules = false; + for (const file of files.split('\0')) { + if (!file) continue; + const path = join(repoPath, file); + const s = stat(path); + nextWorktree.set(path, stamp(s)); + addParents(path, repoPath, directories); + if (s?.isDirectory()) hasSubmodules = true; + } + nextWorktree.set(join(gitDir, 'index'), stamp(stat(join(gitDir, 'index')))); + for (const base of new Set([gitDir, commonDir])) { + for (const file of ['HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'packed-refs', 'config', 'info/exclude', 'logs/HEAD', 'rebase-merge', 'rebase-apply', 'sequencer']) { + const path = join(base, file); + const s = stat(path); + nextRefs.set(path, stamp(s)); + addParents(path, base, directories); + if (s?.isDirectory()) directories.add(path); + } + for (const name of ['refs', 'refs/heads', 'refs/remotes', 'refs/tags']) directories.add(join(base, name)); + } + for (const line of refList.split('\n')) { + if (!line) continue; + const space = line.indexOf(' '); + const path = join(commonDir, line.slice(0, space)); + nextRefs.set(path, line.slice(space + 1)); + addParents(path, commonDir, directories); + } + updateWatchers(directories); + // Gitlinks are directories, not their files in the superproject's index. Refresh + // their dirty status on reconciliation without recursively subscribing to them. + const workChanged = differs(worktree, nextWorktree) || hasSubmodules; + const refsChanged = differs(refs, nextRefs); + worktree = nextWorktree; + refs = nextRefs; + if (!ready) { + ready = true; + send({ type: 'ready' }); + } else if (!paused && (workChanged || refsChanged)) { + send({ type: 'change', reason: workChanged && refsChanged ? 'both' : refsChanged ? 'refs' : 'worktree' }); + } + lastError = ''; + } catch (err) { + if (!stopped) warn(err); + if (!ready) { + stopped = true; + clearInterval(poll); + clearTimeout(timer); + closeWatchers(); + } + } finally { + running = false; + if (stopped) port.close(); + else if (again) { again = false; schedule(); } + } +} + +const poll = setInterval(schedule, pollIntervalMs); +port.on('message', (message: 'pause' | 'resume' | 'stop') => { + if (message === 'pause') paused++; + else if (message === 'resume') paused = Math.max(0, paused - 1); + else { + stopped = true; + abort.abort(); + clearInterval(poll); + clearTimeout(timer); + closeWatchers(); + if (!running) port.close(); + } +}); +void scan(); diff --git a/src/main/repo/watcher.ts b/src/main/repo/watcher.ts index 3bd4ea7..563db90 100644 --- a/src/main/repo/watcher.ts +++ b/src/main/repo/watcher.ts @@ -1,201 +1,92 @@ -import { watch, type FSWatcher } from 'node:fs'; -import { stat } from 'node:fs/promises'; -import { join, relative, sep } from 'node:path'; +/// +import type { Worker } from 'node:worker_threads'; import { log } from '../logger'; +import createWatcherWorker from './watcher-worker?nodeWorker'; export type ChangeReason = 'worktree' | 'refs' | 'both'; -const IGNORED_GIT_SEGMENTS = new Set(['objects', 'lfs', 'gitgood-rebase', 'modules']); -/** Top-level working-tree directories whose churn (installs, caches) never changes `git status` output worth a refresh. */ -const IGNORED_WORKTREE_SEGMENTS = new Set(['node_modules', '__pycache__', '.cache', '.venv', '.idea']); -/** Trailing quiet time before one change notification; fs.watch already delivers ~250 ms late on Linux, so this stays short. */ -const DEBOUNCE_MS = 120; - export interface RepositoryWatcherOptions { - /** - * The `.git` directory shared by every worktree of this repository - * (`git rev-parse --git-common-dir`). Defaults to `gitDir`, i.e. this - * worktree is the main one. When it differs, the common `refs/` directory - * is watched too, so branches created in another worktree are noticed. - */ + gitPath: string; + env: NodeJS.ProcessEnv; commonDir?: string; - /** Polling interval in ms (default 4000); mainly for tests. */ pollIntervalMs?: number; - /** Skip native fs.watch entirely and use polling (deterministic; mainly for tests). */ forcePolling?: boolean; } -/** - * Watches a repository for working tree and .git changes. Uses recursive - * fs.watch (native on Windows/macOS, inotify-based on Linux) with a polling - * fallback when the watcher cannot be created. - */ +export interface WatcherData extends RepositoryWatcherOptions { + repoPath: string; + gitDir: string; +} + +export type WatcherMessage = + | { type: 'ready' } + | { type: 'change'; reason: ChangeReason } + | { type: 'error'; message: string }; + +/** Main-thread owner only: enumeration, Git, stat calls and event coalescing live in the worker. */ export class RepositoryWatcher { - private watcher: FSWatcher | null = null; - private commonWatcher: FSWatcher | null = null; - private timer: ReturnType | null = null; - private pollTimer: ReturnType | null = null; - private pending: ChangeReason | null = null; - private lastPoll = new Map(); + private worker: Worker | null = null; private paused = 0; - private readonly commonDir: string; constructor( private readonly repoPath: string, private readonly gitDir: string, private readonly onChange: (reason: ChangeReason) => void, - private readonly opts: RepositoryWatcherOptions = {}, - ) { - this.commonDir = opts.commonDir ?? gitDir; - } + private readonly opts: RepositoryWatcherOptions, + ) {} - start(): void { - if (this.opts.forcePolling) { - this.startPolling(); - return; - } - try { - this.watcher = watch(this.repoPath, { recursive: true, persistent: false }, (_event, filename) => { - if (filename === null || filename === undefined) { - this.schedule('both'); - return; + start(): Promise { + if (this.worker) throw new Error('Repository watcher already started'); + const worker = createWatcherWorker({ workerData: { ...this.opts, repoPath: this.repoPath, gitDir: this.gitDir } satisfies WatcherData }); + this.worker = worker; + worker.unref(); + return new Promise((resolve, reject) => { + let ready = false; + worker.on('message', (message: WatcherMessage) => { + if (this.worker !== worker) return; + if (message.type === 'ready') { + ready = true; + resolve(); + } else if (message.type === 'error') { + log.warn(`Watcher for ${this.repoPath}: ${message.message}`); + } else if (this.paused === 0) { + this.onChange(message.reason); } - this.classify(String(filename)); }); - this.watcher.on('error', (err) => { - log.warn(`Watcher error for ${this.repoPath}: ${(err as Error).message}; falling back to polling`); - this.stopWatcher(); - this.startPolling(); + worker.on('error', (err) => { + if (this.worker === worker) log.error(`Repository watcher failed for ${this.repoPath}`, err); + reject(err); }); - // The .git directory may live elsewhere (worktrees); watch it too. - if (!this.gitDir.startsWith(this.repoPath + sep) && this.gitDir !== join(this.repoPath, '.git')) { - const gitWatcher = watch(this.gitDir, { recursive: true, persistent: false }, (_e, filename) => this.classifyGit(String(filename ?? ''))); - gitWatcher.on('error', () => undefined); - const original = this.watcher; - this.watcher = { - close: () => { - original.close(); - gitWatcher.close(); - }, - } as unknown as FSWatcher; - } - // Linked worktree: HEAD/index live in the per-worktree admin dir above, - // but branches/refs are shared in the common dir and are not covered by - // it. Watch the common refs/ too, so ref changes made in any other - // worktree are picked up here. - if (this.commonDir !== this.gitDir) { - try { - this.commonWatcher = watch(join(this.commonDir, 'refs'), { recursive: true, persistent: false }, () => this.schedule('refs')); - this.commonWatcher.on('error', () => undefined); - } catch { - /* the polling fallback below also covers the common refs/ and packed-refs */ + worker.once('exit', (code) => { + if (this.worker === worker) { + this.worker = null; + log.warn(`Repository watcher exited for ${this.repoPath} (${code})`); } - } - } catch (err) { - log.warn(`Cannot watch ${this.repoPath} (${(err as Error).message}); using polling`); - this.startPolling(); - } + if (!ready) reject(new Error(`Repository watcher stopped before initialization (${code})`)); + }); + }); } - /** Temporarily suppress change events (e.g. while we run our own git commands). */ pause(): void { this.paused++; + this.worker?.postMessage('pause'); } resume(): void { this.paused = Math.max(0, this.paused - 1); + this.worker?.postMessage('resume'); } - private classify(filename: string): void { - const normalized = filename.split(sep).join('/'); - if (normalized === '.git' || normalized.startsWith('.git/')) { - this.classifyGit(normalized.replace(/^\.git\/?/, '')); - return; - } - const slash = normalized.indexOf('/'); - if (IGNORED_WORKTREE_SEGMENTS.has(slash === -1 ? normalized : normalized.slice(0, slash))) return; - this.schedule('worktree'); - } - - private classifyGit(relPath: string): void { - const parts = relPath.split(/[\\/]/).filter(Boolean); - if (parts.length === 0) { - this.schedule('refs'); - return; - } - if (IGNORED_GIT_SEGMENTS.has(parts[0])) return; - const last = parts[parts.length - 1]; - if (last.endsWith('.lock') || last.startsWith('tmp_') || last === 'index.lock') return; - if (last === 'index') { - this.schedule('worktree'); - return; - } - this.schedule('refs'); - } - - private schedule(reason: ChangeReason): void { - if (this.paused > 0) return; - this.pending = this.pending === null || this.pending === reason ? reason : 'both'; - if (this.timer) clearTimeout(this.timer); - this.timer = setTimeout(() => { - const r = this.pending ?? 'both'; - this.pending = null; - this.timer = null; - this.onChange(r); - }, DEBOUNCE_MS); - } - - private startPolling(): void { - if (this.pollTimer) return; - const targets = ['HEAD', 'index', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD', 'packed-refs', join('refs', 'heads'), join('logs', 'HEAD')].map((f) => join(this.gitDir, f)); - if (this.commonDir !== this.gitDir) { - targets.push(join(this.commonDir, 'packed-refs'), join(this.commonDir, 'refs', 'heads')); - } - this.pollTimer = setInterval(async () => { - let changed = false; - for (const t of targets) { - try { - const s = await stat(t); - const prev = this.lastPoll.get(t); - if (prev !== undefined && prev !== s.mtimeMs) changed = true; - this.lastPoll.set(t, s.mtimeMs); - } catch { - if (this.lastPoll.has(t)) { - changed = true; - this.lastPoll.delete(t); - } - } - } - if (changed) this.schedule('both'); - else this.schedule('worktree'); - }, this.opts.pollIntervalMs ?? 4000); - } - - private stopWatcher(): void { - try { - this.watcher?.close(); - } catch { - /* ignore */ - } - this.watcher = null; - try { - this.commonWatcher?.close(); - } catch { - /* ignore */ - } - this.commonWatcher = null; - } - - stop(): void { - this.stopWatcher(); - if (this.pollTimer) clearInterval(this.pollTimer); - this.pollTimer = null; - if (this.timer) clearTimeout(this.timer); - this.timer = null; - } - - static relativeInside(root: string, p: string): boolean { - const rel = relative(root, p); - return !!rel && !rel.startsWith('..'); + async stop(): Promise { + const worker = this.worker; + this.worker = null; + if (!worker) return; + // Let the worker abort any Git child before terminating a stuck enumeration. + await new Promise((resolve) => { + const timer = setTimeout(() => void worker.terminate(), 2000); + timer.unref(); + worker.once('exit', () => { clearTimeout(timer); resolve(); }); + worker.postMessage('stop'); + }); } } diff --git a/src/renderer/src/components/diff/TextDiff.tsx b/src/renderer/src/components/diff/TextDiff.tsx index f8fe86a..32594c7 100644 --- a/src/renderer/src/components/diff/TextDiff.tsx +++ b/src/renderer/src/components/diff/TextDiff.tsx @@ -413,9 +413,11 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele // Split-view pairs per hunk, and lazily computed intraline ranges per line key. const pairsCache = useRef(new Map()); + const pairByKey = useRef(new Map()); const intralineCache = useRef(new Map()); useMemo(() => { pairsCache.current.clear(); + pairByKey.current.clear(); intralineCache.current.clear(); }, [diff.hunks, intraline]); const pairsOf = useCallback((hunkIndex: number): Pair[] => { @@ -426,11 +428,8 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele if (intraline) { for (const pair of pairs) { if (pair.left && pair.right && pair.left.line.type === 'delete' && pair.right.line.type === 'add') { - const r = intralineDiff(pair.left.line.text, pair.right.line.text); - if (r) { - intralineCache.current.set(pair.left.key, r.old); - intralineCache.current.set(pair.right.key, r.new); - } + pairByKey.current.set(pair.left.key, pair); + pairByKey.current.set(pair.right.key, pair); } } } @@ -444,8 +443,15 @@ export function TextDiff({ diff, mode, wrap, syntax, intraline, selectable, sele if (line.type === 'delete' && line.oldLineNumber !== null && oldLines && oldLines[line.oldLineNumber - 1] === line.text) html = highlighted('old', line.oldLineNumber); else if (line.type !== 'delete' && line.newLineNumber !== null && newLines && newLines[line.newLineNumber - 1] === line.text) html = highlighted('new', line.newLineNumber); if (html === undefined) html = escapeHtml(line.text); - if (intraline) { + if (intraline && line.type !== 'context') { pairsOf(hunkIndex); + // Only diff pairs whose rows render, not every pair in a large hunk. + const pair = pairByKey.current.get(key); + if (!intralineCache.current.has(key) && pair?.left && pair.right) { + const r = intralineDiff(pair.left.line.text, pair.right.line.text); + intralineCache.current.set(pair.left.key, r?.old ?? []); + intralineCache.current.set(pair.right.key, r?.new ?? []); + } const ranges = intralineCache.current.get(key); if (ranges && ranges.length) html = markHtml(html, ranges, line.type === 'add' ? 'word-add' : 'word-del'); } diff --git a/test/fixture/watcher.test.ts b/test/fixture/watcher.test.ts new file mode 100644 index 0000000..970bd0f --- /dev/null +++ b/test/fixture/watcher.test.ts @@ -0,0 +1,116 @@ +import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { RepositoryWatcher, type ChangeReason } from '../../src/main/repo/watcher'; +import { createRepo, hasGitSync, type TestRepo } from '../helpers/repo'; + +// Integration tests use real fs.watch and a separate worker event loop; parent fake +// timers cannot advance it. Delays below bound observation of suppressed events. +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe.skipIf(!hasGitSync())('repository watcher', () => { + let repo: TestRepo; + let watcher: RepositoryWatcher | undefined; + const events: ChangeReason[] = []; + + afterEach(async () => { + await watcher?.stop(); + watcher = undefined; + await repo?.dispose(); + events.length = 0; + }); + + async function start(forcePolling = false) { + watcher = new RepositoryWatcher(repo.path, join(repo.path, '.git'), (reason) => events.push(reason), { + gitPath: repo.gitBin, env: repo.env, pollIntervalMs: 200, forcePolling, + }); + await watcher.start(); + } + + async function changed() { + await expect.poll(() => events.includes('worktree') || events.includes('both'), { timeout: 8000 }).toBe(true); + events.length = 0; + } + + it('ignores atomic cache writes but retains tracked files inside ignored trees', async () => { + repo = await createRepo({ commits: [{ message: 'init', files: { 'cache/tracked.txt': 'one', '.gitignore': '' } }] }); + await repo.write('.gitignore', '/cache/\n/new-cache/\n'); + await start(); + for (let i = 0; i < 100; i++) { + const temp = join(repo.path, 'cache', `${i}.tmp`); + await writeFile(temp, 'cache'); + await rename(temp, join(repo.path, 'cache', `${i}.json`)); + } + await repo.write('new-cache/nested/output.json', 'ignored'); + await sleep(700); + expect(events).toEqual([]); + await repo.write('cache/tracked.txt', 'two'); + await changed(); + await repo.write('cache/tracked.txt', 'six'); // Still modified; same size as the previous edit. + await changed(); + }); + + it('honors nested negation, info/exclude and global ignores, including rule changes', async () => { + repo = await createRepo({ commits: [{ message: 'init', files: { + '.gitignore': '*.log\n', 'nested/.gitignore': '!keep.log\n', 'tracked.txt': 'one', + } }] }); + await writeFile(join(repo.root, 'global-ignore'), '*.global\n'); + repo.git(['config', 'core.excludesFile', join(repo.root, 'global-ignore')]); + await writeFile(join(repo.path, '.git/info/exclude'), '*.local\n'); + await start(); + await repo.write('nested/drop.log', 'ignored'); + await repo.write('a.global', 'ignored'); + await repo.write('b.local', 'ignored'); + await sleep(700); + expect(events).toEqual([]); + await repo.write('nested/keep.log', 'visible'); + await changed(); + await repo.write('nested/.gitignore', ''); + await changed(); + await repo.write('nested/keep.log', 'now ignored'); + await sleep(700); + expect(events).toEqual([]); + await writeFile(join(repo.root, 'global-ignore'), ''); + await changed(); // Existing a.global becomes visible without an event in the repository. + }); + + it('reconciles empty directories, deletions, index changes and repeat edits while polling', async () => { + repo = await createRepo({ commits: [{ message: 'init', files: { 'tracked.txt': 'one' } }] }); + await mkdir(join(repo.path, 'empty/nested'), { recursive: true }); + await start(true); + await repo.write('empty/nested/new.txt', 'new'); + await changed(); + await repo.write('tracked.txt', 'two'); + await changed(); + await repo.write('tracked.txt', 'six'); + await changed(); + repo.git(['add', 'tracked.txt']); + await changed(); + await rm(join(repo.path, 'empty'), { recursive: true }); + await changed(); + }); + + it('suppresses paused and stopped notifications without leaving startup pending', async () => { + repo = await createRepo({ commits: [{ message: 'init', files: { 'tracked.txt': 'one' } }] }); + await start(); + watcher!.pause(); + watcher!.pause(); + await repo.write('tracked.txt', 'two'); + await sleep(700); + watcher!.resume(); + await repo.write('tracked.txt', 'six'); + await sleep(700); + expect(events).toEqual([]); + watcher!.resume(); + await repo.write('tracked.txt', 'ten'); + await changed(); + await watcher!.stop(); + await repo.write('tracked.txt', 'end'); + await sleep(500); + expect(events).toEqual([]); + const starting = watcher!.start(); + const settled = expect(starting).rejects.toThrow(); + await watcher!.stop(); + await settled; + }); +}); diff --git a/test/fixture/worktrees.test.ts b/test/fixture/worktrees.test.ts index 952e33b..167f089 100644 --- a/test/fixture/worktrees.test.ts +++ b/test/fixture/worktrees.test.ts @@ -209,13 +209,9 @@ describe.skipIf(!hasGitSync())('RepositoryWatcher cross-worktree refs', () => { expect(worktreeGitDir).not.toBe(commonDir); const events: string[] = []; - // The change-detection debounce (watcher.ts DEBOUNCE_MS, 120ms) resets on every poll tick, so - // the poll interval must be longer than that for a change to ever surface. - const watcherB = new RepositoryWatcher(dir, worktreeGitDir, (reason) => events.push(reason), { commonDir, forcePolling: true, pollIntervalMs: 500 }); - watcherB.start(); + const watcherB = new RepositoryWatcher(dir, worktreeGitDir, (reason) => events.push(reason), { commonDir, gitPath: repo.gitBin, env: repo.env, forcePolling: true, pollIntervalMs: 500 }); + await watcherB.start(); try { - // Let the first poll tick establish its baseline mtimes before making the change. - await new Promise((r) => setTimeout(r, 600)); // Create a branch in the main worktree; its ref lives in the shared common dir. repo.git(['branch', 'new-shared-branch']); @@ -225,7 +221,7 @@ describe.skipIf(!hasGitSync())('RepositoryWatcher cross-worktree refs', () => { } expect(events.some((e) => e === 'refs' || e === 'both')).toBe(true); } finally { - watcherB.stop(); + await watcherB.stop(); } }); diff --git a/test/helpers/watcher-worker.ts b/test/helpers/watcher-worker.ts new file mode 100644 index 0000000..0789cc5 --- /dev/null +++ b/test/helpers/watcher-worker.ts @@ -0,0 +1,17 @@ +import { buildSync } from 'esbuild'; +import { resolve } from 'node:path'; +import { Worker, type WorkerOptions } from 'node:worker_threads'; + +// Vitest does not run electron-vite's ?nodeWorker plugin. Execute the same bundled +// worker in a real thread; do not mock its filesystem or Git behavior. +const { outputFiles } = buildSync({ + entryPoints: [resolve('src/main/repo/watcher-worker.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, +}); + +export default function createWatcherWorker(options: WorkerOptions): Worker { + return new Worker(outputFiles[0].text, { ...options, eval: true }); +} diff --git a/test/smoke/scenarios/35-windowed-intraline.json b/test/smoke/scenarios/35-windowed-intraline.json new file mode 100644 index 0000000..a5973a8 --- /dev/null +++ b/test/smoke/scenarios/35-windowed-intraline.json @@ -0,0 +1,23 @@ +{ + "name": "windowed-intraline", + "repoKind": "basic", + "steps": [ + { + "wait": 1500 + }, + { + "js": "(async () => {\n const {store,actions}=window.__gitgood;\n const pause=()=>new Promise(r=>setTimeout(r,80));\n const wait=async f=>{for(let i=0;i<100;i++){if(f())return;refresh();await pause();}throw Error('Diff did not settle');};\n const assert=(v,message)=>{if(!v)throw Error(message);};\n let refresh=()=>{};\n await actions.addLocalRepository(__REPO_PATH__);await pause();\n await actions.updateSettings({diffSyntaxHighlighting:false,diffShowIntraline:true,diffViewMode:'unified',diffWrapLines:false});\n const count=250;\n const show=(oldWord,newWord)=>{\n const lines=['delete','add'].flatMap(type=>Array.from({length:count},(_,i)=>({type,text:`const value${i} = ${type==='delete'?oldWord:newWord};`,oldLineNumber:type==='delete'?i+1:null,newLineNumber:type==='add'?i+1:null})));\n const diff={kind:'text',oldPath:'src/app.ts',newPath:'src/app.ts',language:'typescript',oldContent:null,newContent:null,hasCRLF:false,lineCount:lines.length,hunks:[{header:'@@ -1,250 +1,250 @@',oldStart:1,oldLines:count,newStart:1,newLines:count,lines}]};\n const apply=()=>store.set(s=>({changes:{...s.changes,selectedPaths:['src/app.ts']},diff:{...s.diff,key:'working:src/app.ts|ws=false',diff,loading:false,error:null}}));apply();refresh=apply;\n };\n const marks=cls=>[...document.querySelectorAll('.'+cls)].map(e=>e.textContent);\n const expectMarks=async(cls,word)=>{await wait(()=>marks(cls).includes(word));assert(marks(cls).every(v=>v===word),'Incorrect '+cls+' highlights');};\n show('oldValue','newValue');await expectMarks('word-del','oldValue');await pause();\n const body=document.querySelector('.diff-body');body.style.height='300px';body.style.flex='none';await pause();\n body.scrollTop=body.scrollHeight;body.dispatchEvent(new Event('scroll',{bubbles:true}));await expectMarks('word-add','newValue');\n body.scrollTop=0;await expectMarks('word-del','oldValue');\n await actions.updateSettings({diffViewMode:'split'});await expectMarks('word-del','oldValue');await expectMarks('word-add','newValue');\n body.scrollTop=body.scrollHeight;body.dispatchEvent(new Event('scroll',{bubbles:true}));await wait(()=>document.querySelector('tr[data-new-line=\"250\"]'));await expectMarks('word-add','newValue');\n await actions.updateSettings({diffShowIntraline:false});await wait(()=>!document.querySelector('.word-add,.word-del'));\n await actions.updateSettings({diffShowIntraline:true});await expectMarks('word-add','newValue');\n show('previousIdentifier','replacementIdentifier');await expectMarks('word-add','replacementIdentifier');await expectMarks('word-del','previousIdentifier');\n return JSON.stringify({passed:true});\n})()", + "dump": "result.json" + }, + { + "shot": "screenshot.png" + } + ], + "expect": [ + { + "dump": "result.json", + "jsonPath": "passed", + "equals": true + } + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 16b9488..db36d58 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,7 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; -const sharedAlias = { '@shared': resolve('src/shared') }; +const sharedAlias = { '@shared': resolve('src/shared'), './watcher-worker?nodeWorker': resolve('test/helpers/watcher-worker.ts') }; export default defineConfig({ test: {