Skip to content

feat(vscode): dev-workflow batch — Open Dev URL rows, per-engineer config, Builders list/tree toggle#785

Merged
waleedkadous merged 13 commits into
mainfrom
feat/dev-workflow-and-vscode-updates-3
May 20, 2026
Merged

feat(vscode): dev-workflow batch — Open Dev URL rows, per-engineer config, Builders list/tree toggle#785
waleedkadous merged 13 commits into
mainfrom
feat/dev-workflow-and-vscode-updates-3

Conversation

@amrmelsayed
Copy link
Copy Markdown
Collaborator

@amrmelsayed amrmelsayed commented May 20, 2026

A batch of VSCode-extension changes centred on the Workspace view's dev-server workflow, plus a Builders-view list/tree toggle mirroring VSCode's native Source Control panel.

What's new

  • Per-engineer config overrides via `.codev/config.local.json`. A gitignored sibling to `.codev/config.json` that layers personal overrides on top of the shared project config — personal staging URLs, tunnel hostnames, etc. stay out of the shared file.
image image
  • Workspace view live-refreshes on config edits. Editing `.codev/config.json` or `.codev/config.local.json` re-renders every open VSCode window's sidebar immediately; no reload needed. Driven via Tower so multiple windows stay in sync.

  • "Open Dev URL" rows in the Workspace view. Add an array of `{ label, url }` entries under `worktree.devUrls` in `.codev/config.json` to surface dev/staging/preview links as one-click rows in the Workspace view. Clicking opens the URL in the user's default browser. Distinct from "Open Web Interface", which always points at Tower's dashboard.

image
  • Changed-files view toggles between tree and list. A new title-bar button on the Builders view switches a builder's expanded file list between a folder tree (default; single-child folder chains compacted as in VSCode's Source Control panel) and a flat list. Setting: `codev.buildersFileViewAsTree`.
image

Bug fixes

  • Workspace view detects dev servers started from any source. Starting a dev from a builder row's right-click context menu now correctly flips the Workspace view's row to "Stop Dev Server" — previously the row stayed stuck on "Start Dev Server" because the check was scoped to this workspace's own target.
  • "Start Dev Server" row is hidden when `worktree.devCommand` isn't configured. No more click → no-op / error on workspaces that don't define a dev command.
  • `.codev/config.local.json` overrides also apply when actually running the dev command. Previously the override changed only the sidebar display; the command Tower ran still came from the shared config. Now both honour the layered config.
  • Re-expanding a builder restores its folder tree. After accordion auto-collapse (clicking a different builder), re-expanding the first builder now re-expands its folders too via `reveal({ expand: 3 })` (the VSCode API ceiling) — not just the top builder row.

Verification

  • `pnpm --filter codev-vscode run compile` clean (`tsc --noEmit` + lint + esbuild).
  • vscode mocha tests: 77 passing (67 baseline + 10 new for `file-path-tree` with empty / flat / deep-compacted / shared-prefix / diverging-branch / mixed-ordering / rename / single-file-child / case-insensitive-sort / realistic-monorepo cases).
  • Manual end-to-end: Workspace view's Start/Stop row reflects any dev source; `worktree.devUrls` rows open in default browser; `.codev/config.local.json` edits live-refresh the sidebar; Builders view title-bar button flips between tree (default) and list; re-expanding a builder after auto-collapse restores the full folder tree.

Branch notes

Branch is `feat/dev-workflow-and-vscode-updates-3`. 12 commits — most are extension-side; a couple touch the codev package (the new `/api/worktree-config` endpoint + SSE event that backs the live-refresh, and the layered config-load that backs the `.local.json` override).

The Workspace view's Start/Stop row was scoped to *this workspace's*
dev target — `devTarget.id` (`main` or a worktree basename). A dev
started via the builder-row right-click context menu registers under
`builder.id` from Tower's overview, which doesn't match any workspace
target's id, so the row stayed stuck on "Start Dev Server" even with
a dev actively running.

