Merge pull request #67824 from NousResearch/perf/desktop-tree-revalidate

perf(desktop): targeted file-tree revalidation (only the changed subtree)
This commit is contained in:
brooklyn! 2026-07-19 22:26:01 -05:00 committed by GitHub
commit 7f56f89706
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 152 additions and 27 deletions

View file

@ -3,9 +3,9 @@ import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { $workspaceChangeTick } from '@/store/workspace-events'
import { $workspaceChangeTick, consumeWorkspaceChange } from '@/store/workspace-events'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { clearProjectDirCache, type ProjectTreeEntry, readProjectDir } from './ipc'
export interface TreeNode {
/** Absolute filesystem path. Doubles as react-arborist node id. */
@ -48,6 +48,33 @@ function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n:
})
}
function findNode(nodes: TreeNode[], id: string): null | TreeNode {
for (const node of nodes) {
if (node.id === id) {
return node
}
if (node.children?.length) {
const hit = findNode(node.children, id)
if (hit) {
return hit
}
}
}
return null
}
// Merge a freshly-read dir's entries into its existing children: keep surviving
// nodes (subtrees intact), add new, drop deleted. Non-recursive — a grandchild
// dir only re-reads when it's itself in the change set.
function mergeChildren(existing: TreeNode[], entries: ProjectTreeEntry[]): TreeNode[] {
const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node]))
return entries.map(entry => byId.get(entry.path) ?? makeNode(entry.path, entry.name, entry.isDirectory))
}
function placeholderChild(parentId: string): TreeNode {
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…', placeholder: 'loading' }
}
@ -220,12 +247,13 @@ export function resetProjectTreeState() {
clearProjectDirCache()
}
// Non-destructive refresh: re-read every currently-loaded directory and merge
// entries (add new files/folders, drop deleted ones) while preserving expansion
// and already-loaded subtrees. Unlike `loadRoot({force})` this never collapses
// the tree, so it's safe to run live as the agent edits — and because node ids
// (absolute paths) stay stable across merges, rows can animate in/out.
async function revalidateTree(cwd: string): Promise<void> {
// Non-destructive live refresh as the agent edits: preserves expansion + loaded
// subtrees (stable absolute-path ids let rows animate in/out), never collapses.
// Targeted by default — re-reads only the changed dirs in `change`; the root and
// untouched folders never touch the filesystem or re-render. Falls back to
// re-reading every loaded dir only when the mutation is opaque (a terminal
// command / a path we couldn't resolve) — see store/workspace-events.
async function revalidateTree(cwd: string, change: { dirs: string[]; full: boolean }): Promise<void> {
const state = $projectTree.get()
if (!cwd || state.cwd !== cwd || !state.loaded) {
@ -233,32 +261,66 @@ async function revalidateTree(cwd: string): Promise<void> {
}
const rootPath = state.resolvedCwd || cwd
clearProjectDirCache()
if (!change.full && change.dirs.length) {
// Only re-read changed dirs that are actually loaded (root, or an expanded
// folder); a change inside a collapsed/absent dir isn't visible → skip.
const targets = change.dirs.filter(dir => dir === rootPath || findNode(state.data, dir)?.children)
if (!targets.length) {
return
}
const reads = await Promise.all(targets.map(async dir => ({ dir, ...(await readProjectDir(dir, rootPath)) })))
setProjectTree(latest => {
if (latest.cwd !== cwd || !latest.loaded) {
return latest
}
let data = latest.data
for (const { dir, entries, error } of reads) {
if (error) {
continue // keep last-known children on a transient read error
}
data =
dir === rootPath
? mergeChildren(data, entries)
: patchNode(data, dir, node =>
node.children ? { ...node, children: mergeChildren(node.children, entries) } : node
)
}
return data === latest.data ? latest : { ...latest, data }
})
return
}
// Opaque fallback: reconcile every loaded dir. Siblings read concurrently
// (Promise.all keeps order); loaded subfolders recurse.
const reconcile = async (dirPath: string, existing: TreeNode[]): Promise<TreeNode[]> => {
const { entries, error } = await readProjectDir(dirPath, rootPath)
if (error) {
return existing // keep the last-known children on a transient read error
return existing
}
const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node]))
const merged: TreeNode[] = []
for (const entry of entries) {
const prev = byId.get(entry.path)
return Promise.all(
entries.map(async entry => {
const prev = byId.get(entry.path)
if (prev?.isDirectory && prev.children) {
// Loaded folder: recurse so deep edits surface without a re-expand.
merged.push({ ...prev, children: await reconcile(prev.id, prev.children) })
} else if (prev) {
merged.push(prev)
} else {
merged.push(makeNode(entry.path, entry.name, entry.isDirectory))
}
}
if (prev?.isDirectory && prev.children) {
return { ...prev, children: await reconcile(prev.id, prev.children) }
}
return merged
return prev ?? makeNode(entry.path, entry.name, entry.isDirectory)
})
)
}
const nextData = await reconcile(rootPath, state.data)
@ -360,7 +422,7 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
// very first render: tick 0 is the initial value, not a real change).
useEffect(() => {
if (workspaceTick > 0) {
void revalidateTree(cwd)
void revalidateTree(cwd, consumeWorkspaceChange())
}
}, [workspaceTick, cwd])

