A file tree looks like a read-only view of a directory. In a coding agent, it is closer to a live distributed projection.
The filesystem changes outside the browser. A Java watcher observes platform events. A WebSocket transports them. JavaScript updates a partially expanded DOM. Meanwhile, the user can collapse a directory, reconnect the page, switch workspaces, or ask the agent to create a whole package tree in one turn.
That means a file event is useful, but it is not the file tree itself. It is a hint that helps the UI move toward the current filesystem state.
SolonCode's public source provides a compact case study in that distinction. Its workspace tree combines recursive watching, change coalescing, incremental DOM patches, lazy reconciliation, and reconnect-time refresh. The interesting part is not any single function. It is the boundary between the event stream and the authoritative directory scan.
The real SolonCode Web file panel, captured from a clean demo workspace. The screenshot is cropped to the file panel and contains no model, endpoint, account, session, or local absolute path.
This article follows the public repository at commit 58c19665. That snapshot is later than the latest formal release verified while writing, v2026.8.30, so treat this as a source analysis rather than a guarantee about every installed binary.
The synchronization pipeline
At a high level, the path is:
filesystem
-> JDK WatchService
-> ChangeEntry(wsId, path, kind, type)
-> net-effect merge by workspace + path
-> system.filer_change over WebGate
-> onFilerChange(payload)
-> incremental DOM update or lazy reconciliation
The backend and frontend have different responsibilities.
The backend knows which physical root changed and converts platform events into a small transport contract. The frontend knows which parts of the tree are currently rendered, expanded, collapsed, or absent. Neither side alone owns the complete user-visible state.
That division is important. Sending the whole tree after every save would be simple but expensive and disruptive. Sending only raw events would be cheap but would force the browser to pretend that it has a complete, perfectly ordered log. SolonCode takes a hybrid approach.
One watcher, multiple roots
A SolonCode workspace can expose more than its launch directory. It can also include enabled mounts. In WorkspaceManager.doCreateWorkspaceContext(), each workspace context creates its own FileWatchService, registers the launch workspace as the workspace root, and then registers enabled mounts.
The root ID travels with every change:
public static class ChangeEntry {
public final String wsId;
public final String path;
public final String kind;
public final String type;
}
This prevents a path such as src/App.java in one mount from being confused with the same relative path in another.
The mount type also determines the downstream action:
- a
FILESmount broadcasts file changes to the Web UI; - a
SKILLSmount refreshes the corresponding Skill group; - an
AGENTSmount refreshes the matching agent definition.
So the watcher is shared infrastructure, but its handlers preserve domain boundaries. Not every filesystem event is a file-tree event.
Recursive registration is part of correctness
JDK WatchService registers directories, not an abstract recursive tree. SolonCode's registerTree() walks the existing directory hierarchy and registers each relevant directory for create, delete, and modify events.
The second half is easy to miss: when pollEvents() observes a newly created directory, it calls registerTree() for that new subtree. Without that step, the UI might show the new directory but never hear about files created inside it later.
The watcher also excludes noisy or irrelevant directories such as:
.git
node_modules
target
build
.gradle
.mvn
This is not merely a performance tweak. A dependency installation or build can produce thousands of events that do not belong in the coding agent's navigational tree. Filtering them at the watcher boundary reduces transport noise and avoids unnecessary DOM work.
The public test suite covers both sides of this behavior: changes inside a newly created nested directory are detected, while changes under an excluded .git directory are not dispatched.
Coalesce operations into a net effect
A single human action rarely maps to one low-level filesystem event. Saving a file may create, modify, replace, or delete temporary paths. Generators can emit bursts. Editors may use an atomic-write pattern.
SolonCode does not put every observation directly onto the WebSocket. Pending changes are keyed by workspace ID + relative path, and mergeChange() reduces repeated operations to a structural net effect.
The verified rules include:
create + modify -> create
create + delete -> no event
delete + create -> create
modify + delete -> delete
This small state machine matters more than a generic "debounce" label.
Imagine that src/NewFile.java is created and immediately modified before the current batch is delivered. The tree only needs to learn that a file now exists. It does not need two DOM operations. If a temporary file is created and deleted within the same pending window, the correct structural outcome is no node at all.
The implementation uses concurrent compare-and-replace operations in putChange(), so updates to the same key are merged without turning the entire pending map into one coarse lock.
After collection, flushChanges() groups entries by wsId and dispatches each root's list only to that root's handlers. A failing handler is caught so it does not prevent another handler from receiving its batch.
The event envelope is intentionally small
The browser receives a SAEP-style event named system.filer_change. Its useful shape is:
{
"event": "system.filer_change",
"timestamp": 1716153600000,
"payload": {
"changes": [
{
"wsId": "workspace",
"path": "src/Foo.java",
"kind": "create",
"type": "file"
}
],
"createdAt": 1716153600000
}
}
The event carries identity and intent, not file content and not a serialized tree.
That keeps the hot path small. It also makes the contract honest: the browser is told that a path was observed as created, deleted, or modified. It is not told that the message is an immutable transaction record or a complete filesystem snapshot.
app-streaming.js treats this as a system-level event without a chat session ID and routes the payload to onFilerChange().
Apply structural changes in dependency order
The frontend sorts each batch before touching the DOM:
- delete;
- modify;
- create.
Depth breaks ties:
- deletes run deepest path first;
- creates run shallowest path first.
That is a simple dependency rule. If a directory subtree disappears, removing children before parents avoids trying to operate through a parent that is already gone. If a new subtree appears, creating parents before descendants gives later operations somewhere to attach.
It also helps rename-like sequences. WatchService does not give the browser a domain-level rename command here; the visible effect can arrive as delete plus create. Processing deletions first reduces path conflicts in the same batch.
The ordering is not a claim that every operating system emits identical events. It is a defensive frontend rule for the events that do arrive.
Patch visible nodes; mark invisible branches dirty
applyFilerChange() separates content changes from structural changes.
-
deletecallsremoveTreeNode(); -
createcallsensureTreeNode(); -
modifynormally does nothing to the structure; - a modify event can still create a missing file node for compatibility with older or incomplete event shapes.
The key optimization appears when the parent directory is collapsed.
A collapsed branch does not need an immediate full child list. In fact, the browser may not have rendered those children at all. Instead of fetching and painting invisible nodes, SolonCode marks the parent with data-dirty="1".
When the user expands that directory later, the click handler sees the dirty marker, requests the real one-level directory listing, and replaces that branch from the authoritative scan.
This creates two update modes:
visible expanded branch -> patch now
collapsed/unrendered branch -> mark dirty, reconcile on expansion
That is a useful general pattern for large lazy trees. Event processing stays proportional to what the user can currently see, while a later read repairs uncertainty.
Insert without redrawing siblings
When the parent container is visible, insertNodeSorted() builds one new node and places it among existing siblings.
The ordering matches the backend tree service:
- directories before files;
- names compared case-insensitively within each group.
The function also checks for an existing node before insertion. That makes repeated or compatibility events harmless at the DOM level and, more importantly, leaves existing nodes untouched.
Leaving siblings untouched preserves their bound handlers and current UI state. A full innerHTML replacement might produce the same names but still collapse directories, reset focus, lose hover state, or move the scroll position.
This concern is visible in the project's history. The v2026.7.13 notes explicitly describe replacing refresh-driven behavior with dynamic deletion and addition because expanded file-tree nodes could collapse after a refresh. The implementation commit 14e6510 changes the Java watcher, the frontend tree code, and the watcher tests together.
Reconciliation still needs an authoritative read
Incremental events are the fast path, not the only path.
When the WebSocket reconnects, connectWebGate().onopen calls loadTree(). If a tree already exists, the call enters smartRefreshRoot() instead of treating the panel as an empty first load.
The refresh process:
- records expanded workspaces and directory paths;
- reloads the workspace-root list;
- rebuilds the visible roots;
- restores expanded workspaces;
- sorts saved paths by depth;
- reloads expanded directories serially, parent before child.
The directory requests are authoritative reads through the file-tree HTTP endpoint. This repairs the projection after a connection gap without pretending that the browser received every missed event.
Serial restoration is deliberate. A child node cannot be found until its parent directory has been loaded into the DOM. Depth sorting establishes the dependency order, and serial requests make that order explicit.
This is the larger design lesson:
Events provide low-latency hints. Reads provide truth.
A robust live tree usually needs both.
What the current public source does not claim
It is useful to separate implemented mechanisms from possible hardening work.
In the public source snapshot analyzed here, smartRefreshRoot() clears the live tree and then rebuilds it. The code does preserve expanded paths, but it does not implement a complete detached-tree build followed by one atomic swap.
The same snapshot does not show:
- a refresh generation token that rejects stale async results;
- a structural fingerprint that skips an unchanged DOM commit;
- explicit
scrollTopandscrollLeftrestoration; - a whole-refresh watchdog;
- per-request timeout handling inside the file-tree module;
- a frontend JavaScript test suite for refresh races.
Those are not hidden claims or implied features. They are useful review questions for the next reliability pass.
For example, once a refresh spans several requests, an older request can finish after a newer refresh has begun. A generation token can prevent stale work from committing. Building into a detached container can keep the old tree visible until the replacement is complete. A structural fingerprint can make a no-change refresh a zero-DOM-change operation. A watchdog can prevent a hung request chain from leaving a permanent in-flight state.
The correct article about a real codebase should say where the implementation stops, not quietly turn a design wish list into product documentation.
Test the semantics, not just the watcher callback
The current Java tests provide a solid backend baseline. They cover:
- file create, modify, and delete;
- changes in an existing subdirectory;
- automatic registration of a newly created directory;
- exclusion of
.gitchanges; - isolation between multiple roots;
- multiple handlers on one root;
- the
system.filer_changeJSON shape; - net-effect merge rules;
-
ChangeEntryequality.
A fuller end-to-end matrix would add browser-level assertions:
- a create event inserts one correctly sorted node;
- a delete removes the deepest visible node first;
- a collapsed parent becomes dirty but is not eagerly rendered;
- expanding a dirty parent replaces it from an HTTP listing;
- reconnect refresh preserves the expanded path set;
- events arriving during refresh do not disappear;
- an unchanged refresh does not disturb scroll or selection;
- a failed directory request leaves a recoverable UI.
The distinction matters because a watcher test can prove that Java observed a path while the browser still displays the wrong tree. Synchronization correctness crosses process and UI boundaries.
A reusable design checklist
For any live workspace tree, ask these questions:
Observation
- Are existing directories registered recursively?
- Are newly created directories registered too?
- Which generated or hidden directories are excluded?
- How are multiple workspace roots distinguished?
Reduction
- Are repeated changes merged by stable identity?
- What is the net effect of create/modify/delete sequences?
- Are temporary create-delete pairs eliminated?
Transport
- Does the event carry root, relative path, kind, and node type?
- Is the event a hint or a promised transaction log?
- What happens during disconnection?
Projection
- Are deletes and creates applied in dependency order?
- Can visible nodes be patched without replacing siblings?
- Are collapsed branches marked dirty and lazily reconciled?
Recovery
- Is there an authoritative directory-read path?
- Does reconnect trigger reconciliation?
- Are expanded paths restored parent-first?
- Can stale async work overwrite a newer result?
- Can a failed or hung refresh recover?
Verification
- Do tests cover event reduction as well as event capture?
- Do browser tests assert DOM stability, not only final node names?
- Are scroll, focus, selection, and expanded state part of the contract?
The tree is a projection, not a ledger
The most useful shift is conceptual.
A filesystem watcher is not the source of truth. A WebSocket is not the source of truth. The DOM is certainly not the source of truth. They are stages in a projection whose authority remains the workspace on disk.
SolonCode's design reflects that reality in practical layers:
- recursively observe relevant roots;
- merge noisy low-level events into structural outcomes;
- patch the visible tree incrementally;
- defer work for collapsed branches;
- reconcile from directory scans after reconnect;
- preserve expansion dependencies while rebuilding.
Once file events are treated as hints rather than facts, the architecture becomes easier to reason about. The fast path can stay fast, and the recovery path can remain honest.

Top comments (0)