Contradicts the single-dev-slot model in dev-shared.ts that enforces
"one dev at a time across {main + all builders}". The slot was
occupied; the UI just didn't reflect it.

Three-way render now:

- No dev running anywhere → Start Dev Server (for this workspace's
  target). Today's behaviour.
- This workspace's dev is the one running → Stop Dev Server via
  codev.stopWorkspaceDev. Today's behaviour.
- A *different* target's dev is running → Stop Dev Server via
  codev.stopWorktreeDev. Tooltip names the running target so the
  user sees what they're stopping. (Single-slot invariant means
  there's at most one to stop, so stopWorktreeDev's "kill all in
  the local registry" semantics are exactly "stop the slot.")

The existing onDidChangeDevTerminals subscription already fires from
context-menu-started devs (TerminalManager.openDevTerminal fires it
unconditionally), so no event-plumbing changes are needed — only the
check that consumes the event widened from per-target to per-slot.

Mutually-exclusive row-count invariant preserved: still exactly one
Start-or-Stop row, never both.
Adds a one-click affordance to open the dev server's URL in the
user's default browser, surfaced as a Workspace-view row below
Start/Stop Dev Server.

Schema (.codev/config.json): worktree.devUrl (legacy single) coexists
with worktree.devUrls: [{ label, url }, ...] (multi, labeled). The
canonical resolved shape is always a WorktreeDevUrl[]; a legacy single
normalizes to [{ label: 'Open Dev URL', url }] so consumers don't
branch. devUrls (if set) wins over devUrl.

VSCode UX: one workspace-view row per resolved entry, visible whenever
any URL is configured (independent of dev-PTY state). Row click and
palette 'Codev: Open Dev URL' both route through vscode.env.openExternal
— a real default browser gives DevTools, a real cookie jar, and OAuth
that actually works, unlike VSCode's Simple Browser webview which has
none of those (rationale recorded in #780). Palette invocation without
an argument picks via QuickPick if multiple URLs are configured.

Reads .codev/config.json directly from the extension (sync, small file)
rather than adding a Tower endpoint just for one field. Existing 81
codev tests around the resolved-config shape updated for the schema
change.
Appends a 5th layer to lib/config.ts:loadConfig. Reads
<workspaceRoot>/.codev/config.local.json (if present) and deep-merges
it on top of the committed .codev/config.json. The existing 4-layer
pipeline (defaults -> cache -> ~/.codev/config.json -> .codev/config.json)
continues to apply below it.

The use case the previous layers couldn't express: different engineers
working on the same repo wanting different defaults (web dev defaults
to 'pnpm dev', mobile dev to 'pnpm dev:mobile', backend to
'pnpm dev:api'). Layer 3 (~/.codev/config.json) spans every project on
the machine, so it can't say 'in *this* repo only.' Layer 5 fixes that
without inventing new machinery — deepMerge / readJsonFile are reused;
a new resolveLocalConfigPath helper mirrors resolveProjectConfigPath so
the layer-loading code stays uniform. agent-farm/utils/config.ts:
loadUserConfig already calls loadConfig, so every worktree.* consumer
(including the just-shipped devUrl/devUrls) automatically picks up
local overrides with no further wiring.

Recommended user action: add .codev/config.local.json to your project's
.gitignore so per-engineer overrides aren't accidentally committed.

Tests: 5 new cases in __tests__/config.test.ts — 3 covering loadConfig
layer-5 semantics (local-only, local-overrides-project, missing-noop)
and 2 covering resolveLocalConfigPath directly. All 26 config tests
pass.
… defaults

Drops the legacy worktree.devUrl (legacy single-URL string) shipped one
commit ago in bf7a2e7, before any external user could depend on it. Sole
form is now worktree.devUrls: Array<{ label, url }>; both fields are
mandatory per schema. Entries missing either field are silently filtered
out by the resolver and the VSCode-side reader.

Why: with two coexisting shapes (devUrl vs devUrls), label-defaulting to
"Open Dev URL" was a holdover from devUrl's literal semantic. Unlabeled
multi-URL configs produced duplicate "Open Dev URL" rows distinguishable
only by tooltip — confusing. Requiring label removes the ambiguity at the
source: every row has a human-recognizable name picked by the user.

Files: schema in types.ts; resolveDevUrls in utils/config.ts;
readWorktreeDevUrls in vscode/commands/open-dev-url.ts. 107 tests still
pass; tsc + lint clean on both packages.
…on edits

The workspace view's rows are config-driven (Open Dev URL entries from
worktree.devUrls, more to come) but until now the only refresh triggers
were Tower connection state changes and dev-terminal lifecycle events.
Editing .codev/config.json or .codev/config.local.json required a window
reload to see the new rows.

Adds a vscode.workspace.createFileSystemWatcher on .codev/config{,.local}.json
under the active workspacePath. Any create/change/delete fires the
provider's existing changeEmitter; getChildren() then re-reads the
config on next render. The watching primitive is extracted to
src/watch-codev-config.ts so future config-driven views can wire it in
as a one-liner instead of duplicating the boilerplate.

WorkspaceProvider's install is lazy because workspacePath isn't set at
construction time (the Tower connection lands async). The first
onStateChange that exposes a workspacePath installs the watcher and a
flag pins it for the session. Disposables flow through
context.subscriptions so cleanup is standard on extension deactivate.

The pattern mirrors Tower's
agent-farm/servers/tower-tunnel.ts:startConfigWatcher (which watches
~/.codev/cloud.json via node:fs.watch for OAuth credential changes) but
this is the first vscode.workspace.createFileSystemWatcher in the
extension — prior code reacted only to Tower / VSCode events, never to
filesystem events.
The earlier glob pattern '.codev/config{,.local}.json' relied on an
empty-string alternative inside the braces. VSCode's glob matcher
inconsistently honors that — in practice events fired for config.json
but not for config.local.json, so per-engineer override edits silently
failed to refresh the view.

Switching to two explicit non-empty alternatives
'.codev/{config.json,config.local.json}' is unambiguous across glob
implementations. Both files now consistently trigger the watcher's
create/change/delete events.
Both rows were rendering with the globe icon, making them visually
identical in the Workspace view though they do different things —
'Open Web Interface' opens the Tower dashboard, 'Open Dev URL' opens
the user's running app.

Switch the Open Dev URL rows to 'link-external' (square + outgoing
arrow) — VSCode's conventional 'opens outside the editor' glyph,
which is also a more precise match for what vscode.env.openExternal
does. 'Open Web Interface' keeps the more abstract 'globe' since the
Tower dashboard is the closest thing to a 'project's web presence' the
extension surfaces.
Closes the architecture gap where the VSCode extension was parsing and
merging .codev/config(.local).json itself instead of consuming the
canonical resolved view from Tower. The merge logic now lives in one
place (lib/config.ts:loadConfig); every client just asks the API.

Tower (codev):
- New GET /api/worktree-config route + handler. Returns the resolved
  ResolvedWorktreeConfig for the requested workspace (defaults / cache
  / global / project / project-local, deep-merged) by delegating to
  the existing getWorktreeConfig(workspaceRoot).
- New worktree-config-watcher.ts: per-workspace node:fs.watch on
  .codev/{config.json,config.local.json}, debounced, fans out a
  'worktree-config-updated' SSE event on change. Mirrors the existing
  tower-tunnel.ts:startConfigWatcher pattern for ~/.codev/cloud.json.
  The watcher is lazily installed by the route handler on first
  request and torn down by graceful shutdown.

Types (codev-types):
- ResolvedWorktreeConfig + WorktreeDevUrl promoted from a codev-
  internal interface to wire-contract types since they now cross
  HTTP. codev's utils/config.ts re-exports them so existing internal
  callers keep working.

