import { atom, computed, type ReadableAtom } from 'nanostores' import type { HermesGitWorktree, HermesRepoStatus } from '@/global' import { desktopGit } from '@/lib/desktop-git' import { $projectScope, $projectTree, $worktreeDialog, $worktreeRefreshToken, ALL_PROJECTS, projectRootCwd } from './projects' import { $busy, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner, workspaceCwdBelongsToSelectedSession } from './session' import { $focusedRuntimeId, $sessionStates } from './session-states' import { $workspaceChangeTick } from './workspace-events' // Live working-tree status for every git surface on screen — the data backbone // of the composer coding rail. Keyed PER CWD: the main pane and each session // tile can sit in different worktrees, so a single global status would paint // the primary repo's branch/± onto every rail (the "tile shows main's diff" // bug). It's the same "cheaply re-read git truth at the right moments" model // as the sidebar worktree probe: a single bounded `git status --porcelain=v2` // per on-screen worktree per refresh, driven by structural edges (cwd change, // turn settle, window focus, worktree mutation), never per-token and never // touching the conversation/system-prompt cache. const REPO_STATUS_REFRESH_DEBOUNCE_MS = 100 const normalizeCwd = (cwd?: null | string): null | string => cwd?.trim() || null const EMPTY_WORKTREES: HermesGitWorktree[] = [] // Status + worktrees per normalized cwd. Entries outlive their surface (the // map stays bounded by the worktrees touched this run) so re-opening a tile // paints its last-known status instantly while the fresh probe runs. export const $repoStatusByCwd = atom>({}) export const $repoWorktreesByCwd = atom>({}) // The PRIMARY (main pane) view — the active session's slice of the per-cwd // truth. Existing consumers (keybind gate, base-branch picker, file tree) keep // reading these; only surfaces that can live in ANOTHER worktree (tile rails) // need the per-cwd accessors below. During a conversation switch `$currentCwd` // can still name the previous conversation's path, so ownership hides only this // primary slice; the per-cwd cache stays available to any tile that genuinely // owns that worktree (#71254). export const $repoStatus: ReadableAtom = computed( [$repoStatusByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner], (byCwd, cwd) => (workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? null) : null) ) export const $repoStatusLoading = atom(false) // The repo's real worktrees (for the coding rail's "jump to a worktree" menu). // Refreshed on the same edges as the status probe; empty off a repo. export const $repoWorktrees: ReadableAtom = computed( [$repoWorktreesByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner], (byCwd, cwd) => workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES ) // Reference-stable per-cwd slices, so any number of rails can each subscribe // to their own worktree's status without re-deriving atoms per render. const statusAtomByCwd = new Map>() const worktreesAtomByCwd = new Map>() const $noRepoStatus = atom(null) const $noWorktrees = atom(EMPTY_WORKTREES) /** Reactive status for one repo cwd (a tile's worktree). Stable per cwd. */ export function repoStatusForCwd(cwd?: null | string): ReadableAtom { const key = normalizeCwd(cwd) if (!key) { return $noRepoStatus } let $slice = statusAtomByCwd.get(key) if (!$slice) { $slice = computed($repoStatusByCwd, byCwd => byCwd[key] ?? null) statusAtomByCwd.set(key, $slice) } return $slice } /** * Is this path a git repo? This function reads the probe cache, and probes on * demand when the cache has no entry for the path. Use it to validate any repo * that was picked out of candidate FOLDERS: a path in a project row is not * evidence that git can branch from it. On a remote gateway the probe is * backend-routed (`desktopGit()` returns the REST mirror), so a VPS path is * judged by the VPS's git — not by this machine's filesystem (#81724). */ export async function isGitRepoPath(cwd: string): Promise { const key = normalizeCwd(cwd) if (!key) { return false } if (key in $repoStatusByCwd.get()) { return $repoStatusByCwd.get()[key] !== null } if (!desktopGit()?.repoStatus) { return false } await refreshRepoStatus(key) return ($repoStatusByCwd.get()[key] ?? null) !== null } /** Reactive worktree list for one repo cwd. Stable per cwd. */ export function repoWorktreesForCwd(cwd?: null | string): ReadableAtom { const key = normalizeCwd(cwd) if (!key) { return $noWorktrees } let $slice = worktreesAtomByCwd.get(key) if (!$slice) { $slice = computed($repoWorktreesByCwd, byCwd => byCwd[key] ?? EMPTY_WORKTREES) worktreesAtomByCwd.set(key, $slice) } return $slice } export type RepoChangeKind = 'added' | 'conflicted' | 'modified' // Absolute file path → its git change kind, for VS Code-style file-tree tinting. // Reuses the same bounded $repoStatus probe (capped file list); git reports // repo-root-relative paths, so we join them onto the active cwd. Deletions never // appear — the file is gone from disk, so there's no tree row to tint. export const $repoChangeByPath = computed([$repoStatus, $currentCwd], (status, cwd) => { const map = new Map() const root = (cwd || '').replace(/[/\\]+$/, '') if (!status || !root) { return map } for (const file of status.files) { const kind: RepoChangeKind = file.conflicted ? 'conflicted' : file.untracked ? 'added' : 'modified' map.set(`${root}/${file.path}`, kind) } return map }) /** * Per-row Git decoration subscription. A visible file row reads one scalar, so * a fresh repo-status map only re-renders that row when its own kind changed. */ export function repoChangeKindForPath(path: string): ReadableAtom { return computed($repoChangeByPath, changes => changes.get(path)) } // Cwds whose rails are on screen right now (refcounted — two tiles in one // worktree register it twice). Every refresh edge re-probes each registered // cwd plus the primary workspace, so a tile's rail moves when ITS agent // touches the tree — not only when main's does. const registeredCwds = new Map() /** Keep `cwd` in the refresh set while its rail is mounted. Returns a release * (undefined for a blank cwd), kicking off an immediate probe on register. */ export function registerRepoStatusCwd(cwd?: null | string): (() => void) | undefined { const key = normalizeCwd(cwd) if (!key) { return undefined } registeredCwds.set(key, (registeredCwds.get(key) ?? 0) + 1) scheduleRepoStatusRefresh(key) let released = false return () => { if (released) { return } released = true const count = registeredCwds.get(key) ?? 0 if (count <= 1) { registeredCwds.delete(key) } else { registeredCwds.set(key, count - 1) } } } function setRepoStatusEntry(target: string, status: HermesRepoStatus | null): void { const byCwd = $repoStatusByCwd.get() // Skip the no-op null→null write so a repeated failed probe doesn't churn // every rail's computed slice. if (target in byCwd && byCwd[target] === status) { return } $repoStatusByCwd.set({ ...byCwd, [target]: status }) } function setRepoWorktreesEntry(target: string, worktrees: HermesGitWorktree[]): void { const byCwd = $repoWorktreesByCwd.get() if (target in byCwd && byCwd[target] === worktrees) { return } $repoWorktreesByCwd.set({ ...byCwd, [target]: worktrees }) } interface RepoStatusRefreshRequest { probe: (cwd: string) => Promise seq: number target: string } // Coalesce overlapping probes: many triggers can fire around a turn boundary // (busy flip + worktree token + focus), and several worktrees can need a // re-probe at once. Keep ONE probe in flight and at most one trailing request // per cwd so a slow `git status` cannot multiply into an unbounded subprocess // pile-up. const seqByCwd = new Map() const pendingByCwd = new Map() let repoStatusRefreshInFlight: Promise | null = null let repoStatusRefreshTimer: ReturnType | null = null const scheduledCwds = new Set() let scheduledAllTargets = false // A result only lands while it is still the newest request for ITS cwd. The // debounce below deliberately delays the next probe; without this live check, // an old probe can land during that gap and briefly make a session look like // it is on a stale branch. const isCurrentSeq = (target: string, seq: number): boolean => seqByCwd.get(target) === seq async function loadWorktrees(target: string, seq: number): Promise { const list = desktopGit()?.worktreeList if (!list) { setRepoWorktreesEntry(target, EMPTY_WORKTREES) return } try { const worktrees = await list(target) if (isCurrentSeq(target, seq)) { setRepoWorktreesEntry(target, worktrees) } } catch { if (isCurrentSeq(target, seq)) { setRepoWorktreesEntry(target, EMPTY_WORKTREES) } } } /** * Re-probe the working tree for one cwd. Best-effort: a non-repo, a remote * backend, or a missing probe clears that cwd's entry so its rail hides * rather than showing stale data. */ async function runRepoStatusRefresh({ probe, seq, target }: RepoStatusRefreshRequest): Promise { try { const status = await probe(target) // Drop the result if a newer refresh for this cwd started while we were // probing — the newer probe owns the entry. if (!isCurrentSeq(target, seq)) { return } setRepoStatusEntry(target, status) // Worktrees only matter inside a repo; clear them otherwise. if (status) { void loadWorktrees(target, seq) } else { setRepoWorktreesEntry(target, EMPTY_WORKTREES) } } catch { if (isCurrentSeq(target, seq)) { setRepoStatusEntry(target, null) setRepoWorktreesEntry(target, EMPTY_WORKTREES) } } } async function drainRepoStatusRefreshes(): Promise { while (pendingByCwd.size > 0) { const [target, request] = pendingByCwd.entries().next().value as [string, RepoStatusRefreshRequest] pendingByCwd.delete(target) await runRepoStatusRefresh(request) } // This reset is synchronous with the final empty-queue check. A refresh // arriving before this continuation runs is drained above; one arriving // afterward sees no in-flight promise and starts a new drain. repoStatusRefreshInFlight = null $repoStatusLoading.set(false) } export function refreshRepoStatus(cwd?: null | string): Promise { const target = normalizeCwd(cwd ?? $currentCwd.get()) const probe = desktopGit()?.repoStatus if (!probe) { // Remote backend / no bridge: there is no local git truth at all — wipe // every entry so no rail shows stale local status, and invalidate any // in-flight probe results. pendingByCwd.clear() seqByCwd.clear() $repoStatusByCwd.set({}) $repoWorktreesByCwd.set({}) $repoStatusLoading.set(false) return repoStatusRefreshInFlight || Promise.resolve() } if (!target) { // No cwd (a detached fresh chat). The primary computed already reads null // for a blank cwd, and other worktrees' entries stay valid. return repoStatusRefreshInFlight || Promise.resolve() } const seq = (seqByCwd.get(target) ?? 0) + 1 seqByCwd.set(target, seq) pendingByCwd.set(target, { probe, seq, target }) $repoStatusLoading.set(true) if (!repoStatusRefreshInFlight) { repoStatusRefreshInFlight = drainRepoStatusRefreshes() } return repoStatusRefreshInFlight } /** Registered (on-screen) worktrees + the primary workspace. */ function refreshTargets(): Set { const targets = new Set(registeredCwds.keys()) const primary = normalizeCwd($currentCwd.get()) if (primary) { targets.add(primary) } return targets } /** Re-probe every on-screen worktree (and the primary). Awaits the drain. */ export async function refreshAllRepoStatuses(): Promise { const targets = refreshTargets() if (targets.size === 0) { return } // Queue every target, then await the single in-flight drain once. let last: Promise = Promise.resolve() for (const target of targets) { last = refreshRepoStatus(target) } await last } // `cwd` scopes the refresh to one worktree; omit it to re-probe every // on-screen worktree (turn settle, window focus — the tree may have changed // under any of them). function scheduleRepoStatusRefresh(cwd?: null | string): void { if (cwd === undefined) { scheduledAllTargets = true } else { const key = normalizeCwd(cwd) if (key) { scheduledCwds.add(key) } } if (repoStatusRefreshTimer) { clearTimeout(repoStatusRefreshTimer) } repoStatusRefreshTimer = setTimeout(() => { repoStatusRefreshTimer = null const targets = new Set(scheduledCwds) if (scheduledAllTargets) { for (const target of refreshTargets()) { targets.add(target) } } scheduledCwds.clear() scheduledAllTargets = false for (const target of targets) { void refreshRepoStatus(target) } }, REPO_STATUS_REFRESH_DEBOUNCE_MS) } // ── Triggers ───────────────────────────────────────────────────────────────── // Wired once at module load (mirrors projects.ts's module-scope subscriptions). // Each is a structural edge where a working tree may have changed under us. // The active session's cwd changed (session switch / new chat) → the primary // computed re-keys instantly (a keyboard action in the switch-to-probe gap can // only ever see the NEW repo's last-known facts, never the previous // worktree's), then the debounced probe refreshes that repo's truth. $currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd)) // Switching sessions can land on the same cwd but a different checked-out // branch (the agent ran `git checkout` in another session's terminal). The cwd // subscription above won't fire when the path is identical, so the branch label // would stay stale until a window focus or turn-settle triggers a refresh. // Treat the stored-session id as a structural edge in its own right. $selectedStoredSessionId.subscribe(() => scheduleRepoStatusRefresh()) // A worktree add/remove (desktop op, or the agent's out-of-band git in a settled // turn / a window refocus — both already bump this token) → re-probe. $worktreeRefreshToken.subscribe(() => scheduleRepoStatusRefresh()) // A file-mutating tool finished (event-driven, not polled) → re-probe so the // rails' branch/+/- move exactly when an agent touches a tree. Unscoped on // purpose: the tool may belong to a background tile's session, so every // on-screen worktree re-probes, not just the primary. $workspaceChangeTick.subscribe(() => scheduleRepoStatusRefresh()) // A turn settling is the backstop for changes no tool diff announced (e.g. a // raw `git` in the terminal): one final refresh when the agent goes idle. let prevBusy = $busy.get() $busy.subscribe(busy => { if (prevBusy && !busy) { scheduleRepoStatusRefresh() } prevBusy = busy }) // Window focus: external changes while we were away (an outside terminal). if (typeof window !== 'undefined') { window.addEventListener('focus', () => scheduleRepoStatusRefresh()) } /** Test-only: drop in-flight / pending / registered state so cases don't leak. */ export function _resetCodingStatusForTests(): void { if (repoStatusRefreshTimer) { clearTimeout(repoStatusRefreshTimer) repoStatusRefreshTimer = null } scheduledCwds.clear() scheduledAllTargets = false pendingByCwd.clear() seqByCwd.clear() registeredCwds.clear() repoStatusRefreshInFlight = null $repoStatusByCwd.set({}) $repoWorktreesByCwd.set({}) $repoStatusLoading.set(false) } // ── New-worktree target resolution ─────────────────────────────────────────── // This code lives here and not in projects.ts. To pick the target, it must read // both the project state and the git truth, and coding-status already depends // on projects. A dependency in the other direction is a cycle. // The repo that a new worktree is cut from: the cwd of the focused surface, or // the root of the project the user entered. Both are things the user points at. // There is no "use some other project's repo" step, because that branches // somewhere the user never selected. // // A project root is not always a repo, so existence alone is not proof. Each // candidate is validated against the probe cache. This function is the only // authority on the target, so the hotkey no longer tests `$repoStatus` first, // and ⌘⇧B now works from a detached session inside a project. '' means that no // repo is in reach. That is a no-op and not an error, because a worktree only // exists inside a repo. export async function resolveWorktreeRepoPath(): Promise { const runtimeId = $focusedRuntimeId.get() const scope = $projectScope.get() const candidates = [ runtimeId ? ($sessionStates.get()[runtimeId]?.cwd ?? '') : '', scope === ALL_PROJECTS ? '' : projectRootCwd($projectTree.get().find(node => node.id === scope)) ] for (const candidate of candidates) { const path = candidate.trim() if (path && (await isGitRepoPath(path))) { return path } } return '' } export async function openWorktreeDialog(options?: { base?: string; repoPath?: string }): Promise { const repoPath = options?.repoPath?.trim() || (await resolveWorktreeRepoPath()) if (repoPath) { $worktreeDialog.set({ base: options?.base, repoPath }) } }