Mobile header is now two rows. Row 1: hamburger | project · branch indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker | FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) + NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch panes via the sheet. Cross-tab status sync via chat_status frames published from inference.ts at working/idle/error transitions; StatusDot component renders amber-pulse/green/red/gray on each pane row and on desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only git_status tool to the model, backed by services/git_meta.ts (execFile + 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks as props (hoisted into Session.tsx) so the header pill shares state with the pane grid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
2.8 KiB
TypeScript
93 lines
2.8 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const CACHE_TTL_MS = 30_000;
|
|
const GIT_TIMEOUT_MS = 2_000;
|
|
// Cap stdout size so a pathological repo can't blow the buffer. Branch + status
|
|
// porcelain + diverge counts never approach this on a real repo.
|
|
const GIT_MAX_BUFFER = 1024 * 1024;
|
|
|
|
export interface GitMeta {
|
|
branch: string | null;
|
|
is_dirty: boolean;
|
|
ahead: number;
|
|
behind: number;
|
|
}
|
|
|
|
interface CacheEntry {
|
|
at: number;
|
|
value: GitMeta | null;
|
|
}
|
|
|
|
const cache = new Map<string, CacheEntry>();
|
|
|
|
// Runs a single git invocation with a hard 2s timeout. Returns null on any
|
|
// failure (non-zero exit, timeout, git not installed) so callers can decide
|
|
// how to degrade. Stderr is intentionally swallowed; we don't surface git's
|
|
// error text to the model or UI.
|
|
async function runGit(args: string[], cwd: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await execFileAsync('git', args, {
|
|
cwd,
|
|
timeout: GIT_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
maxBuffer: GIT_MAX_BUFFER,
|
|
});
|
|
return stdout.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getGitMeta(rootPath: string): Promise<GitMeta | null> {
|
|
const cached = cache.get(rootPath);
|
|
const now = Date.now();
|
|
if (cached && now - cached.at < CACHE_TTL_MS) {
|
|
return cached.value;
|
|
}
|
|
|
|
// Three calls in parallel. rev-parse establishes repo + branch name;
|
|
// status --porcelain detects dirtiness with no false-positives from formatting;
|
|
// rev-list --left-right --count compares HEAD to upstream and is allowed to
|
|
// fail silently (returns null → ahead/behind = 0) when no upstream is set.
|
|
const [branchOut, statusOut, divergedOut] = await Promise.all([
|
|
runGit(['rev-parse', '--abbrev-ref', 'HEAD'], rootPath),
|
|
runGit(['status', '--porcelain'], rootPath),
|
|
runGit(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], rootPath),
|
|
]);
|
|
|
|
// If rev-parse fails, this isn't a git repo (or git isn't installed). Cache
|
|
// the null result so the next 30s of requests don't re-probe.
|
|
if (branchOut === null) {
|
|
cache.set(rootPath, { at: now, value: null });
|
|
return null;
|
|
}
|
|
|
|
const branch = branchOut.trim() || null;
|
|
const is_dirty = statusOut !== null && statusOut.trim().length > 0;
|
|
|
|
let ahead = 0;
|
|
let behind = 0;
|
|
if (divergedOut !== null) {
|
|
const match = divergedOut.trim().match(/^(\d+)\s+(\d+)/);
|
|
if (match) {
|
|
ahead = Number(match[1]);
|
|
behind = Number(match[2]);
|
|
}
|
|
}
|
|
|
|
const value: GitMeta = { branch, is_dirty, ahead, behind };
|
|
cache.set(rootPath, { at: now, value });
|
|
return value;
|
|
}
|
|
|
|
export function invalidateGitMetaCache(rootPath?: string): void {
|
|
if (rootPath) {
|
|
cache.delete(rootPath);
|
|
} else {
|
|
cache.clear();
|
|
}
|
|
}
|