View file

@ -46,7 +46,7 @@ import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent }
import { clearActiveSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import { reportInstallMethodWarning } from '@/store/updates'
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../../types'
@ -554,7 +554,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// (coding rail, review pane, file tree) to refresh. Event-driven, not
// polled: fires exactly when the agent touches the tree.
if (payload && toolMayMutateFiles(payload)) {
notifyWorkspaceChanged()
notifyWorkspaceChanged(toolChangedPath(payload))
}
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
if (sessionId && payload && !sessionInterrupted(sessionId)) {

View file

@ -9,6 +9,33 @@ import { atom } from 'nanostores'
export const $workspaceChangeTick = atom(0)
// What changed since the last consume. The file tree targets `dirs` (surgical
// subtree re-reads) and only falls back to a whole-tree rescan when `full` is
// set — an opaque mutation (a terminal command, or a path we can't resolve to
// the tree's absolute ids) whose touched paths we can't enumerate. Coarse
// subscribers (coding rail, review) ignore this and just react to the tick.
let pendingDirs = new Set<string>()
let pendingFull = false
/** Drain the accumulated change since the previous call (the tree's consumer). */
export function consumeWorkspaceChange(): { dirs: string[]; full: boolean } {
const change = { dirs: [...pendingDirs], full: pendingFull }
pendingDirs = new Set()
pendingFull = false
return change
}
// Parent dir of an ABSOLUTE path (POSIX or `C:/…`); null for a relative path we
// can't anchor to the tree — the caller treats null as "rescan to be safe".
function dirOf(path: string): null | string {
const p = path.replace(/\\/g, '/').replace(/\/+$/, '')
const absolute = p.startsWith('/') || /^[a-z]:\//i.test(p)
const slash = p.lastIndexOf('/')
return absolute && slash >= 0 ? p.slice(0, slash) : null
}
// Throttle so a burst of edits in one turn coalesces: fire on the leading edge
// for instant feedback, then at most once per window (a trailing fire catches
// the last edit of the burst).
@ -21,7 +48,17 @@ function fire(): void {
$workspaceChangeTick.set($workspaceChangeTick.get() + 1)
}
export function notifyWorkspaceChanged(): void {
/** @param changedPath absolute path a tool touched; omit (or pass a relative /
* unknowable path) to force a full-tree rescan. */
export function notifyWorkspaceChanged(changedPath?: string): void {
const dir = changedPath ? dirOf(changedPath) : null
if (dir) {
pendingDirs.add(dir)
} else {
pendingFull = true
}
const since = Date.now() - lastFired
if (since >= MIN_INTERVAL_MS) {
@ -58,3 +95,29 @@ export function toolMayMutateFiles(payload: { name?: unknown; tool?: unknown; in
return MUTATING_TOOL_RE.test(name)
}
// Common arg keys a single-file writer/mover uses for its target. A hit lets the
// tree target that dir; a miss (terminal, multi-path, odd schema) → full rescan.
const PATH_ARG_KEYS = ['path', 'file_path', 'filename', 'file', 'target_file', 'new_path', 'dest', 'destination']
/** Best-effort absolute path a finished tool touched, from its args or
* undefined ( full rescan) for terminal/opaque/multi-path mutations. */
export function toolChangedPath(payload: { args?: unknown; arguments?: unknown }): string | undefined {
const args = payload.args ?? payload.arguments
if (!args || typeof args !== 'object') {
return undefined
}
const record = args as Record<string, unknown>
for (const key of PATH_ARG_KEYS) {
const value = record[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return undefined
}