mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
perf(desktop): targeted file-tree revalidation instead of whole-tree rescan
Rewrite of the paradigm, not just a cheaper version of it. Before, any file mutation bumped a contentless $workspaceChangeTick and the tree re-read EVERY loaded directory to diff — the parent state was never told what actually changed. Now the mutation carries its path: - workspace-events accumulates the changed dir(s) (dirname of an absolute tool path) and exposes consumeWorkspaceChange(); an opaque mutation (terminal, or a relative/unresolvable path) sets `full` instead. - gateway-event passes toolChangedPath(payload) through on tool.complete. - revalidateTree(cwd, change) re-reads ONLY the changed dirs that are loaded and patches just those subtrees — root + untouched folders never hit the FS or re-render. Full recursive reconcile is kept as the fallback for `full`. So a write in one folder no longer crawls the whole tree; the opaque terminal case still self-heals via the full path. Safe fallback everywhere a path can't be resolved, so no change is ever missed. typecheck + eslint clean; use-project-tree / right-sidebar / gateway-events tests green.
This commit is contained in:
parent
ae15742bc2
commit
0aa64ffcfc
3 changed files with 143 additions and 18 deletions
|
|
@ -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,21 +261,55 @@ async function revalidateTree(cwd: string): Promise<void> {
|
|||
}
|
||||
|
||||
const rootPath = state.resolvedCwd || cwd
|
||||
// No cache wipe per tick: listings are always read fresh (readProjectDir
|
||||
// doesn't cache them), so wiping only re-read every ancestor .gitignore each
|
||||
// ~500ms edit tick — sticky mid-burst. A .gitignore edit lands on next refresh.
|
||||
|
||||
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]))
|
||||
|
||||
// Reconcile siblings concurrently (Promise.all keeps order); loaded
|
||||
// subfolders recurse. Serial awaits crawled a wide/deep tree one dir per tick.
|
||||
return Promise.all(
|
||||
entries.map(async entry => {
|
||||
const prev = byId.get(entry.path)
|
||||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue