Skip to content

feat(core): watch files open in the side panel - #57915

Merged
jlongster merged 7 commits into
betafrom
sidebar-file-watch
Oct 22, 2026
Merged

jlongster merged 7 commits into
betafrom
sidebar-file-watch

Conversation

@jlongster

@jlongster jlongster commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #

Replaces #57426 (polling). Design: https://gist.github.com/jlongster/80526202aca5784bd3fbac9e554296f8

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Files open in the right sidebar go stale since #41096 stopped recursive project watching, so filesystem.changed never fires for workspace files. Now the client asks the server to watch one file at a time.

  • Core: FileWatch, a global registry keyed by location + absolute path. It runs one Watcher type: "file" watch per file (the parent directory, filtered by name, so atomic temp + rename saves are caught) and publishes filesystem.changed (change, or unlink when the file is gone) on the watch's location. Each watch expires 24h after the latest request; one sweep runs every 5 minutes; at most 256 watches per location, evicting the one closest to expiring. It's global so watches outlive idle location eviction. It follows the fs.filewatcher kill switch.
  • Watcher: fs.watch on a missing directory threw inside Effect.sync (a defect). It now logs and ends the stream.
  • Server/protocol: experimental POST / DELETE /api/experimental/fs/watch { path }, location-scoped. POST uses the same confinement as fs.read (404 otherwise) and returns 503 when watching is disabled. Client regenerated (file.watch, file.unwatch).
  • App: the file model's sync(path) (called when a file tab opens and when a tab is selected, via the panel focus() hook) also sends watch. The file panel's close() hook sends unwatch, so it only fires on a real tab close, not on session switches; this adds Files.unwatch to the extension SDK. On every server.connected, the files open in the side panel are watched again. Errors are ignored (older servers return 404). The existing filesystem.changed reload path is unchanged.

Known limits: files outside the workspace aren't watched (their events would arrive on another location, which the file model doesn't listen to). A DELETE from one window stops a watch another window still shows, until that tab is selected again or the stream reconnects.

How did you verify your code works?

  • packages/core/test/filesystem/file-watch.test.ts: sharing + unwatch, 24h expiry and reset, the per-location cap, events on the right location, the disabled path, and a real native test (in-place edit and temp + rename save, macOS locally, skipped on CI like the other native watcher tests).
  • packages/server/test/fs.test.ts: 404 outside the location or for a missing file, 503 when disabled, 204 for DELETE.
  • bun typecheck in core, server, protocol, app, gui-extensions.

Screenshots / recordings

Not run in the Desktop app.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #52941, #57920

Public API: adds experimental POST/DELETE /api/experimental/fs/watch, client file.watch/file.unwatch, and extension SDK Files.unwatch; Files.sync now also watches (backwards compatible)

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server side works for the main case. In core, the new tests pass on Linux, including the real native test (in-place edit and temp + rename save). The server route tests and type checks for core, server, protocol and app also pass. I didn't run the Desktop or web app, so the files/model.tsx behaviour is only checked by reading the code.

Public API: additions only, so backwards compatible.

  • POST /api/experimental/fs/watch and DELETE /api/experimental/fs/watch (location query, body { path }). POST returns 204, 404 or 503; DELETE returns 204.
  • In openapi.json: experimental.fs.watch and experimental.fs.unwatch.
  • In the client: file.watch and file.unwatch.
  • filesystem.changed now fires for watched files. The event's shape is unchanged.

One thing to fix: a watch whose stream has ended stays registered, so asking again only resets its expiry and never restarts it. Details are in the inline comment.

Related: #52941, #57920

),
)
}).pipe(
Effect.catchCauseIf(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this stream ends, the entry stays in locations. This happens if the parent directory is removed (for example rm -rf src && mkdir src, a branch switch that drops the folder, or a build that wipes dist/), or if fs.watch hits ENOENT and the new Watcher path ends the stream. A later watch() then hits the existing branch (line 127) and only bumps expires, so the tab stays stale for up to 24h even though the client keeps asking.

I reproduced this with the native layer: watch src/a.ts, recreate src, call watch() again, write 10 times, and no events arrive.

One fix: drop the entry when the forked fiber exits without being interrupted, so the next request starts a fresh watch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended streams are handled in bbe5db0 (with a restart test); failed streams now get the same cleanup in 5d1a520 (with a test).

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commits fix part of my earlier point. A file watch that ends or fails is now dropped and restarts on the next request, which covers a directory missing at start and watch errors.

The directory-recreated case I reported still fails on Linux. My earlier reproduction (recreate src/, watch again, write 10 times) still gets 0 events on this head. When the watched directory is deleted, fs.watch sends no error or close, only a rename event named after the directory itself. The native file watch in watcher.ts drops that event because the name isn't on its list, so the stream never ends and the new restart code never runs. Ending the subscription when an event names the watched directory, or when the directory is gone, would let the new restart code handle it.

The app side, which watches on sync and unwatches on tab close, reads correctly. Two small side effects worth knowing about:

  • A link click syncs before its tab exists, so that watch can stay unclaimed until it expires after 24h.
  • Closing a tab in one session stops the watch for another session open on the same directory, until that tab is selected again.

Tests: the core watch tests pass. typecheck passes in core, server and gui-extensions. The app type check has one error, in activity-chips.fixture.tsx, a file this PR doesn't touch. I didn't run the Desktop or web app.

Public API (backwards compatible):

  • HTTP: experimental POST and DELETE /api/experimental/fs/watch (experimental.fs.watch / experimental.fs.unwatch in the OpenAPI spec).
  • Client: file.watch and file.unwatch.
  • Extension SDK: adds Files.unwatch(path), and Files.sync now also starts a server watch.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two new commits change only tests. The promise client test now expects watch and unwatch, and the e2e mock server handles the new watch endpoints. bun test test/promise.test.ts passes. The public API is unchanged by these commits.

The open thread about the directory-recreate case isn't addressed by these commits.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only regenerates the docs site's OpenAPI documents. They match packages/protocol/openapi.json, and the only addition is the experimental /api/experimental/fs/watch endpoints. The public API is unchanged by this commit.

The open thread about the directory-recreate case isn't addressed by this commit.

@jlongster
jlongster merged commit 8b91299 into beta Oct 22, 2026
9 of 17 checks passed
@jlongster
jlongster deleted the sidebar-file-watch branch October 22, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant