527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
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<Record<string, HermesRepoStatus | null>>({})
|
|
export const $repoWorktreesByCwd = atom<Record<string, HermesGitWorktree[]>>({})
|
|
|
|
// 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<HermesRepoStatus | null> = 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<HermesGitWorktree[]> = 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<string, ReadableAtom<HermesRepoStatus | null>>()
|
|
const worktreesAtomByCwd = new Map<string, ReadableAtom<HermesGitWorktree[]>>()
|
|
const $noRepoStatus = atom<HermesRepoStatus | null>(null)
|
|
const $noWorktrees = atom<HermesGitWorktree[]>(EMPTY_WORKTREES)
|
|
|
|
/** Reactive status for one repo cwd (a tile's worktree). Stable per cwd. */
|
|
export function repoStatusForCwd(cwd?: null | string): ReadableAtom<HermesRepoStatus | null> {
|
|
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<boolean> {
|
|
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<HermesGitWorktree[]> {
|
|
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<string, RepoChangeKind>()
|
|
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<RepoChangeKind | undefined> {
|
|
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<string, number>()
|
|
|
|
/** 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<HermesRepoStatus | null>
|
|
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<string, number>()
|
|
const pendingByCwd = new Map<string, RepoStatusRefreshRequest>()
|
|
let repoStatusRefreshInFlight: Promise<void> | null = null
|
|
let repoStatusRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
|
const scheduledCwds = new Set<string>()
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<void> {
|
|
const targets = refreshTargets()
|
|
|
|
if (targets.size === 0) {
|
|
return
|
|
}
|
|
|
|
// Queue every target, then await the single in-flight drain once.
|
|
let last: Promise<void> = 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<string> {
|
|
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<void> {
|
|
const repoPath = options?.repoPath?.trim() || (await resolveWorktreeRepoPath())
|
|
|
|
if (repoPath) {
|
|
$worktreeDialog.set({ base: options?.base, repoPath })
|
|
}
|
|
}
|