Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions packages/extension/src/sidebar_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as vscode from "vscode";
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage, type FileOpRequest, type FileOpResult, type TreeEntry } from "./sidebar_bridge";
import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage, type FileOpRequest, type FileOpResult, type TreeEntry, type TreeRoot } from "./sidebar_bridge";
import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service";
import { ChatPanel } from "./chat_panel";
import { detectProjectType } from "./project/detect";
Expand Down Expand Up @@ -293,6 +293,9 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
private fsPendingProjectTypeChange = false;
private workspaceSub?: vscode.Disposable;
private gitSubs: vscode.Disposable[] = [];
/** Watchers for resolved (non-workspace) environment directories (#903). */
private resolvedEnvWatchers: vscode.FileSystemWatcher[] = [];
private resolvedEnvDebounceTimer?: ReturnType<typeof setTimeout>;
private treeService: SidebarTreeService;
private globalState?: { get(key: string, fallback?: unknown): unknown; update(key: string, value: unknown): Thenable<void> };

Expand Down Expand Up @@ -363,6 +366,8 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
// Schedule a git-status push so colors survive the DOM wipe
// that renderRoots() causes in the webview.
queueMicrotask(() => this.pushGitStatus());
// Refresh watchers for resolved (non-workspace) environments (#903)
this.refreshResolvedEnvWatchers(roots);
return roots;
},
getChildren: async (p) => {
Expand Down Expand Up @@ -405,15 +410,20 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
// Refresh when workspace folders change.
this.workspaceSub = vscode.workspace.onDidChangeWorkspaceFolders(() => {
invalidateEnvironmentCache();
this.postDown({ kind: "roots", roots: this.treeService.getRoots() });
const roots = this.treeService.getRoots();
this.postDown({ kind: "roots", roots });
this.refreshResolvedEnvWatchers(roots);
this.pushGitStatus();
});

webviewView.onDidDispose(() => {
clearTimeout(this.fsDebounceTimer);
clearTimeout(this.resolvedEnvDebounceTimer);
this.fsPendingFolders.clear();
this.fsPendingProjectTypeChange = false;
this.watcher?.dispose();
for (const w of this.resolvedEnvWatchers) w.dispose();
this.resolvedEnvWatchers = [];
this.workspaceSub?.dispose();
for (const sub of this.gitSubs) sub.dispose();
this.gitSubs = [];
Expand Down Expand Up @@ -511,7 +521,9 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
// webview's fs-changed handler already performs.
if (this.fsPendingProjectTypeChange) {
invalidateEnvironmentCache();
this.postDown({ kind: "roots", roots: this.treeService.getRoots() });
const roots = this.treeService.getRoots();
this.postDown({ kind: "roots", roots });
this.refreshResolvedEnvWatchers(roots);
queueMicrotask(() => this.pushGitStatus());
this.fsPendingProjectTypeChange = false;
}
Expand Down Expand Up @@ -588,6 +600,63 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
}
}

/**
* Create file watchers for resolved (non-workspace) environment directories
* so that filesystem changes inside them produce fs-changed messages and
* invalidate the webview's children cache (#903).
*
* The main setupWatcher only covers workspace folders (createFileSystemWatcher
* with a bare glob is workspace-scoped, and the onFsEvent handler explicitly
* checks getWorkspaceFolder). Resolved environments are auto-surfaced from
* the registry and are NOT workspace folders, so changes inside them are
* invisible without these per-path watchers.
*
* Uses RelativePattern(Uri.file(envPath), "**\/*") which works for non-
* workspace paths on macOS (FSEvents) and Windows. On Linux the watcher is
* best-effort per the VS Code API contract.
*/
private refreshResolvedEnvWatchers(roots: TreeRoot[]): void {
const newPaths = new Set(
roots.filter((r) => r.source === "resolved").map((r) => r.path),
);

// Fast path: no change in the set of resolved paths → nothing to do.
const currentPaths = new Set(this.resolvedEnvWatchers.map((w) => (w as any).__envPath as string));
if (newPaths.size === currentPaths.size && [...newPaths].every((p) => currentPaths.has(p))) {
return;
}

// Dispose old watchers
for (const w of this.resolvedEnvWatchers) w.dispose();
this.resolvedEnvWatchers = [];

for (const envPath of newPaths) {
try {
const pattern = new vscode.RelativePattern(vscode.Uri.file(envPath), "**/*");
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
// Tag the watcher so the fast-path check above can compare sets
(watcher as any).__envPath = envPath;

const handler = () => {
// Debounce to avoid flooding, same as the main watcher
clearTimeout(this.resolvedEnvDebounceTimer);
this.resolvedEnvDebounceTimer = setTimeout(() => {
this.postDown({ kind: "fs-changed", folder: envPath });
}, 300);
};

watcher.onDidCreate(handler);
watcher.onDidChange(handler);
watcher.onDidDelete(handler);
this.resolvedEnvWatchers.push(watcher);
} catch {
// Watcher creation failed (e.g., OS-level watch limits) — graceful
// degradation: Fix 1 (no-cache-empty) still allows re-requests on
// expand toggles and webview recreation.
}
}
}

private buildHtml(
webview: vscode.Webview,
nonce: string,
Expand Down Expand Up @@ -1056,7 +1125,10 @@ async function readDirectoryEntries(dir: string): Promise<RawDirEntry[]> {
name,
type: type === vscode.FileType.Directory ? "directory" as const : "file" as const,
}));
} catch {
} catch (err) {
// Log so transient failures are visible — a swallowed empty [] here is
// the entry point for the cache-poison bug (#903).
console.warn(`[sidebar] readDirectoryEntries failed for ${dir}:`, err);
return [];
}
}
Expand Down
14 changes: 12 additions & 2 deletions packages/extension/src/sidebar_webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1895,7 +1895,17 @@ function createIconEl(icon: string): HTMLElement {
}

case "children": {
childrenCache[msg.path] = msg.entries ?? [];
// Only cache non-empty results. An empty [] from a transient
// readDirectory failure is truthy and would permanently suppress
// re-requests (every guard uses !childrenCache[path], and ![] is
// false). Leaving the key absent (undefined) lets future renders
// and expand toggles re-request children. (#903)
const childEntries = msg.entries ?? [];
if (childEntries.length > 0) {
childrenCache[msg.path] = childEntries;
} else {
delete childrenCache[msg.path];
}
// Find the container for this path and render children
const container = treeRoot?.querySelector(`[data-path="${CSS.escape(msg.path)}"] > .children`);
if (container) {
Expand All @@ -1904,7 +1914,7 @@ function createIconEl(icon: string): HTMLElement {
// focused input, firing blur synchronously — suppress cancel.
const hasInlineEdit = activeInlineEdit?.tempRow && activeInlineEdit.path === msg.path;
if (hasInlineEdit) inlineEditRerendering = true;
renderChildren(container as HTMLElement, msg.entries ?? [], depth);
renderChildren(container as HTMLElement, childEntries, depth);
inlineEditRerendering = false;
// Re-insert the inline edit temp row after children are rendered
if (hasInlineEdit && activeInlineEdit?.tempRow) {
Expand Down
Loading