mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 20:03:05 +00:00
144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
import { existsSync, type FSWatcher, readFileSync, statSync, watch } from "fs";
|
|
import { dirname, join, resolve } from "path";
|
|
|
|
/**
|
|
* Find the git HEAD path by walking up from cwd.
|
|
* Handles both regular git repos (.git is a directory) and worktrees (.git is a file).
|
|
*/
|
|
function findGitHeadPath(): string | null {
|
|
let dir = process.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 = content.slice(8);
|
|
const headPath = resolve(dir, gitDir, "HEAD");
|
|
if (existsSync(headPath)) return headPath;
|
|
}
|
|
} else if (stat.isDirectory()) {
|
|
const headPath = join(gitPath, "HEAD");
|
|
if (existsSync(headPath)) return headPath;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) return null;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
private extensionStatuses = new Map<string, string>();
|
|
private cachedBranch: string | null | undefined = undefined;
|
|
private gitWatcher: FSWatcher | null = null;
|
|
private branchChangeCallbacks = new Set<() => void>();
|
|
private availableProviderCount = 0;
|
|
|
|
constructor() {
|
|
this.setupGitWatcher();
|
|
}
|
|
|
|
/** Current git branch, null if not in repo, "detached" if detached HEAD */
|
|
getGitBranch(): string | null {
|
|
if (this.cachedBranch !== undefined) return this.cachedBranch;
|
|
|
|
try {
|
|
const gitHeadPath = findGitHeadPath();
|
|
if (!gitHeadPath) {
|
|
this.cachedBranch = null;
|
|
return null;
|
|
}
|
|
const content = readFileSync(gitHeadPath, "utf8").trim();
|
|
this.cachedBranch = content.startsWith("ref: refs/heads/") ? content.slice(16) : "detached";
|
|
} catch {
|
|
this.cachedBranch = null;
|
|
}
|
|
return this.cachedBranch;
|
|
}
|
|
|
|
/** Extension status texts set via ctx.ui.setStatus() */
|
|
getExtensionStatuses(): ReadonlyMap<string, string> {
|
|
return this.extensionStatuses;
|
|
}
|
|
|
|
/** Subscribe to git branch changes. Returns unsubscribe function. */
|
|
onBranchChange(callback: () => void): () => void {
|
|
this.branchChangeCallbacks.add(callback);
|
|
return () => this.branchChangeCallbacks.delete(callback);
|
|
}
|
|
|
|
/** Internal: set extension status */
|
|
setExtensionStatus(key: string, text: string | undefined): void {
|
|
if (text === undefined) {
|
|
this.extensionStatuses.delete(key);
|
|
} else {
|
|
this.extensionStatuses.set(key, text);
|
|
}
|
|
}
|
|
|
|
/** Internal: clear extension statuses */
|
|
clearExtensionStatuses(): void {
|
|
this.extensionStatuses.clear();
|
|
}
|
|
|
|
/** Number of unique providers with available models (for footer display) */
|
|
getAvailableProviderCount(): number {
|
|
return this.availableProviderCount;
|
|
}
|
|
|
|
/** Internal: update available provider count */
|
|
setAvailableProviderCount(count: number): void {
|
|
this.availableProviderCount = count;
|
|
}
|
|
|
|
/** Internal: cleanup */
|
|
dispose(): void {
|
|
if (this.gitWatcher) {
|
|
this.gitWatcher.close();
|
|
this.gitWatcher = null;
|
|
}
|
|
this.branchChangeCallbacks.clear();
|
|
}
|
|
|
|
private setupGitWatcher(): void {
|
|
if (this.gitWatcher) {
|
|
this.gitWatcher.close();
|
|
this.gitWatcher = null;
|
|
}
|
|
|
|
const gitHeadPath = findGitHeadPath();
|
|
if (!gitHeadPath) return;
|
|
|
|
// 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.
|
|
const gitDir = dirname(gitHeadPath);
|
|
|
|
try {
|
|
this.gitWatcher = watch(gitDir, (_eventType, filename) => {
|
|
if (filename === "HEAD") {
|
|
this.cachedBranch = undefined;
|
|
for (const cb of this.branchChangeCallbacks) cb();
|
|
}
|
|
});
|
|
} catch {
|
|
// Silently fail if we can't watch
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Read-only view for extensions - excludes setExtensionStatus, setAvailableProviderCount and dispose */
|
|
export type ReadonlyFooterDataProvider = Pick<
|
|
FooterDataProvider,
|
|
"getGitBranch" | "getExtensionStatuses" | "getAvailableProviderCount" | "onBranchChange"
|
|
>;
|