Core (codev-core):
- TowerClient.getWorktreeConfig(workspacePath?) — same shape as
  getOverview/getIssue.

VSCode extension:
- open-dev-url.ts: removed fs.readFileSync + the inline two-file
  merge; readWorktreeDevUrls (sync) replaced with async
  loadWorktreeDevUrls(connectionManager) calling
  client.getWorktreeConfig.
- workspace.ts: getChildren is async; constructor subscribes to
  connectionManager.onSSEEvent for 'worktree-config-updated'. Tower
  emits envelopes on the SSE 'data:' field with no 'event:' name (see
  builder-spawn-handler.ts:20 for the precedent comment), so the
  subscription JSON-parses the data and matches on envelope.type.
  Removed the context parameter — no longer needed.
- watch-codev-config.ts: deleted. The extension never watches local
  filesystems anymore; Tower is the only thing that watches the
  config files and tells the extension via SSE.

Why this matters: the extension is now tunnel-safe (Tower can be
remote and config-change events flow over the existing SSE
transport), there's exactly one watcher per workspace regardless of
how many VSCode windows are open, and the merge semantics can't
drift between client and server because the client doesn't merge
anymore. The 'one place to merge config' invariant is finally true.

Action-bound config consumers (devCommand, postSpawn, symlinks) were
already live via Tower's per-invocation getWorktreeConfig() reads —
this change adds the matching liveness for UI-bound consumers
(devUrls today, anything we add to the workspace view later).

107 codev tests still pass. tsc + lint clean across all packages.
… unset

Previously the row appeared unconditionally — clicking it with no
devCommand configured produced a clear-but-still-pointless 'devCommand
isn't set' toast from Tower. Now the row is gated on devCommand
presence in the resolved config (Tower-merged across all 5 layers), so
unconfigured workspaces simply don't show the row. Stop Dev Server is
left alone — it stays visible whenever a dev is actually running, so
the user can always kill a manually-started dev even if devCommand was
later unset.

Tooltip on the Start row now also previews the command itself
(e.g. 'Run worktree.devCommand (`pnpm dev:local`) for this workspace…')
so the user can see what'll fire before clicking.

Along the way: extracted loadWorktreeConfig out of
commands/open-dev-url.ts (which is now back to being command-specific)
into a new top-level src/load-worktree-config.ts helper. The
workspace view's getChildren does one fetch per render and reads both
devCommand (for the Start gate) and devUrls (for the Open Dev URL
rows) off the same response — no duplicate HTTP.
…yle)

