diff --git a/packages/codev/src/__tests__/config.test.ts b/packages/codev/src/__tests__/config.test.ts index 156df6fe..a6bc864e 100644 --- a/packages/codev/src/__tests__/config.test.ts +++ b/packages/codev/src/__tests__/config.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { deepMerge, loadConfig, resolveProjectConfigPath } from '../lib/config.js'; +import { deepMerge, loadConfig, resolveProjectConfigPath, resolveLocalConfigPath } from '../lib/config.js'; // Helpers let tmpDir: string; @@ -39,6 +39,12 @@ function writeGlobalConfig(config: Record) { fs.writeFileSync(path.join(globalCodevDir, 'config.json'), JSON.stringify(config, null, 2)); } +function writeLocalConfig(workspaceRoot: string, config: Record) { + const dir = path.join(workspaceRoot, '.codev'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.local.json'), JSON.stringify(config, null, 2)); +} + // ============================================================================= // deepMerge // ============================================================================= @@ -112,6 +118,22 @@ describe('resolveProjectConfigPath', () => { }); }); +// ============================================================================= +// resolveLocalConfigPath +// ============================================================================= + +describe('resolveLocalConfigPath', () => { + it('returns .codev/config.local.json path when it exists', () => { + writeLocalConfig(tmpDir, { shell: {} }); + const result = resolveLocalConfigPath(tmpDir); + expect(result).toBe(path.join(tmpDir, '.codev', 'config.local.json')); + }); + + it('returns null when no local config exists', () => { + expect(resolveLocalConfigPath(tmpDir)).toBeNull(); + }); +}); + // ============================================================================= // loadConfig // ============================================================================= @@ -201,4 +223,34 @@ describe('loadConfig', () => { const config = loadConfig(tmpDir); expect(config.porch?.checks?.lint).toEqual({ skip: true }); }); + + it('layer 5: .codev/config.local.json overrides defaults when project config absent', () => { + writeLocalConfig(tmpDir, { + shell: { architect: 'local-only-architect' }, + }); + const config = loadConfig(tmpDir); + expect(config.shell?.architect).toBe('local-only-architect'); + expect(config.shell?.builder).toBe('claude'); // default preserved + }); + + it('layer 5: local overrides project, non-overlapping project keys survive', () => { + writeProjectConfig(tmpDir, { + shell: { architect: 'project-architect', builder: 'project-builder' }, + }); + writeLocalConfig(tmpDir, { + shell: { architect: 'local-architect' }, + }); + const config = loadConfig(tmpDir); + expect(config.shell?.architect).toBe('local-architect'); // local wins + expect(config.shell?.builder).toBe('project-builder'); // project survives + }); + + it('layer 5: missing config.local.json is a no-op', () => { + writeProjectConfig(tmpDir, { + shell: { architect: 'project-architect' }, + }); + // no writeLocalConfig — file absent + const config = loadConfig(tmpDir); + expect(config.shell?.architect).toBe('project-architect'); + }); }); diff --git a/packages/codev/src/agent-farm/__tests__/dev.test.ts b/packages/codev/src/agent-farm/__tests__/dev.test.ts index 76dbe0b2..9929f0f2 100644 --- a/packages/codev/src/agent-farm/__tests__/dev.test.ts +++ b/packages/codev/src/agent-farm/__tests__/dev.test.ts @@ -40,6 +40,7 @@ const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev', + devUrls: [], })); vi.mock('../utils/index.js', () => ({ getConfig: () => ({ workspaceRoot: '/proj' }), @@ -94,7 +95,7 @@ beforeEach(() => { findBuilderByIdMock.mockReturnValue(builder); listTerminalsMock.mockResolvedValue([]); killTerminalMock.mockResolvedValue(true); - getWorktreeConfigMock.mockReturnValue({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev' }); + getWorktreeConfigMock.mockReturnValue({ symlinks: [], postSpawn: [], devCommand: 'pnpm dev', devUrls: [] }); }); // ─── Tests ────────────────────────────────────────────────────────────── @@ -110,7 +111,7 @@ describe('afx dev — validation', () => { }); it('errors when worktree.devCommand is unset', async () => { - getWorktreeConfigMock.mockReturnValueOnce({ symlinks: [], postSpawn: [], devCommand: null }); + getWorktreeConfigMock.mockReturnValueOnce({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] }); await expect(dev({ builderId: 'spir-42' })).rejects.toThrow(/No worktree\.devCommand configured/); }); }); diff --git a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts index 790bdc8d..228ad3c7 100644 --- a/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts +++ b/packages/codev/src/agent-farm/__tests__/spawn-worktree.test.ts @@ -64,7 +64,7 @@ vi.mock('../../lib/forge.js', () => ({ // Mock the harness resolution to return claude harness by default import { CLAUDE_HARNESS, OPENCODE_HARNESS } from '../utils/harness.js'; const getBuilderHarnessMock = vi.fn(() => CLAUDE_HARNESS); -const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null })); +const getWorktreeConfigMock = vi.fn(() => ({ symlinks: [], postSpawn: [], devCommand: null, devUrls: [] })); vi.mock('../utils/config.js', () => ({ getBuilderHarness: (...args: unknown[]) => getBuilderHarnessMock(...args), getWorktreeConfig: (...args: unknown[]) => getWorktreeConfigMock(...args), @@ -882,6 +882,7 @@ describe('spawn-worktree', () => { symlinks: ['.env.local', 'packages/*/.env'], postSpawn: [], devCommand: null, + devUrls: [], }); // Hardcoded section (.env / .codev/config.json) — both absent vi.mocked(existsSync) @@ -914,6 +915,7 @@ describe('spawn-worktree', () => { symlinks: ['.env.local'], postSpawn: [], devCommand: null, + devUrls: [], }); // Hardcoded section: both absent so no calls there vi.mocked(existsSync) diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index a61451c3..cecc84a1 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -39,6 +39,8 @@ import { serveStaticFile, } from './tower-utils.js'; import { handleTunnelEndpoint } from './tower-tunnel.js'; +import { getWorktreeConfig } from '../utils/config.js'; +import { ensureWorktreeConfigWatcher } from './worktree-config-watcher.js'; import { hasTeam, loadTeamMembers, loadMessages, type TeamMember, type TeamMessage } from '../../lib/team.js'; import { fetchTeamGitHubData, type TeamMemberGitHubData } from '../../lib/team-github.js'; import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js'; @@ -147,6 +149,7 @@ const ROUTES: Record = { 'GET /api/status': (_req, res) => handleStatus(res), 'GET /api/overview': (_req, res, url, ctx) => handleOverview(res, url, undefined, ctx), 'GET /api/issue': (_req, res, url) => handleIssueView(res, url), + 'GET /api/worktree-config': (_req, res, url) => handleWorktreeConfigView(res, url), 'GET /api/analytics': (_req, res, url) => handleAnalytics(res, url), 'POST /api/overview/refresh': (_req, res, _url, ctx) => handleOverviewRefresh(res, ctx), 'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx), @@ -817,6 +820,42 @@ async function handleIssueView(res: http.ServerResponse, url: URL): Promise !p.includes('/.builders/')) || null; + } + if (!workspaceRoot) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing workspace' })); + return; + } + try { + const config = getWorktreeConfig(workspaceRoot); + ensureWorktreeConfigWatcher(workspaceRoot); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(config)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Failed to resolve worktree config: ${message}` })); + } +} + function handleOverviewRefresh(res: http.ServerResponse, ctx?: RouteContext): void { overviewCache.invalidate(); // Bugfix #388: Broadcast SSE event so all connected dashboard clients diff --git a/packages/codev/src/agent-farm/servers/tower-server.ts b/packages/codev/src/agent-farm/servers/tower-server.ts index 24166e08..826b202d 100644 --- a/packages/codev/src/agent-farm/servers/tower-server.ts +++ b/packages/codev/src/agent-farm/servers/tower-server.ts @@ -49,6 +49,7 @@ import { } from './tower-websocket.js'; import { handleRequest, startSendBuffer, stopSendBuffer } from './tower-routes.js'; import type { RouteContext } from './tower-routes.js'; +import { setWorktreeConfigNotifier, stopAllWorktreeConfigWatchers } from './worktree-config-watcher.js'; import { DEFAULT_TOWER_PORT } from '../lib/tower-client.js'; import { validateHost } from '../utils/server-utils.js'; @@ -159,6 +160,9 @@ async function gracefulShutdown(signal: string): Promise { // 6. Disconnect tunnel (Spec 0097 Phase 4 / Spec 0105 Phase 2) shutdownTunnel(); + // 6b. Close per-workspace .codev/config(.local).json watchers. + stopAllWorktreeConfigWatchers(); + // 7. Tear down instance module (Spec 0105 Phase 3) shutdownInstances(); @@ -319,6 +323,12 @@ const routeCtx: RouteContext = { }, }; +// Wire the broadcast function into the worktree config watcher so file +// edits to .codev/config{,.local}.json fan out as +// `worktree-config-updated` SSE events. The actual watcher is installed +// lazily by the /api/worktree-config route handler on first request. +setWorktreeConfigNotifier(broadcastNotification); + // ============================================================================ // Create server — delegates all HTTP handling to tower-routes.ts // ============================================================================ diff --git a/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts b/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts new file mode 100644 index 00000000..6d2620c4 --- /dev/null +++ b/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts @@ -0,0 +1,81 @@ +/** + * Per-workspace file watcher for `.codev/config.json` and + * `.codev/config.local.json`. Lazily installed by the + * `/api/worktree-config` route handler on first request, then persists + * for the Tower process lifetime. On each detected change it fans out + * a `worktree-config-updated` SSE event so subscribed clients (the + * VSCode extension, the dashboard) can refetch via the route and + * re-render. + * + * Pattern follows `tower-tunnel.ts:startConfigWatcher` (which watches + * `~/.codev/cloud.json` for OAuth credential changes) — `node:fs.watch` + * on the parent directory, filename filter, short debounce to coalesce + * the multiple events that fire per save. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +type NotifyFn = (notification: { + type: string; + title: string; + body: string; + workspace?: string; +}) => void; + +const TARGET_FILES = new Set(['config.json', 'config.local.json']); +const DEBOUNCE_MS = 50; + +const watchers = new Map(); +const debounces = new Map(); +let notify: NotifyFn | undefined; + +/** + * Wire the broadcast function once at Tower startup. Subsequent calls + * to `ensureWorktreeConfigWatcher` will use this notifier when files + * change. + */ +export function setWorktreeConfigNotifier(fn: NotifyFn): void { + notify = fn; +} + +/** + * Lazily install (or no-op if already installed) a file watcher for + * `/.codev/{config.json,config.local.json}`. Safe to + * call on every route hit. + */ +export function ensureWorktreeConfigWatcher(workspacePath: string): void { + if (watchers.has(workspacePath)) { return; } + const dir = path.join(workspacePath, '.codev'); + try { + const watcher = fs.watch(dir, { persistent: false }, (_event, filename) => { + if (!filename || !TARGET_FILES.has(filename)) { return; } + const prev = debounces.get(workspacePath); + if (prev) { clearTimeout(prev); } + debounces.set( + workspacePath, + setTimeout(() => { + debounces.delete(workspacePath); + notify?.({ + type: 'worktree-config-updated', + title: 'Worktree config changed', + body: JSON.stringify({ workspace: workspacePath }), + workspace: workspacePath, + }); + }, DEBOUNCE_MS), + ); + }); + watcher.on('error', () => { /* benign — dir may be removed mid-watch */ }); + watchers.set(workspacePath, watcher); + } catch { + // `.codev/` may not exist yet; the next ensure call will retry. + } +} + +/** Test / shutdown helper — close every watcher and clear pending debounces. */ +export function stopAllWorktreeConfigWatchers(): void { + for (const t of debounces.values()) { clearTimeout(t); } + debounces.clear(); + for (const w of watchers.values()) { try { w.close(); } catch { /* benign */ } } + watchers.clear(); +} diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index d68ef7e8..4edad89c 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -212,6 +212,19 @@ export interface UserConfig { * Example: 'pnpm dev'. */ devCommand?: string; + /** + * Dev URLs the running app(s) listen on — surfaced as one + * workspace-view row per entry in VSCode (`label` = row text, + * `url` = what opens in the default browser). The palette command + * `Codev: Open Dev URL` shows a QuickPick when invoked without a + * specific target. Both fields are required; entries missing + * either are silently filtered out. + * Example: + * [{ "label": "App", "url": "http://localhost:3000" }, + * { "label": "API", "url": "http://localhost:3001" }, + * { "label": "Admin", "url": "http://localhost:8080/admin" }] + */ + devUrls?: Array<{ label: string; url: string }>; }; } diff --git a/packages/codev/src/agent-farm/utils/config.ts b/packages/codev/src/agent-farm/utils/config.ts index e6304f22..c5ca82d2 100644 --- a/packages/codev/src/agent-farm/utils/config.ts +++ b/packages/codev/src/agent-farm/utils/config.ts @@ -11,6 +11,12 @@ import { getSkeletonDir } from '../../lib/skeleton.js'; import { loadConfig } from '../../lib/config.js'; import type { CodevConfig } from '../../lib/config.js'; import { resolveHarness, type HarnessProvider, type CustomHarnessConfig } from './harness.js'; +import type { ResolvedWorktreeConfig, WorktreeDevUrl } from '@cluesmith/codev-types'; + +// Re-export so existing internal callers that import the resolved types +// from this module keep working. The canonical home is now +// @cluesmith/codev-types (these cross HTTP via /api/worktree-config). +export type { ResolvedWorktreeConfig, WorktreeDevUrl }; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -269,18 +275,10 @@ export function getBuilderHarness(workspaceRoot?: string): HarnessProvider { ); } -/** - * Resolved view of the `worktree` config block with defaults applied. - * Unset fields collapse to empty / null so callers don't have to branch. - */ -export interface ResolvedWorktreeConfig { - /** Glob patterns to symlink from workspace root into each worktree. `[]` when unset. */ - symlinks: string[]; - /** Shell commands to run in each worktree after creation. `[]` when unset. */ - postSpawn: string[]; - /** Command for `afx dev `. `null` when unset. */ - devCommand: string | null; -} +// ResolvedWorktreeConfig + WorktreeDevUrl now live in +// @cluesmith/codev-types (they cross HTTP via /api/worktree-config). +// Re-exported from this module at the top of the file so existing +// internal callers keep working. /** * Load the `worktree` block from .codev/config.json, applying defaults. @@ -296,9 +294,24 @@ export function getWorktreeConfig(workspaceRoot?: string): ResolvedWorktreeConfi symlinks: w?.symlinks ?? [], postSpawn: w?.postSpawn ?? [], devCommand: w?.devCommand ?? null, + devUrls: resolveDevUrls(w), }; } +/** + * Filter malformed entries (missing/empty `label` or `url`). Both + * fields are mandatory by schema — no default-label fallback. + */ +function resolveDevUrls(w: { devUrls?: Array<{ label?: string; url?: string }> } | undefined): WorktreeDevUrl[] { + if (!Array.isArray(w?.devUrls)) { return []; } + return w.devUrls + .map(e => ({ + label: typeof e?.label === 'string' ? e.label.trim() : '', + url: typeof e?.url === 'string' ? e.url.trim() : '', + })) + .filter(e => e.label && e.url); +} + /** * Build configuration for the current project */ diff --git a/packages/codev/src/lib/config.ts b/packages/codev/src/lib/config.ts index 9c7925ad..0fe646c6 100644 --- a/packages/codev/src/lib/config.ts +++ b/packages/codev/src/lib/config.ts @@ -194,14 +194,34 @@ export function resolveProjectConfigPath(workspaceRoot: string): string | null { return null; } +/** + * Resolve the project-local override config path. + * + * Returns .codev/config.local.json if it exists, otherwise null. No + * legacy alias to consider — this layer is new. The local file is + * intended to be gitignored and per-engineer. + */ +export function resolveLocalConfigPath(workspaceRoot: string): string | null { + const localPath = resolve(workspaceRoot, '.codev', 'config.local.json'); + if (existsSync(localPath)) return localPath; + return null; +} + /** * Load the full merged config for a workspace. * * Layer order (lowest → highest priority): * 1. Hardcoded defaults * 2. /config.json (remote framework base config) - * 3. ~/.codev/config.json (global) - * 4. .codev/config.json (project) + * 3. ~/.codev/config.json (global, per-user, across all projects) + * 4. .codev/config.json (project, committed, shared with the team) + * 5. .codev/config.local.json (project, per-engineer, gitignored) + * + * Layer 5 is the place to put preferences that vary between engineers + * working on the same repo (e.g. different `worktree.devCommand` for + * web vs mobile roles) — Layer 3 spans every project so can't express + * "in *this* repo I want X." Add the file to your project's + * `.gitignore` so it's never accidentally committed. */ export function loadConfig(workspaceRoot: string): CodevConfig { let merged: CodevConfig = structuredClone(DEFAULT_CONFIG); @@ -232,6 +252,15 @@ export function loadConfig(workspaceRoot: string): CodevConfig { } } + // Layer 5: project-local override (gitignored, per-engineer). + const localPath = resolveLocalConfigPath(workspaceRoot); + if (localPath) { + const localConfig = readJsonFile(localPath); + if (localConfig) { + merged = deepMerge(merged as unknown as Record, localConfig) as CodevConfig; + } + } + // Validate custom harness definitions at load time if (merged.harness) { for (const [name, def] of Object.entries(merged.harness)) { diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index fdc438b1..34648a16 100644 --- a/packages/core/src/tower-client.ts +++ b/packages/core/src/tower-client.ts @@ -7,7 +7,7 @@ * Extracted from packages/codev/src/agent-farm/lib/tower-client.ts */ -import type { DashboardState, OverviewData, IssueView } from '@cluesmith/codev-types'; +import type { DashboardState, OverviewData, IssueView, ResolvedWorktreeConfig } from '@cluesmith/codev-types'; import { DEFAULT_TOWER_PORT } from './constants.js'; import { ensureLocalKey } from './auth.js'; @@ -273,6 +273,23 @@ export class TowerClient { return result.ok ? result.data! : null; } + /** + * Fetch the canonical resolved worktree config (defaults / cache / + * global / project / project-local layers, deep-merged) from Tower's + * GET /api/worktree-config. The single source of truth for any client + * that needs to act on `.codev/config(.local).json` — e.g. the VSCode + * "Open Dev URL" surface — without parsing or merging the files + * locally. Tower lazily installs a directory watcher on first call; + * subsequent edits fan out a `worktree-config-updated` SSE event so + * subscribed clients refetch and re-render. Returns null on failure + * so callers can degrade. + */ + async getWorktreeConfig(workspacePath?: string): Promise { + const query = workspacePath ? `?workspace=${encodeURIComponent(workspacePath)}` : ''; + const result = await this.request(`/api/worktree-config${query}`); + return result.ok ? result.data! : null; + } + /** * Invalidate Tower's in-memory overview cache and broadcast an * `overview-changed` SSE event. Subscribed clients (VSCode sidebar, diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index c2514519..8a130773 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -187,6 +187,36 @@ export interface OverviewData { errors?: { prs?: string; issues?: string }; } +// --- Worktree config (GET /api/worktree-config) --- + +/** One row in the VSCode "Open Dev URL" workspace surface. */ +export interface WorktreeDevUrl { + label: string; + url: string; +} + +/** + * Resolved view of the `worktree` config block with defaults applied + * across the loadConfig layer chain (defaults / cache / global / + * project / project-local). Always has populated fields — unset + * scalars collapse to null, unset collections to empty arrays — so + * consumers don't have to branch. + */ +export interface ResolvedWorktreeConfig { + /** Glob patterns to symlink from workspace root into each worktree. `[]` when unset. */ + symlinks: string[]; + /** Shell commands to run in each worktree after creation. `[]` when unset. */ + postSpawn: string[]; + /** Command for `afx dev `. `null` when unset. */ + devCommand: string | null; + /** + * Canonical resolved list of dev URLs for the VSCode "Open Dev URL" + * workspace surface. Always an array — `[]` when neither `devUrl` + * nor `devUrls` is set in the user config. + */ + devUrls: WorktreeDevUrl[]; +} + // --- Issue view (GET /api/issue) --- /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ef2b6a5b..9bcdadf0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -24,6 +24,8 @@ export { type OverviewRecentlyClosed, type OverviewData, type IssueView, + type WorktreeDevUrl, + type ResolvedWorktreeConfig, type TeamMemberGitHubData, type ReviewBlockingEntry, type TeamApiMember, diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 28dc3dde..3e41a757 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -2,7 +2,23 @@ What's changed in the Codev VS Code extension, version by version, written for the developers who use it. -## [3.0.8] - 2026-05-19 +## [Unreleased] + +### What's new + +- **"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 your **default browser**. Distinct from "Open Web Interface", which always points at Tower's dashboard. +- **Per-engineer config overrides via `.codev/config.local.json`.** A gitignored sibling to `.codev/config.json` that layers your personal overrides on top of the shared project config — your local staging URLs, tunnel hostnames, etc. stay out of the file everyone else commits. +- **Workspace view live-refreshes on config edits.** Edit `.codev/config.json` or `.codev/config.local.json` and every open VSCode window's sidebar re-renders immediately; no reload needed. Driven via Tower so multiple windows stay in sync. +- **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 and a flat list. Setting: `codev.buildersFileViewAsTree`. + +### 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 what the sidebar displayed; 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 — not just the top builder row. + +## [3.0.8] - 2026-05-20 ### What's new diff --git a/packages/vscode/package.json b/packages/vscode/package.json index db1984f0..0b98b776 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -94,6 +94,16 @@ "title": "Codev: Turn Off Builders Auto-Collapse", "icon": "$(fold)" }, + { + "command": "codev.enableBuildersFileTreeMode", + "title": "Codev: View Changed Files as Tree", + "icon": "$(list-tree)" + }, + { + "command": "codev.disableBuildersFileTreeMode", + "title": "Codev: View Changed Files as List", + "icon": "$(list-flat)" + }, { "command": "codev.refreshTeam", "title": "Codev: Refresh Team", @@ -148,6 +158,10 @@ "command": "codev.stopWorkspaceDev", "title": "Codev: Stop Dev Server (this workspace)" }, + { + "command": "codev.openDevUrl", + "title": "Codev: Open Dev URL" + }, { "command": "codev.pasteImage", "title": "Codev: Paste Image into Terminal", @@ -347,6 +361,16 @@ "when": "view == codev.builders && !codev.buildersAutoCollapse", "group": "navigation" }, + { + "command": "codev.disableBuildersFileTreeMode", + "when": "view == codev.builders && codev.buildersFileViewAsTree", + "group": "navigation" + }, + { + "command": "codev.enableBuildersFileTreeMode", + "when": "view == codev.builders && !codev.buildersFileViewAsTree", + "group": "navigation" + }, { "command": "codev.refreshOverview", "when": "view == codev.pullRequests", @@ -524,6 +548,11 @@ "type": "boolean", "default": true, "description": "Builders view accordion: expanding one builder auto-collapses the others, so only one builder's changed-files diff is open at a time." + }, + "codev.buildersFileViewAsTree": { + "type": "boolean", + "default": true, + "description": "Render a builder's changed-files list as a folder tree (with single-child folder chains compacted, like VSCode's Source Control panel) instead of a flat list. Toggleable via the Builders title-bar button." } } } diff --git a/packages/vscode/src/commands/dev-shared.ts b/packages/vscode/src/commands/dev-shared.ts index 35d4dca0..6c2cbb0c 100644 --- a/packages/vscode/src/commands/dev-shared.ts +++ b/packages/vscode/src/commands/dev-shared.ts @@ -14,10 +14,10 @@ */ import * as vscode from 'vscode'; -import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import type { ConnectionManager } from '../connection-manager.js'; import type { TerminalManager } from '../terminal-manager.js'; +import { loadWorktreeConfig } from '../load-worktree-config.js'; export const KILL_WAIT_TIMEOUT_MS = 7000; export const KILL_POLL_INTERVAL_MS = 200; @@ -50,19 +50,6 @@ export function resolveWorkspaceDevTarget(workspacePath: string): DevTarget { return { id: 'main', cwd: workspacePath, name: 'Workspace' }; } -/** Read `worktree.devCommand` from `.codev/config.json`; null if unset/unreadable. */ -export async function readDevCommand(workspacePath: string): Promise { - const configPath = path.join(workspacePath, '.codev', 'config.json'); - try { - const raw = await readFile(configPath, 'utf-8'); - const parsed = JSON.parse(raw) as { worktree?: { devCommand?: unknown } }; - const cmd = parsed.worktree?.devCommand; - return typeof cmd === 'string' && cmd.length > 0 ? cmd : null; - } catch { - return null; - } -} - /** Poll `listTerminals` until the killed terminal disappears (or timeout). */ export async function waitForTerminalGone( client: NonNullable>, @@ -95,10 +82,16 @@ export async function startDevForTarget( return; } - const devCommand = await readDevCommand(workspacePath); + // Resolve the dev command through Tower so the full 5-layer config merge + // (defaults / cache / global / project / project-local) applies — including + // `.codev/config.local.json`, which is the per-engineer override layer. + // Reading `.codev/config.json` directly would miss the local override. + const worktreeConfig = await loadWorktreeConfig(connectionManager); + const devCommand = worktreeConfig?.devCommand ?? null; if (!devCommand) { vscode.window.showErrorMessage( - 'Codev: Configure worktree.devCommand in .codev/config.json to use this action. ' + + 'Codev: Configure worktree.devCommand in .codev/config.json ' + + '(or .codev/config.local.json) to use this action. ' + 'See "Runnable Worktrees" in CLAUDE.md for stack-specific recipes.', ); return; diff --git a/packages/vscode/src/commands/open-dev-url.ts b/packages/vscode/src/commands/open-dev-url.ts new file mode 100644 index 00000000..7c07bcb8 --- /dev/null +++ b/packages/vscode/src/commands/open-dev-url.ts @@ -0,0 +1,56 @@ +/** + * Codev: Open Dev URL — open URLs configured under `worktree.devUrls` + * in the user's default browser. Surfaced as one workspace-view row + * per configured URL (label = row text), plus a palette command. + * Both `label` and `url` are mandatory per schema; entries missing + * either are silently filtered out (by the resolver server-side). + * + * The resolved devUrls list comes from Tower's GET /api/worktree-config, + * which applies the full layered config merge (defaults / cache / + * global / project / project-local). The extension never parses + * `.codev/config.json` directly — Tower is the single source of truth + * for "what's configured" so the merge semantics can't drift. + * + * Why the default browser over VSCode's Simple Browser: DevTools / + * Console / Network are dev-loop essentials Simple Browser doesn't + * have, and a real browser sidesteps the third-party-cookie issues + * that come from loading the dev URL inside a `vscode-webview://` + * iframe. + */ + +import * as vscode from 'vscode'; +import type { ConnectionManager } from '../connection-manager.js'; +import { loadWorktreeConfig } from '../load-worktree-config.js'; + +export async function openDevUrl( + connectionManager: ConnectionManager, + urlArg?: string, +): Promise { + // Direct invocation: a row click passes its URL; just open it. + if (typeof urlArg === 'string' && urlArg.trim()) { + await vscode.env.openExternal(vscode.Uri.parse(urlArg)); + return; + } + + // Palette / arg-less invocation: resolve from config and route. + const devUrls = (await loadWorktreeConfig(connectionManager))?.devUrls ?? []; + + if (devUrls.length === 0) { + vscode.window.showWarningMessage( + 'Codev: `worktree.devUrls` not configured in `.codev/config.json`', + ); + return; + } + if (devUrls.length === 1) { + await vscode.env.openExternal(vscode.Uri.parse(devUrls[0]!.url)); + return; + } + + const picked = await vscode.window.showQuickPick( + devUrls.map(d => ({ label: d.label, description: d.url, url: d.url })), + { placeHolder: 'Open which dev URL?' }, + ); + if (picked) { + await vscode.env.openExternal(vscode.Uri.parse(picked.url)); + } +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 3d693ce2..60b3bea4 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -11,6 +11,7 @@ import { viewDiff, activateDiffView, diffUrisForChange } from './commands/view-d import { runWorktreeDev } from './commands/run-worktree-dev.js'; import { stopWorktreeDev } from './commands/stop-worktree-dev.js'; import { runWorkspaceDev, stopWorkspaceDev } from './commands/run-workspace-dev.js'; +import { openDevUrl } from './commands/open-dev-url.js'; import { pasteImage } from './commands/paste-image.js'; import { openWorktreeFolder } from './commands/open-worktree-folder.js'; import { runWorktreeSetup } from './commands/run-worktree-setup.js'; @@ -251,7 +252,8 @@ export async function activate(context: vscode.ExtensionContext) { // List views use createTreeView so their title can carry a live item // count; the rest stay on registerTreeDataProvider. - buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: new BuildersProvider(overviewCache, builderDiffCache) }); + const buildersProvider = new BuildersProvider(overviewCache, builderDiffCache); + buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: buildersProvider }); pullRequestsView = vscode.window.createTreeView('codev.pullRequests', { treeDataProvider: new PullRequestsProvider(overviewCache) }); backlogView = vscode.window.createTreeView('codev.backlog', { treeDataProvider: new BacklogProvider(overviewCache) }); recentlyClosedView = vscode.window.createTreeView('codev.recentlyClosed', { treeDataProvider: new RecentlyClosedProvider(overviewCache) }); @@ -295,7 +297,14 @@ export async function activate(context: vscode.ExtensionContext) { reconciling = true; try { await vscode.commands.executeCommand('workbench.actions.treeView.codev.builders.collapseAll'); - await buildersView!.reveal(e.element, { expand: true, select: false, focus: false }); + // `expand: 3` (the VSCode max) recursively expands the builder + // and its file-tree descendants — so re-expanding after the + // accordion's collapseAll restores the default expanded-folder + // look instead of leaving every folder collapsed. Without this, + // collapseAll persists "collapsed" against each folder's stable + // id, and the next reveal honours that persisted state for + // every level below the builder row itself. + await buildersView!.reveal(e.element, { expand: 3, select: false, focus: false }); } finally { reconciling = false; } @@ -307,6 +316,23 @@ export async function activate(context: vscode.ExtensionContext) { }), ); + // Builders file-view-as-tree: each builder's changed-files list renders + // as a folder tree (with single-child folder chains compacted, like + // VSCode SCM) when on, or as a flat list when off. Toggle via the + // header button / `codev.buildersFileViewAsTree`. Same mechanics as + // accordion above — read setting, mirror to context key, refresh + // provider on change so the tree redraws in the new mode. + const readFileViewAsTree = () => + vscode.workspace.getConfiguration('codev').get('buildersFileViewAsTree', true); + vscode.commands.executeCommand('setContext', 'codev.buildersFileViewAsTree', readFileViewAsTree()); + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration('codev.buildersFileViewAsTree')) { return; } + vscode.commands.executeCommand('setContext', 'codev.buildersFileViewAsTree', readFileViewAsTree()); + buildersProvider.refresh(); + }), + ); + // Periodic overview refresh. VSCode has no timer-based refresh (event-only), // so an idle workspace never sees externally-merged PRs / new issues. Mirror // the dashboard's poll idiom: refresh on a cadence while the Codev sidebar is @@ -501,6 +527,8 @@ export async function activate(context: vscode.ExtensionContext) { runWorkspaceDev(connectionManager!, terminalManager!)), vscode.commands.registerCommand('codev.stopWorkspaceDev', () => stopWorkspaceDev(connectionManager!, terminalManager!)), + vscode.commands.registerCommand('codev.openDevUrl', (urlArg?: unknown) => + openDevUrl(connectionManager!, typeof urlArg === 'string' ? urlArg : undefined)), vscode.commands.registerCommand('codev.pasteImage', () => pasteImage(connectionManager!, terminalManager!)), vscode.commands.registerCommand('codev.refreshTeam', () => teamProvider.refresh()), @@ -515,6 +543,10 @@ export async function activate(context: vscode.ExtensionContext) { vscode.workspace.getConfiguration('codev').update('buildersAutoCollapse', true, vscode.ConfigurationTarget.Global)), vscode.commands.registerCommand('codev.disableBuildersAutoCollapse', () => vscode.workspace.getConfiguration('codev').update('buildersAutoCollapse', false, vscode.ConfigurationTarget.Global)), + vscode.commands.registerCommand('codev.enableBuildersFileTreeMode', () => + vscode.workspace.getConfiguration('codev').update('buildersFileViewAsTree', true, vscode.ConfigurationTarget.Global)), + vscode.commands.registerCommand('codev.disableBuildersFileTreeMode', () => + vscode.workspace.getConfiguration('codev').update('buildersFileViewAsTree', false, vscode.ConfigurationTarget.Global)), vscode.commands.registerCommand('codev.reconnect', () => connectionManager?.reconnect()), vscode.commands.registerCommand('codev.connectTunnel', () => connectTunnel(connectionManager!)), vscode.commands.registerCommand('codev.disconnectTunnel', () => disconnectTunnel(connectionManager!)), diff --git a/packages/vscode/src/load-worktree-config.ts b/packages/vscode/src/load-worktree-config.ts new file mode 100644 index 00000000..11dbbcd8 --- /dev/null +++ b/packages/vscode/src/load-worktree-config.ts @@ -0,0 +1,33 @@ +/** + * Thin client-side wrapper over Tower's `GET /api/worktree-config`. + * + * Returns the canonical `ResolvedWorktreeConfig` for the active + * workspace — Tower applies the full five-layer deep-merge (defaults + * / cache / global / project / project-local), so the extension + * never has to parse or merge `.codev/config(.local).json` itself. + * + * Lives at the top level of `src/` because multiple consumers need + * it (the Workspace tree view, the `Codev: Open Dev URL` command, + * any future config-driven UI). Past versions had this inlined in + * `commands/open-dev-url.ts`, but that file's job is one command; + * the resolved config is a workspace-wide concern. + */ + +import type { ResolvedWorktreeConfig, WorktreeDevUrl } from '@cluesmith/codev-types'; +import type { ConnectionManager } from './connection-manager.js'; + +export type { ResolvedWorktreeConfig, WorktreeDevUrl }; + +/** + * Returns `null` when Tower is unreachable or the workspace isn't + * activated. Callers extract whichever field they need (e.g. + * `(await loadWorktreeConfig(cm))?.devUrls ?? []`). + */ +export async function loadWorktreeConfig( + connectionManager: ConnectionManager, +): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { return null; } + return client.getWorktreeConfig(workspacePath); +} diff --git a/packages/vscode/src/test/file-path-tree.test.ts b/packages/vscode/src/test/file-path-tree.test.ts new file mode 100644 index 00000000..9649381e --- /dev/null +++ b/packages/vscode/src/test/file-path-tree.test.ts @@ -0,0 +1,144 @@ +import * as assert from 'assert'; +import { buildFilePathTree, type FilePathNode } from '../views/file-path-tree.js'; +import type { BuilderFileChange } from '../views/builder-diff-cache.js'; +import type { ChangeStatus } from '../commands/view-diff.js'; + +/** + * Tests for the path-tree builder + single-child folder compaction + * (mirroring VSCode SCM behaviour). + * + * Fixtures fake the BuilderFileChange shape just enough for tree + * grouping — only `plan.resourcePath` and `change.status` matter here; + * the tree builder doesn't inspect anything else. + */ + +function f(resourcePath: string, status: ChangeStatus = 'M'): BuilderFileChange { + return { + change: { status, path: resourcePath }, + plan: { resourcePath } as BuilderFileChange['plan'], + } as BuilderFileChange; +} + +/** Flatten a tree to `[name@fullPath, …]` for compact assertions. */ +function flatten(nodes: FilePathNode[]): string[] { + const out: string[] = []; + const visit = (n: FilePathNode, indent: string) => { + out.push(`${indent}${n.children ? '📁 ' : '📄 '}${n.name}@${n.fullPath}`); + if (n.children) { + for (const c of n.children) { visit(c, indent + ' '); } + } + }; + for (const n of nodes) { visit(n, ''); } + return out; +} + +suite('buildFilePathTree', () => { + test('empty input → empty output', () => { + assert.deepStrictEqual(buildFilePathTree([]), []); + }); + + test('flat-only files (no folders) → that many top-level leaves', () => { + const out = buildFilePathTree([f('a.ts'), f('b.ts'), f('README.md')]); + // All leaves, no children, sorted alphabetically (case-insensitive). + assert.deepStrictEqual(out.map(n => n.name), ['a.ts', 'b.ts', 'README.md']); + assert.ok(out.every(n => !n.children && n.file)); + }); + + test('single deep file is fully compacted into one folder node', () => { + // a/b/c/d.ts → one folder "a/b/c" containing one leaf "d.ts" + const out = buildFilePathTree([f('a/b/c/d.ts')]); + assert.strictEqual(out.length, 1); + const folder = out[0]!; + assert.strictEqual(folder.name, 'a/b/c'); + assert.strictEqual(folder.fullPath, 'a/b/c'); + assert.ok(folder.children); + assert.strictEqual(folder.children!.length, 1); + assert.strictEqual(folder.children![0]!.name, 'd.ts'); + assert.strictEqual(folder.children![0]!.fullPath, 'a/b/c/d.ts'); + assert.ok(folder.children![0]!.file); + }); + + test('multiple files sharing a prefix → shared folder, file children inside', () => { + const out = buildFilePathTree([ + f('packages/codev/src/x.ts'), + f('packages/codev/src/y.ts'), + ]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0]!.name, 'packages/codev/src'); + assert.deepStrictEqual( + out[0]!.children!.map(c => c.name), + ['x.ts', 'y.ts'], + ); + }); + + test('diverging branches keep their own compacted prefixes', () => { + // Two top-level packages with deep internals — each side compacts + // independently, no cross-branch merging. + const out = buildFilePathTree([ + f('packages/codev/src/a.ts'), + f('packages/vscode/src/b.ts'), + ]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0]!.name, 'packages'); + const pkgs = out[0]!.children!; + assert.strictEqual(pkgs.length, 2); + assert.deepStrictEqual(pkgs.map(p => p.name), ['codev/src', 'vscode/src']); + }); + + test('mixed files and folders at one level → folders sort before files', () => { + const out = buildFilePathTree([ + f('z.ts'), // top-level file + f('a/sub.ts'), // folder "a" + f('README.md'), // top-level file + ]); + // Order: folder ("a"), then files (README.md, z.ts) — sort within group. + assert.deepStrictEqual(out.map(n => n.name), ['a', 'README.md', 'z.ts']); + }); + + test('renames carry through to the leaf untouched', () => { + const renamed = f('new/path.ts', 'R'); + (renamed.change as { oldPath?: string }).oldPath = 'old/path.ts'; + const out = buildFilePathTree([renamed]); + // Walk to the leaf. + assert.strictEqual(out[0]!.name, 'new'); + const leaf = out[0]!.children![0]!; + assert.strictEqual(leaf.name, 'path.ts'); + assert.strictEqual(leaf.file!.change.status, 'R'); + assert.strictEqual((leaf.file!.change as { oldPath?: string }).oldPath, 'old/path.ts'); + }); + + test('folder containing exactly one *file* (not folder) child stays uncompacted', () => { + // VSCode SCM compacts single-child *folder* chains, but a folder + // whose lone child is a file stays as two rows — the file is the + // meaningful leaf. + const out = buildFilePathTree([f('docs/README.md')]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0]!.name, 'docs'); + assert.strictEqual(out[0]!.children!.length, 1); + assert.strictEqual(out[0]!.children![0]!.name, 'README.md'); + }); + + test('alphabetical sort is case-insensitive', () => { + const out = buildFilePathTree([f('Zoo.ts'), f('apple.ts'), f('Banana.ts')]); + assert.deepStrictEqual(out.map(n => n.name), ['apple.ts', 'Banana.ts', 'Zoo.ts']); + }); + + test('full-tree shape for a realistic monorepo PR', () => { + const out = buildFilePathTree([ + f('packages/codev/src/commands/consult/index.ts'), + f('packages/codev/src/commands/consult/types.ts'), + f('packages/vscode/src/views/builders.ts'), + f('CHANGELOG.md'), + ]); + const flat = flatten(out); + assert.deepStrictEqual(flat, [ + '📁 packages@packages', + ' 📁 codev/src/commands/consult@packages/codev/src/commands/consult', + ' 📄 index.ts@packages/codev/src/commands/consult/index.ts', + ' 📄 types.ts@packages/codev/src/commands/consult/types.ts', + ' 📁 vscode/src/views@packages/vscode/src/views', + ' 📄 builders.ts@packages/vscode/src/views/builders.ts', + '📄 CHANGELOG.md@CHANGELOG.md', + ]); + }); +}); diff --git a/packages/vscode/src/views/builder-folder-tree-item.ts b/packages/vscode/src/views/builder-folder-tree-item.ts new file mode 100644 index 00000000..3a5c270f --- /dev/null +++ b/packages/vscode/src/views/builder-folder-tree-item.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; +import type { FilePathNode } from './file-path-tree.js'; + +/** + * Intermediate folder row in tree-mode rendering of a builder's + * changed files. Carries everything the file-row constructor downstream + * needs (`worktreePath`, `baseRef`) so `BuildersProvider.getChildren` + * can materialise children without re-fetching from the diff cache. + * + * Expanded by default — mirrors VSCode SCM, where the file-tree opens + * out under each repository. A stable `id` (`::folder::`) + * lets VSCode persist user expand/collapse across the overview-poll + * refreshes; without it the tree would reset on every tick. + */ +export class BuilderFolderTreeItem extends vscode.TreeItem { + constructor( + public readonly builderId: string, + public readonly worktreePath: string, + public readonly baseRef: string, + public readonly node: FilePathNode, + ) { + super(node.name, vscode.TreeItemCollapsibleState.Expanded); + this.id = `${builderId}::folder::${node.fullPath}`; + this.iconPath = vscode.ThemeIcon.Folder; + this.contextValue = 'builder-file-folder'; + // No `command` — folder rows have no click action. + } +} diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts index be1e562b..743c18cd 100644 --- a/packages/vscode/src/views/builders.ts +++ b/packages/vscode/src/views/builders.ts @@ -4,6 +4,8 @@ import { isIdleWaiting } from '@cluesmith/codev-core/builder-helpers'; import type { OverviewCache } from './overview-data.js'; import { BuilderTreeItem } from './builder-tree-item.js'; import { BuilderFileTreeItem } from './builder-file-tree-item.js'; +import { BuilderFolderTreeItem } from './builder-folder-tree-item.js'; +import { buildFilePathTree, type FilePathNode } from './file-path-tree.js'; import type { BuilderDiffCache } from './builder-diff-cache.js'; /** @@ -44,6 +46,15 @@ export class BuildersProvider implements vscode.TreeDataProvider this.changeEmitter.fire()); } + /** + * Force a re-render. Used by config-change listeners (e.g. the + * file-view-as-tree toggle) that aren't reflected in the overview + * cache but need the tree to redraw with the new setting applied. + */ + refresh(): void { + this.changeEmitter.fire(); + } + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { return element; } @@ -60,6 +71,12 @@ export class BuildersProvider implements vscode.TreeDataProvider + materialiseNode(element.builderId, element.worktreePath, element.baseRef, child), + ); + } // File rows are leaves. if (element instanceof BuilderFileTreeItem) { return []; @@ -119,7 +136,12 @@ export class BuildersProvider implements vscode.TreeDataProvider { const builder = this.cache.getData()?.builders.find(b => b.id === builderId); if (!builder?.worktreePath) { @@ -135,10 +157,49 @@ export class BuildersProvider implements vscode.TreeDataProvider + materialiseNode(builderId, builder.worktreePath, result.baseRef, node), + ); + } + // List mode (today's behaviour): flat, one row per changed file. return result.files.map( f => new BuilderFileTreeItem(builderId, builder.worktreePath, result.baseRef, f.change, f.plan), ); } + + /** Read the file-view-as-tree setting; falls back to the spec default. */ + private viewAsTree(): boolean { + return vscode.workspace + .getConfiguration('codev') + .get('buildersFileViewAsTree', true); + } +} + +/** + * Render one tree-mode node as either a folder row (if it has children) + * or a file row (if it carries a leaf). Folders carry the worktreePath + * + baseRef forward so the renderer can construct file children on + * subsequent expansion without re-fetching from the diff cache. + */ +function materialiseNode( + builderId: string, + worktreePath: string, + baseRef: string, + node: FilePathNode, +): vscode.TreeItem { + if (node.children) { + return new BuilderFolderTreeItem(builderId, worktreePath, baseRef, node); + } + // Leaf: must have a file (folder-without-children shouldn't be reachable + // from buildFilePathTree, but the type allows it — guard defensively). + if (!node.file) { + return placeholder(node.name); + } + return new BuilderFileTreeItem(builderId, worktreePath, baseRef, node.file.change, node.file.plan); } /** Non-clickable informational leaf (no worktree / no changes / error). */ diff --git a/packages/vscode/src/views/file-path-tree.ts b/packages/vscode/src/views/file-path-tree.ts new file mode 100644 index 00000000..b8266855 --- /dev/null +++ b/packages/vscode/src/views/file-path-tree.ts @@ -0,0 +1,122 @@ +/** + * Group a flat list of changed files into a nested folder tree, with + * VSCode SCM-style compaction: any folder with exactly one child folder + * gets merged into that child's path, so `packages/codev/src` renders + * as a single row instead of three nested folders. + * + * Pure path manipulation — no VSCode dependency, easy to unit-test. + * The leaf node carries the original `BuilderFileChange` verbatim so + * the renderer can build `BuilderFileTreeItem` instances unchanged + * (including renames, status badges, etc.). + */ + +import type { BuilderFileChange } from './builder-diff-cache.js'; + +/** + * One node in the file-path tree. Folder nodes have `children`; leaf + * (file) nodes have `file`. The two are mutually exclusive in practice + * but the shape doesn't enforce it — the renderer branches on which + * field is present. + * + * `name` is the *display* label (may be compacted, e.g. + * `packages/codev/src`). `fullPath` is the canonical relative path + * from the worktree root; used as a stable id so VSCode persists + * folder-expansion state across overview-poll refreshes. + */ +export interface FilePathNode { + name: string; + fullPath: string; + file?: BuilderFileChange; + children?: FilePathNode[]; +} + +/** + * Build a folder-tree representation of the given files, then apply + * single-child-folder compaction. Returns the top-level nodes + * (folders or root-level files), sorted folders-first / alphabetical + * within each group. + */ +export function buildFilePathTree(files: BuilderFileChange[]): FilePathNode[] { + // Walk each file's path into a mutable nested structure. Folder nodes + // get an internal `_kids` map keyed by segment name so we can merge + // siblings as we go; the final shape drops it in favour of + // `children: FilePathNode[]`. + interface Mutable { + name: string; + fullPath: string; + file?: BuilderFileChange; + _kids?: Map; + } + + const root: Mutable = { name: '', fullPath: '', _kids: new Map() }; + for (const f of files) { + const segments = f.plan.resourcePath.split('/').filter(s => s.length > 0); + let cursor = root; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const isLeaf = i === segments.length - 1; + const parentPath = cursor.fullPath; + const childPath = parentPath ? `${parentPath}/${seg}` : seg; + if (!cursor._kids) { cursor._kids = new Map(); } + let next = cursor._kids.get(seg); + if (!next) { + next = { name: seg, fullPath: childPath }; + cursor._kids.set(seg, next); + } + if (isLeaf) { + next.file = f; + } + cursor = next; + } + } + + // Convert the mutable structure to FilePathNode, sort, and compact. + const convert = (m: Mutable): FilePathNode => { + const kids = m._kids ? [...m._kids.values()].map(convert) : undefined; + return { + name: m.name, + fullPath: m.fullPath, + ...(m.file ? { file: m.file } : {}), + ...(kids && kids.length > 0 ? { children: kids } : {}), + }; + }; + const compactAndSort = (node: FilePathNode): FilePathNode => { + if (!node.children) { return node; } + // Single-child compaction: collapse only when the lone child is + // itself a folder. A folder with a single *file* child stays as + // two rows — that matches VSCode SCM (the file is the meaningful + // leaf and shouldn't merge into its parent). + let compacted: FilePathNode = node; + while ( + compacted.children && + compacted.children.length === 1 && + compacted.children[0]!.children !== undefined + ) { + const only = compacted.children[0]!; + compacted = { + name: `${compacted.name}/${only.name}`, + fullPath: only.fullPath, + children: only.children, + }; + } + // Recurse, then sort: folders before files, alphabetical within. + compacted = { + ...compacted, + children: compacted.children!.map(compactAndSort).sort(compareNodes), + }; + return compacted; + }; + + const topLevel = root._kids + ? [...root._kids.values()].map(convert).map(compactAndSort) + : []; + return topLevel.sort(compareNodes); +} + +/** Folders before files; alphabetical (case-insensitive) within each group. */ +function compareNodes(a: FilePathNode, b: FilePathNode): number { + const aIsFolder = a.children !== undefined; + const bIsFolder = b.children !== undefined; + if (aIsFolder !== bIsFolder) { return aIsFolder ? -1 : 1; } + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); +} diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index 2622966d..839c8f20 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -4,6 +4,7 @@ import type { ConnectionManager } from '../connection-manager.js'; import type { TerminalManager } from '../terminal-manager.js'; import { getTowerAddress } from '../workspace-detector.js'; import { resolveWorkspaceDevTarget } from '../commands/dev-shared.js'; +import { loadWorktreeConfig } from '../load-worktree-config.js'; /** * Workspace-level entry points: architect terminal, Tower web dashboard, @@ -23,13 +24,33 @@ export class WorkspaceProvider implements vscode.TreeDataProvider this.changeEmitter.fire()); + // Tower fans out a `worktree-config-updated` SSE event whenever + // .codev/config(.local).json changes (server-side file watcher in + // worktree-config-watcher.ts). We re-render on that signal so the + // config-driven rows (Open Dev URL …) reflect edits live, without + // the extension ever needing to read or watch the file itself. + // + // Tower emits events as a JSON envelope on the SSE `data:` field + // with no `event:` name (see builder-spawn-handler.ts for the same + // gotcha), so the SSE-client-level `type` is always '' and the + // real type sits inside the envelope. + connectionManager.onSSEEvent(({ data }) => { + try { + const envelope = JSON.parse(data) as { type?: unknown }; + if (envelope.type === 'worktree-config-updated') { + this.changeEmitter.fire(); + } + } catch { + // benign — malformed envelope + } + }); } getTreeItem(element: vscode.TreeItem): vscode.TreeItem { return element; } - getChildren(): vscode.TreeItem[] { + async getChildren(): Promise { const items: vscode.TreeItem[] = []; const architect = new vscode.TreeItem('Open Architect'); @@ -82,14 +103,34 @@ export class WorkspaceProvider implements vscode.TreeDataProvider d.builderId === devTarget.id); + // Resolved worktree config (Tower-merged across all 5 layers). One + // fetch drives both the dev-server row's visibility (gated on + // devCommand presence) and the Open Dev URL row(s) below. + const worktreeConfig = await loadWorktreeConfig(this.connectionManager); + const devCommand = worktreeConfig?.devCommand ?? null; + const devUrls = worktreeConfig?.devUrls ?? []; - if (targetDevRunning) { + // Mutually exclusive: show Start when no dev is running anywhere, Stop + // when one is — regardless of which target started it. The single-slot + // model in dev-shared.ts means listDevTerminals() has at most one entry; + // reflect the slot's occupancy here so a dev started from a builder's + // right-click context menu is visible/stoppable from the Workspace view + // too. The visible control is itself the state indicator (play/stop + // model) — never both, no row-count jitter. + // + // Visibility also depends on whether devCommand is configured: + // - dev running → always show Stop (lets the user kill a dev they + // started before nulling out devCommand) + // - no dev running + devCommand set → show Start + // - no dev running + devCommand null → show nothing + // The third case is the new one — it removes the "click Start, get a + // toast saying devCommand isn't configured" footgun. + const allDevs = this.terminalManager.listDevTerminals(); + const targetDev = devTarget ? allDevs.find(d => d.builderId === devTarget.id) : undefined; + const otherDev = !targetDev ? allDevs[0] : undefined; // single-slot ⇒ at most one + + if (targetDev) { + // This workspace's own dev is the running one. Today's Stop row. const stopDev = new vscode.TreeItem('Stop Dev Server'); stopDev.iconPath = new vscode.ThemeIcon('debug-stop'); stopDev.tooltip = `Stop the dev server for this workspace (target: ${devTarget!.id})`; @@ -99,12 +140,28 @@ export class WorkspaceProvider implements vscode.TreeDataProvider