338 lines
12 KiB
JavaScript
338 lines
12 KiB
JavaScript
import { execFile, spawnSync } from "child_process";
|
|
import { existsSync, readFileSync, statSync, unwatchFile, watchFile } from "fs";
|
|
import { dirname, join, resolve } from "path";
|
|
import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js";
|
|
/**
|
|
* Find git metadata paths by walking up from cwd.
|
|
* Handles both regular git repos (.git is a directory) and worktrees (.git is a file).
|
|
*/
|
|
export function findGitPaths(cwd) {
|
|
let dir = cwd;
|
|
while (true) {
|
|
const gitPath = join(dir, ".git");
|
|
if (existsSync(gitPath)) {
|
|
try {
|
|
const stat = statSync(gitPath);
|
|
if (stat.isFile()) {
|
|
const content = readFileSync(gitPath, "utf8").trim();
|
|
if (content.startsWith("gitdir: ")) {
|
|
const gitDir = resolve(dir, content.slice(8).trim());
|
|
const headPath = join(gitDir, "HEAD");
|
|
if (!existsSync(headPath))
|
|
return null;
|
|
const commonDirPath = join(gitDir, "commondir");
|
|
const commonGitDir = existsSync(commonDirPath)
|
|
? resolve(gitDir, readFileSync(commonDirPath, "utf8").trim())
|
|
: gitDir;
|
|
return { repoDir: dir, commonGitDir, headPath };
|
|
}
|
|
}
|
|
else if (stat.isDirectory()) {
|
|
const headPath = join(gitPath, "HEAD");
|
|
if (!existsSync(headPath))
|
|
return null;
|
|
return { repoDir: dir, commonGitDir: gitPath, headPath };
|
|
}
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir)
|
|
return null;
|
|
dir = parent;
|
|
}
|
|
}
|
|
/** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */
|
|
function resolveBranchWithGitSync(repoDir) {
|
|
const result = spawnSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
cwd: repoDir,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
const branch = result.status === 0 ? result.stdout.trim() : "";
|
|
return branch || null;
|
|
}
|
|
/** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */
|
|
function resolveBranchWithGitAsync(repoDir) {
|
|
return new Promise((resolvePromise) => {
|
|
execFile("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
cwd: repoDir,
|
|
encoding: "utf8",
|
|
}, (error, stdout) => {
|
|
if (error) {
|
|
resolvePromise(null);
|
|
return;
|
|
}
|
|
const branch = stdout.trim();
|
|
resolvePromise(branch || null);
|
|
});
|
|
});
|
|
}
|
|
function isWslEnvironment() {
|
|
return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
}
|
|
function isWindowsMountedRepoPath(repoDir) {
|
|
return /^\/mnt\/[a-z](?:\/|$)/i.test(repoDir);
|
|
}
|
|
function shouldPollGitHead(repoDir) {
|
|
return isWslEnvironment() && isWindowsMountedRepoPath(repoDir);
|
|
}
|
|
/**
|
|
* Provides git branch and extension statuses - data not otherwise accessible to extensions.
|
|
* Token stats, model info available via ctx.sessionManager and ctx.model.
|
|
*/
|
|
export class FooterDataProvider {
|
|
cwd;
|
|
static WATCH_DEBOUNCE_MS = 500;
|
|
extensionStatuses = new Map();
|
|
cachedBranch = undefined;
|
|
gitPaths = undefined;
|
|
headWatcher = null;
|
|
headWatchFilePath = null;
|
|
headWatchFileListener = null;
|
|
reftableWatcher = null;
|
|
reftableTablesListWatcher = null;
|
|
reftableTablesListPath = null;
|
|
branchChangeCallbacks = new Set();
|
|
availableProviderCount = 0;
|
|
refreshTimer = null;
|
|
gitWatcherRetryTimer = null;
|
|
refreshInFlight = false;
|
|
refreshPending = false;
|
|
disposed = false;
|
|
constructor(cwd) {
|
|
this.cwd = cwd;
|
|
this.gitPaths = findGitPaths(cwd);
|
|
this.setupGitWatcher();
|
|
}
|
|
/** Current git branch, null if not in repo, "detached" if detached HEAD */
|
|
getGitBranch() {
|
|
if (this.cachedBranch === undefined) {
|
|
this.cachedBranch = this.resolveGitBranchSync();
|
|
}
|
|
return this.cachedBranch;
|
|
}
|
|
/** Extension status texts set via ctx.ui.setStatus() */
|
|
getExtensionStatuses() {
|
|
return this.extensionStatuses;
|
|
}
|
|
/** Subscribe to git branch changes. Returns unsubscribe function. */
|
|
onBranchChange(callback) {
|
|
this.branchChangeCallbacks.add(callback);
|
|
return () => this.branchChangeCallbacks.delete(callback);
|
|
}
|
|
/** Internal: set extension status */
|
|
setExtensionStatus(key, text) {
|
|
if (text === undefined) {
|
|
this.extensionStatuses.delete(key);
|
|
}
|
|
else {
|
|
this.extensionStatuses.set(key, text);
|
|
}
|
|
}
|
|
/** Internal: clear extension statuses */
|
|
clearExtensionStatuses() {
|
|
this.extensionStatuses.clear();
|
|
}
|
|
/** Number of unique providers with available models (for footer display) */
|
|
getAvailableProviderCount() {
|
|
return this.availableProviderCount;
|
|
}
|
|
/** Internal: update available provider count */
|
|
setAvailableProviderCount(count) {
|
|
this.availableProviderCount = count;
|
|
}
|
|
setCwd(cwd) {
|
|
if (this.cwd === cwd) {
|
|
return;
|
|
}
|
|
this.cwd = cwd;
|
|
if (this.refreshTimer) {
|
|
clearTimeout(this.refreshTimer);
|
|
this.refreshTimer = null;
|
|
}
|
|
this.clearGitWatchers();
|
|
this.cachedBranch = undefined;
|
|
this.gitPaths = findGitPaths(cwd);
|
|
this.setupGitWatcher();
|
|
this.notifyBranchChange();
|
|
}
|
|
/** Internal: cleanup */
|
|
dispose() {
|
|
this.disposed = true;
|
|
if (this.refreshTimer) {
|
|
clearTimeout(this.refreshTimer);
|
|
this.refreshTimer = null;
|
|
}
|
|
this.clearGitWatchers();
|
|
this.branchChangeCallbacks.clear();
|
|
}
|
|
notifyBranchChange() {
|
|
for (const cb of this.branchChangeCallbacks)
|
|
cb();
|
|
}
|
|
scheduleRefresh() {
|
|
if (this.disposed || this.refreshTimer)
|
|
return;
|
|
if (this.refreshInFlight) {
|
|
this.refreshPending = true;
|
|
return;
|
|
}
|
|
this.refreshTimer = setTimeout(() => {
|
|
this.refreshTimer = null;
|
|
void this.refreshGitBranchAsync();
|
|
}, FooterDataProvider.WATCH_DEBOUNCE_MS);
|
|
}
|
|
async refreshGitBranchAsync() {
|
|
if (this.disposed)
|
|
return;
|
|
if (this.refreshInFlight) {
|
|
this.refreshPending = true;
|
|
return;
|
|
}
|
|
this.refreshInFlight = true;
|
|
try {
|
|
const nextBranch = await this.resolveGitBranchAsync();
|
|
if (this.disposed)
|
|
return;
|
|
if (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) {
|
|
this.cachedBranch = nextBranch;
|
|
this.notifyBranchChange();
|
|
return;
|
|
}
|
|
this.cachedBranch = nextBranch;
|
|
}
|
|
finally {
|
|
this.refreshInFlight = false;
|
|
if (this.refreshPending && !this.disposed) {
|
|
this.refreshPending = false;
|
|
this.scheduleRefresh();
|
|
}
|
|
}
|
|
}
|
|
resolveGitBranchSync() {
|
|
try {
|
|
if (!this.gitPaths)
|
|
return null;
|
|
const content = readFileSync(this.gitPaths.headPath, "utf8").trim();
|
|
if (content.startsWith("ref: refs/heads/")) {
|
|
const branch = content.slice(16);
|
|
return branch === ".invalid" ? (resolveBranchWithGitSync(this.gitPaths.repoDir) ?? "detached") : branch;
|
|
}
|
|
return "detached";
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
async resolveGitBranchAsync() {
|
|
try {
|
|
if (!this.gitPaths)
|
|
return null;
|
|
const content = readFileSync(this.gitPaths.headPath, "utf8").trim();
|
|
if (content.startsWith("ref: refs/heads/")) {
|
|
const branch = content.slice(16);
|
|
return branch === ".invalid"
|
|
? ((await resolveBranchWithGitAsync(this.gitPaths.repoDir)) ?? "detached")
|
|
: branch;
|
|
}
|
|
return "detached";
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
clearGitWatchers() {
|
|
closeWatcher(this.headWatcher);
|
|
this.headWatcher = null;
|
|
if (this.headWatchFilePath && this.headWatchFileListener) {
|
|
unwatchFile(this.headWatchFilePath, this.headWatchFileListener);
|
|
this.headWatchFilePath = null;
|
|
this.headWatchFileListener = null;
|
|
}
|
|
closeWatcher(this.reftableWatcher);
|
|
this.reftableWatcher = null;
|
|
closeWatcher(this.reftableTablesListWatcher);
|
|
this.reftableTablesListWatcher = null;
|
|
if (this.reftableTablesListPath) {
|
|
unwatchFile(this.reftableTablesListPath);
|
|
this.reftableTablesListPath = null;
|
|
}
|
|
if (this.gitWatcherRetryTimer) {
|
|
clearTimeout(this.gitWatcherRetryTimer);
|
|
this.gitWatcherRetryTimer = null;
|
|
}
|
|
}
|
|
scheduleGitWatcherRetry() {
|
|
if (this.disposed || this.gitWatcherRetryTimer) {
|
|
return;
|
|
}
|
|
this.gitWatcherRetryTimer = setTimeout(() => {
|
|
this.gitWatcherRetryTimer = null;
|
|
this.setupGitWatcher();
|
|
}, FS_WATCH_RETRY_DELAY_MS);
|
|
}
|
|
handleGitWatcherError() {
|
|
this.clearGitWatchers();
|
|
this.scheduleGitWatcherRetry();
|
|
}
|
|
setupGitWatcher() {
|
|
this.clearGitWatchers();
|
|
if (!this.gitPaths)
|
|
return;
|
|
const pollGitHead = shouldPollGitHead(this.gitPaths.repoDir);
|
|
// Watch the directory containing HEAD, not HEAD itself.
|
|
// Git uses atomic writes (write temp, rename over HEAD), which changes the inode.
|
|
// fs.watch on a file stops working after the inode changes.
|
|
this.headWatcher = watchWithErrorHandler(dirname(this.gitPaths.headPath), (_eventType, filename) => {
|
|
if (!filename || filename === "HEAD") {
|
|
this.scheduleRefresh();
|
|
}
|
|
}, () => this.handleGitWatcherError());
|
|
if (pollGitHead) {
|
|
this.headWatchFilePath = this.gitPaths.headPath;
|
|
this.headWatchFileListener = (current, previous) => {
|
|
if (current.mtimeMs !== previous.mtimeMs ||
|
|
current.ctimeMs !== previous.ctimeMs ||
|
|
current.size !== previous.size) {
|
|
this.scheduleRefresh();
|
|
}
|
|
};
|
|
watchFile(this.headWatchFilePath, { interval: 1000 }, this.headWatchFileListener);
|
|
}
|
|
if (!this.headWatcher && !pollGitHead) {
|
|
return;
|
|
}
|
|
// In reftable repos, branch switches update files in the reftable directory
|
|
// instead of HEAD. Watch it separately so the footer picks up those changes.
|
|
const reftableDir = join(this.gitPaths.commonGitDir, "reftable");
|
|
if (existsSync(reftableDir)) {
|
|
this.reftableWatcher = watchWithErrorHandler(reftableDir, () => {
|
|
this.scheduleRefresh();
|
|
}, () => this.handleGitWatcherError());
|
|
if (!this.reftableWatcher) {
|
|
return;
|
|
}
|
|
const tablesListPath = join(reftableDir, "tables.list");
|
|
if (existsSync(tablesListPath)) {
|
|
this.reftableTablesListPath = tablesListPath;
|
|
this.reftableTablesListWatcher = watchWithErrorHandler(tablesListPath, () => {
|
|
this.scheduleRefresh();
|
|
}, () => this.handleGitWatcherError());
|
|
if (!this.reftableTablesListWatcher) {
|
|
return;
|
|
}
|
|
watchFile(tablesListPath, { interval: 250 }, (current, previous) => {
|
|
if (current.mtimeMs !== previous.mtimeMs ||
|
|
current.ctimeMs !== previous.ctimeMs ||
|
|
current.size !== previous.size) {
|
|
this.scheduleRefresh();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
//# sourceMappingURL=footer-data-provider.js.map
|