Each builder row in the Builders view now renders its expanded
changed-files list as either a flat list (today's behaviour) or as
a folder tree (default, matching VSCode's Source Control panel),
with single-child folder chains compacted into one row — e.g.
`packages/codev/src` displayed as a single folder, not three nested.

Title-bar button toggles between the two; mirrors the accordion-
toggle precedent (`codev.buildersAutoCollapse`). Setting
`codev.buildersFileViewAsTree` (default true) backs the toggle and
is honoured by the provider's `fileChildren` render branch.

Tree-mode mechanics:

- `file-path-tree.ts` builds a nested FilePathNode[] from the flat
  BuilderFileChange[]; compacts any folder whose lone child is also
  a folder. Folders sort before files at each level; case-
  insensitive alphabetical within each group.
- `BuilderFolderTreeItem` is a collapsible row with the native
  Folder ThemeIcon and a stable id (`<builderId>::folder::<fullPath>`),
  Expanded by default so the tree opens out on first render. The
  stable id persists user expand/collapse across the 60s
  overview-poll refreshes.
- `BuildersProvider.getChildren` learns a folder-dispatch branch:
  expanding a folder row materialises its children (each either
  another folder or a leaf BuilderFileTreeItem).
- `BuilderFileTreeItem` itself is unchanged — the leaf shape and
  click action are identical in both modes; only the grouping
  around the leaves differs.

Wiring follows the accordion precedent at extension.ts: read
setting → set context key → listen for config change → flip key +
call `buildersProvider.refresh()` so the tree redraws in the new
mode. A new public `refresh()` method on BuildersProvider exposes
the change-emitter for this purpose.

10 new test cases in `file-path-tree.test.ts` cover empty input,
flat files, deep single-file compaction, shared-prefix grouping,
diverging branches (independent compaction), mixed folder/file
ordering at one level, renames (oldPath survives), folder-with-
single-file-child staying uncompacted, case-insensitive sorting,
and a realistic monorepo PR shape end-to-end. vscode tests:
67 → 77 passing.
…json is honored

The dev-launch path (codev.runWorkspaceDev / codev.runWorktreeDev) was
reading worktree.devCommand directly from .codev/config.json via
fs.readFileSync, bypassing the 5-layer config merge. Result: if the
devCommand was set in .codev/config.local.json (the per-engineer
override layer) but not in the committed config.json, clicking 'Start
Dev Server' surfaced 'Configure worktree.devCommand …' even though
the resolved config had it.

Switches dev-shared.ts to loadWorktreeConfig(), which calls Tower's
GET /api/worktree-config — the same source the Workspace tree view
uses for the Start row's visibility/tooltip. The launch path and the
row visibility now agree on what 'configured' means.
After the accordion fires (a user expanded a different builder, so
the current one collapsed via `workbench.actions.treeView.codev.
builders.collapseAll`), the subsequent reveal at the new builder used
`expand: true` — which expands only the target row, not its
descendants. VSCode had just persisted "collapsed" against each
folder's stable id during the collapseAll step, so re-expanding the
builder restored the persisted state for every child level and the
file tree appeared closed.

VSCode's TreeView.reveal clamps `expand` at 3 in mainThreadTreeViews.ts
(`Math.min(expand, 3)`); `true` maps to 1. Switching `expand: true` →
`expand: 3` asks for the API ceiling — the re-expansion cascades
through the descendant folders, restoring the default
"folders expanded" look that fresh-rendered BuilderFolderTreeItems
carry via TreeItemCollapsibleState.Expanded.

With single-child folder compaction (from the SCM-style toggle that
just landed), the practical worktree depth fits comfortably in 3
levels — e.g. `packages/codev/src/commands/consult/index.ts`
compacts to builder → `packages` → `codev/src/commands/consult` →
file leaves, exactly 3 levels below the builder row. Trees with
4+ distinct branching folder levels after compaction would have
their deepest level remain collapsed (VSCode's hard cap, not
addressable via a single reveal call); rare in the codebases this
surface targets, and the user can still click into them manually.
If it ever bites, the workaround is iterating reveal() over the
builder's descendant folders to compound 3-level windows — left as
follow-up work.
Four user-facing features and four bug fixes covering the 12-commit
batch on this branch:

What's new — `worktree.devUrls` config-driven "Open Dev URL" rows in
the Workspace view; per-engineer `.codev/config.local.json` overrides
layered on top of the shared config; live-refresh of the sidebar on
config edits (driven via Tower so multiple windows stay in sync);
title-bar toggle on the Builders view to switch the changed-files
list between a folder tree (default, SCM-style) and a flat list.

Bug fixes — Workspace view now detects dev servers started from
the builder right-click context menu (not just its own row); the
"Start Dev Server" row is hidden on workspaces without a
`worktree.devCommand`; `config.local.json` overrides now apply when
running the dev command, not only to the sidebar display;
re-expanding a builder after the accordion's auto-collapse now
restores its folder tree, not just the top row.
@amrmelsayed amrmelsayed requested a review from waleedkadous May 20, 2026 10:26
@waleedkadous waleedkadous merged commit e0e2d6e into main May 20, 2026
6 checks passed
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.

2 participants