Fix Workspace listings that stayed stale until manual refresh or F5. Reproduced the issue in the attached Tauri application: a host-created file was absent until the panel's Refresh button reread it. OpenCode filesystem events were the only invalidation source, not a subscription to every browsed directory. Observe authorized listed directories on the host through bounded, non-recursive native watches, registered before listing. Preserve physical containment, inode and workspace-lifetime checks and close watches on removal or shutdown. Forward actual directory observations through the existing SSE event bus, without inventing native OpenCode events or adding polling, recursive scans or an independent service. Coalesce passive cache invalidations before readers wake: a short isolated- change delay, then at most one publication every five seconds during sustained activity, as requested. Only pending observations create a timer. Explicit mutation/recovery invalidations remain immediate. Scope tree versions by browsing root and listed directory, so only stale visible directories reread; other worktrees, unchanged siblings and hidden/collapsed folders do not scan. Retain existing read budgets, cache-first rows and coalesced in-flight demand. Keep logical listing aliases separate from physical watch targets, so every internal junction/symlink subscriber receives its own cache invalidation. Directory listings include an opaque device/inode identity: when a parent detects removal or replacement, discard only that child's cached subtree, cancel its old reads and reload still-expanded paths to bind new observations. Ordinary mtime changes never force a subtree scan. Validation covers real filesystem create/edit/rename/unlink, ownership, replacement, aliases, watcher budgets/disposal, continuous cache bursts and immediate explicit mutations. A real Chromium regression exercises the whole filesystem-to-watch-to-SSE-to-Workspace path without F5, including parent-only replacement and multiple junction aliases. Existing Files/Git browser checks, the full server suite and targeted UI checks pass; typecheck diagnostics remain at baseline. No installed application or shared OpenCode daemon was replaced or restarted. Final server validation: 2,415 tests pass, seven existing skips. Two independent gatekeeper rounds; the final review reports zero actionable P0-P3 findings at 728bf59861d063863c6bb3351d5ce0a030ce6866. The hosted CI is still running at merge. Closes #1027.
21 KiB
Files panel
The right panel has one Files entry with three compact modes:
- Workspace (initial default): lazy directory tree for the browsed worktree.
- Changes: indexed and working-tree changes, including new/deleted files.
- Commits: paginated history, commit message and changed-file list.
The last selected mode and existing diff/wrap preferences use client layout
storage. Migration merges the old Git and Files tab positions and visibility
once, retaining access when either entry was previously visible. Each mode
retains its mounted navigation while switching between them. Tree snapshots are
scoped to the physical worktree directory. All three modes are cache-first:
listings, git status and history serve cached data and revalidate lazily on
filesystem invalidation, mode activation (when absent or marked stale) or
explicit refresh. Reopening a folder re-reads its visible subtree only when the
filesystem changed since it was loaded; collapsed folders are never re-read on
expand. Workspace rows expose a hover/focus … menu (folder: open, terminal,
copy path; file: open, reveal, copy path). Changes rows carry an explicit
per-file stage/unstage button and accept drag-and-drop between the staged and
unstaged sections. File row clicks select without opening the reader. Workspace,
Changes and commit-file rows share FileRowActions: a pinned eye toggles the
central preview with the shared icon-toggle active state. Secondary actions
stay inline while the measured whole row fits, falling back to the shared menu
only on overflow; an open menu survives resizing until dismissal. Hover uses
control-hover.css's shared surface overlay and selection covers the whole row.
Change counts are muted; additions use success green and deletions error red.
Changes retains two independently collapsible sections, initially expanded: Staged Changes and Changes. Git actions and the compact commit-message field live at the top of Staged Changes, before its file rows, without an additional Actions Git disclosure at the bottom. Collapsing either section preserves the commit draft and selection, causes no status read, and leaves its header as a drop target for staging/unstaging even when the file list is hidden. Mode switches and status refreshes preserve both sections' disclosure state.
Large inventories
A checkout with thousands of changes once froze the renderer: every
filesystem.changed refresh (agents keep firing them, including from nested
worktrees) rebuilt every row object, so the keyed list destroyed and recreated
all ~60k row nodes, and each row scanned the selection. Three rules now bound
that cost:
- Unchanged refreshes publish nothing.
reuseUnchanged(git-changes-model.ts) returns the previous status array when a read is structurally identical, so no memo recomputes and no row is touched. When the status changed, entries and list items whose fields all match (by path / by item id) keep their previous objects; only changed, added or removed rows reach the DOM. List items therefore hold primitive fields only. The request-version, worktree and whitespace-option stale fences are unchanged. - Bounded sections.
git-change-window.tsrenders each section's firstGIT_CHANGE_ROW_LIMIT(200) rows and closes it with an in-place "Show 200 more" line stating how many files are not shown; header and file counts stay the full totals. Revealing moves keyboard focus to the first revealed row. Reveals survive refreshes and mode switches, and reset when the worktree changes. Current, previewed and bulk-selected rows always render, and the pins are sticky (stickyPins): a row once pinned stays rendered until the worktree changes or its file leaves the loaded inventory (a returning file is not pinned again; a list discarded while Changes is hidden is not a departure). Beyond the bound they follow the window in list order, before Show more, so an auto-selected most-changed file or a preview moved by staging stays reachable without rendering every row before it, and nothing shown there disappears because a preview closed, the selection moved or focus left. The sticky set only grows through selection and previews. There is no focus tracking. A focused row that unmounts anyway (staged/unstaged into the other section, changed or removed by a refresh) hands focus to its replacement for the same file, else to the nearest following row or Show more control that survived (a bulk move removes neighbours in the same update), the nearest preceding row, or the section header without scrolling (git-change-row-focus.ts): never the commit box or an action on another file, never the document. Shift ranges span the rendered rows only, so bulk actions never include files hidden beyond the bound (collapsed sections keep their existing behavior). - Per-row selection state uses
createSelector, so a selection change only updates the rows whose state changed.
The Changes body stays mounted (hidden) in Workspace/Commits modes to keep its disclosure state; with the bound and the unchanged-refresh rule that costs at most a few hundred idle rows.
Folders without a repository
When Git is available but the opened folder is not a repository (for example a temporary workspace or a plain folder), Changes and Commits stay selectable and show "This folder is not a Git repository." with an Initialize Git button, without issuing any Git read. The panel no longer forces such folders back to Workspace. When the backend cannot run Git at all (directory-only degraded mode) the repository status is unknown and nothing changes: initialization would be impossible there.
The button acts immediately, without confirmation: like staging or committing,
initialization is non-destructive (it only creates .git), unlike worktree or
temporary-folder removal, which confirm. It sends a single request and never
replays it. Success and every failure (including uncertain transport errors)
re-read the worktree inventory, invalidate filesystem caches, reload the native
project metadata and then the session list, so Changes opens on the new
repository without reloading the window. A 501 from an OpenCode runtime whose
provider cannot initialize, an existing repository, a mismatched folder and
missing Git each show a localized message. Explicit refresh in that state
rechecks the inventory, noticing a repository created outside CodeNomad.
Native vcs.init initializes the location's project directory and refreshes
its services; for a markerless folder that is the folder itself, and the native
project ID changes (a repository without commits is global, sessions follow
it). This is why the UI reloads project metadata before sessions.
Central reader
files-preview-view.tsx selects the shared diff reader or the editable workspace
reader (source, rendered Markdown, raster/vector image, or binary fallback).
The composer stays mounted below it. Selections are bound to the originating
project/session and cleared on session/worktree changes. Opening the browser
preview replaces the file reader; opening a file returns the browser to chat mode.
Cancelling or hiding a view fences pending reads. Workspace/local-diff readers
refresh on filesystem invalidation, while commit contents remain immutable.
Hide whitespace-only changes
Settings → Advanced "Hide whitespace-only changes" (hideDiffWhitespaceChanges,
enabled by default and stored with the other UI settings) mainly keeps the
Changes list focused on real edits; diffs follow it as a secondary effect.
- List (main effect). The status read sends
?hideWhitespace=1, and only then the server adds one bounded worker query,git diff --numstat -z --ignore-all-space(worktree vs index), with no per-file processes. It starts only after the ordinary unstaged numstat resolves, so a write between the two reads can only make the hidden state follow the newer content. An unstaged trackedmodifiedtext file with a non-zero ordinary numstat getsunstagedWhitespaceOnlyonly when it is absent from the-woutput. Git omits a file there when its content differs only by whitespace and its mode is unchanged. Git's-wcovers CR (CRLF-only edits), whitespace inside a line and a removed or added final newline ("x\n"→"x"). - Listed records stay visible. Any
-wrecord, including0 0, keeps the file visible: Git lists a worktree mode change combined with a whitespace-only edit, and an unmerged path (for example a conflict resolved with "ours" and re-indented but not added), as0 0. Entries whose staged side isunmergedare skipped regardless of record order. Older Git versions that print0 0for every whitespace-only file therefore simply never hide anything (fail-open), as does a failed detection read. Untracked, added/deleted/renamed and binary (-) changes are never flagged. The server singleflight key and the UI read include the option. - Staged files are never filtered out. Detection never runs for index vs
HEAD, so every staged file a commit (
git commit, staged content only) includes keeps its row; beyond the row bound such rows stay counted and revealable (see Large inventories). A file staged with real content and re-indented afterwards shows only its staged row; a staged chmod (git update-index --chmod) likewise stays in the staged section. - Bulk actions.
useGitChangesexposes only the visible inventory, so the file count, section counts, row selection, range/bulk "stage selected" and drag-and-drop can never target a hidden file; hidden files stay unstaged. There is no separate "stage all" command. - Live toggle. Switching the option refreshes a visible Changes list (a read made under the previous option is discarded) or drops the cached rows so the next activation reads again; turning it off shows flagged rows immediately. Filesystem invalidation and explicit refresh are unchanged.
- Diffs (secondary effect). Changes and Commits share the diff reader, which
maps the option to Monaco's native
ignoreTrimWhitespace: lines that differ only by indentation or trailing spaces/tabs compare equal, while whitespace inside a line remains visible. The diff content read is unchanged and image diffs ignore the setting. Staging and commits always act on exact content.
Monaco never draws line-ending (CRLF/LF/CR) or leading byte-order-mark
differences, whatever the setting; on Windows the Git LF blob often differs from
CRLF worktree bytes only this way. file-viewer/diff-whitespace.ts mirrors the
model's BOM stripping, line splitting, space/tab trimming and empty-side
whole-file change in linear time, so the reader is never silently blank: one
note reports line-ending/BOM-only files and another states that only whitespace
(indentation or trailing spaces) changed. Both are factual and shown whatever the
setting, never advice to change it, because Monaco 0.52's legacy diff has an
upstream limitation: in show mode it compares the original line with itself, so
same-length trailing-whitespace swaps ("foo\t" → "foo ", "\t" → " ") are
never drawn in either mode. The classifier deliberately does not replicate
Monaco's loops to predict exactly what is drawn. Monaco itself defaults to
ignoring trim whitespace; the viewer always passes the option explicitly, so
turning the setting off shows the other whitespace changes. With the option on,
these notes mostly concern Commits diffs and staged files, since unstaged
whitespace-only files are no longer listed.
Git image previews retain base64 bytes for the actual compared revisions:
HEAD/index for staged changes, index/worktree for unstaged changes, and the
parent/commit blobs for history. git-image-preview.ts uses bounded worker reads
and the existing bounded filesystem reader; no checkout occurs and historical
images never fall back to the current worktree. GitImagePreview shows before
and after images (including one-sided additions/deletions); other binary files
retain their unavailable-preview fallback.
Workspace source retains Monaco editing, Save and Ctrl/Cmd+S in the central view; Markdown's source mode is editable too. Images, binary content and historical diffs remain read-only. The former sidebar layout is retired, not file editing. Dirty drafts are retained in renderer memory per instance/directory/path when closing or changing readers, and filesystem invalidation never replaces them. Saving rereads the bounded authoritative file and asks before overwriting external changes; read/write errors preserve the draft. Explicit refresh asks before discarding edits. Monaco's tokenizers are packaged locally for offline highlighting.
Clicked readers have a reserved one-request lane beside the two background scan slots. Start the local Monaco load alongside the content read; preview Git work uses the worker's foreground queue. Commit metadata and parent IDs share one Git process. These changes preserve ownership checks and cancellation fencing.
lib/monaco/theme.ts resolves the selected palette's CSS tokens into Monaco hex
colors, including source tokens, editor surfaces and added/removed-line tints.
Its observer follows live appearance/custom-palette changes and is disposed with
the viewer. Read-only previews disable cross-model occurrence highlighting to
avoid Monaco 0.52's leaked cancellation on fast model changes; text selection and
insertion into the composer remain available.
Backend boundaries
Workspace listings register a non-recursive host watch through
workspaces/file-watches.ts before reading the directory. OpenCode's native
filesystem events are not a subscription to every browsed folder, so they are
not the sole invalidation source. Actual host changes are coalesced into the
existing SSE stream as workspace.filesChanged (workspace ID and changed listing
directories, not one event per file). Shared lib/filesystem-events.ts merges
passive observations before publishing cache versions: a quiet-case delay of
100 ms, then at most one publication every five seconds during continuous
activity, including native filesystem.changed events. The timer exists only
while observations are pending. Explicit mutations and recovery still invalidate
immediately. This path does not require an initialized OpenCode client.
The Workspace tree compares versions per browsing root and listed directory; an observation in another worktree or an unchanged sibling does not cause a scan. Only stale visible directories reread, using the existing background read budget and one coalesced follow-up when already busy. Hidden/collapsed views retain their snapshots until ordinary demand. There is no periodic scan or polling. Version metadata is bounded, conservatively falling back to full invalidation if its root/directory budget is exceeded, and disposed with the workspace. Explicit Refresh still rereads all visible directories. Watches are restricted to the authorized listing root's physical tree (never an outward symlink), bounded to the 256 most recently listed directories per backend, and closed on workspace removal/shutdown. Directory replacement rebinds its inode identity; errors remain best effort and a later listing can retry setup. Native OpenCode invalidations and explicit Refresh retain their existing paths.
Directory entries carry an opaque device/inode identity, distinct from their modification time. When a parent listing discovers a removed or replaced child, the tree drops only that child's cached subtree, cancels its old pending reads, and reloads still-expanded paths so their observations bind to the replacement. Ordinary parent content/mtime changes never turn into subtree scans. Observations retain the logical listed path separately from the physical safety boundary; multiple internal junctions/symlinks of one physical directory notify each alias, not just its canonical target.
/api/workspaces/:id/files/previewaccepts a relativepathand the browseddirectory, validates it through the manager's existing worktree ownership, and uses the bounded filesystem content reader (5 MiB, base64). This also works for the explicitly opened directory in Git-degraded mode./api/workspaces/:id/worktrees/:slug/git-historyreturns up to 50 commits plus a continuation flag. Later pages retain their original HEAD hash.PUT /api/workspaces/:id/files/contentaccepts the exact browseddirectoryalongside the relative path, checks current ownership and the deletion fence, and uses the existing filesystem writer. It never redirects a linked-worktree edit to the root checkout.POST /api/workspaces/:id/git-initaccepts{ directory }, which must be the explicitly opened folder (same realpath), never a parent or linked worktree. It refuses directory-only mode (git_required) and folders already inside a repository, checks ownership and holds the deletion fence, verifies that nativelocation.getresolves that exact folder as the project directory (translated through WSL when needed), and callsvcs.initwith providergitonce. After success the worktree inventory is invalidated and read fresh, publishingworkspace.worktreesChangedto every window. The Git availability advisory is unaffected: it reflects the Git executable, not repository presence./git-history/:commitreturns the message and files; an optionalpathreturns before/after text against the first parent (empty for a root commit).- Commit IDs are full SHA-1/SHA-256 hashes. Files must belong to the commit's authoritative change list. Git commands run in the existing worker with a timeout and output bound. Renames use their original path; gitlinks show their object IDs rather than reading another repository. Binary diffs use a fallback.
Validation
-
workspaces/file-watches.test.ts: real host file operations, replacement, physical containment, bounded handles and disposal/admission fencing. -
tests/browser/workspace-file-refresh.test.ts: real filesystem changes through the watcher, shared event bus and SSE into the real Workspace tree; create, rename, unlink and nested files appear without F5, while idle/hidden/collapsed views do not generate scans. -
lib/filesystem-events.test.tsanduseWorkspaceTree.test.ts: continuous observation bursts cannot repeatedly invalidate caches; multi-worktree batches reread only changed visible directories, while explicit writes remain immediate. -
server/routes/files-history.test.ts: real temporary root/linked repositories, route ownership, file bounds, history/diff and directory-only reading. -
workspaces/git-history.test.ts: root/merge/rename/delete/binary/gitlink cases and snapshot pagination across a new HEAD. -
workspaces/git-init.test.tsandserver/routes/git-init.test.ts: exact-folder and parent refusal, ownership/deletion fence, 501/conflict/uncertain mapping without retry, and inventory republication;git-requirement.test.tscovers the directory-only refusal. -
tests/browser/git-init.test.ts: the?uninitializedfixture's empty state, Initialize Git, refreshed Changes/branch and the unsupported-runtime message. -
node scripts/test-git-init-native.mjs <absolute CLI>: an isolated daemon initializes a markerless child folder (never its parent) and checks session continuity plus unborn-repository inventory, status and history. -
tests/browser/git-history.test.ts: the real panel/readers and SessionView, draft preservation, late-read/session fencing, filesystem refresh and keyboard tree navigation. Its?advancedfixture mode renders the real Advanced settings section: by default whitespace/CRLF-only rows leave the list and its counts, a range "stage selected" spanning them stages only visible files; turning the option off lists them with their factual whitespace and line-ending notes, and the shared Commits reader follows it. -
workspaces/git-status.test.ts: real repositories with whitespace-only, CRLF-only, final-newline-only, real, binary, untracked, staged and staged-then-reindented files; a worktree mode change and an unmerged "ours"-resolved re-indent (both listed0 0, kept visible) and a staged chmod; the-wread starting after the unstaged numstat; option-keyed singleflight and fail-open detection. -
tests/browser/git-changes-scale.test.ts: a 2,700-file inventory renders 200 rows per section plus the auto-selected row beyond the bound; an identical refresh mutates no node and a one-file change replaces only that row (MutationObserver); Show more focus, reveal persistence and worktree reset; cross-section ranges, row staging, a staged preview beyond the bound and drag; with real keyboard/mouse input, a sticky row across preview close, Tab/Shift+Tab, a click and its compact … menu; focus after a bulk unstage (nearest surviving row, scroll kept), unstage/refresh removal and never on the commit box; sticky rows kept across a hidden-mode discard, pruned with their file and cleared on a worktree change. It logs DOM/time measurements for each refresh. -
git-change-window.test.ts, plus identity reuse ingit-changes-model.test.tsand unchanged-refresh/window behavior inuseGitChanges.test.ts. -
useGitChanges.test.ts/git-changes-model.test.ts: the option in the read, staged rows kept, bulk targets, live toggling and stale-option responses;file-viewer/diff-whitespace.test.ts(CRLF/CR/BOM, empty sides, linear time on long blank runs) andsettings/behavior-registry.test.tscover the in-diff classifier and the preference. The design fixture remains available throughnode scripts/preview-git-history.mjswith synthetic data only.