Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,526 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user