fix(web): restore persisted file tree expansions (#1892)

This commit is contained in:
Carlos Florêncio
2026-07-23 15:18:19 +01:00
committed by GitHub
parent 5167bb9832
commit 1c70495689
5 changed files with 808 additions and 175 deletions
+49 -175
View File
@@ -9,25 +9,20 @@ import { getFilesPanelExpandedPaths, setFilesPanelExpandedPaths } from "@/lib/lo
import { useTree, type VisibleRow } from "@/hooks/use-tree";
import { mergeTreeNodes } from "./file-browser-parts";
import { compareTreeNodes, sortRootChildren } from "./file-tree-utils";
import { restoredExpandedPaths } from "./file-browser-restore";
import { useTreeLoader } from "./file-browser-tree-loader";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
const debugLoad = createDebugLogger("file-browser:load");
const debugChanges = createDebugLogger("file-browser:changes");
const FB_GET_PATH = (n: FileTreeNode) => n.path;
// Children are sorted (dirs first, then files, alphabetically) on every
// access so the flat visibleRows list comes out in the same order the
// recursive render produced. The backend doesn't guarantee order and tree
// mutations (rename / create) can insert arbitrarily.
const FB_GET_CHILDREN = (n: FileTreeNode) =>
n.children ? [...n.children].sort(compareTreeNodes) : undefined;
const FB_IS_DIR = (n: FileTreeNode) => n.is_dir;
export type FileBrowserRow = VisibleRow<FileTreeNode>;
const MAX_RETRY_ATTEMPTS = 4;
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
export type LoadState = "loading" | "waiting" | "loaded" | "manual" | "error";
/** Hook encapsulating file search state and handlers. */
@@ -39,14 +34,12 @@ export function useFileBrowserSearch(sessionId: string) {
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Focus search input when search opens
useEffect(() => {
if (isSearchActive && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isSearchActive]);
// Clear search when closing
useEffect(() => {
if (!isSearchActive) {
setLocalSearchQuery("");
@@ -56,7 +49,6 @@ export function useFileBrowserSearch(sessionId: string) {
}
}, [isSearchActive]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
@@ -123,7 +115,6 @@ export function applyFileChanges(ctx: {
const { client, sessionId, expandedPaths, changes, setTree, setLoadState } = ctx;
const foldersToRefresh = new Set<string>();
for (const change of changes) {
// `refresh` events have empty path; refresh root + every expanded folder under the affected repo so new files show up.
if (change.operation === "refresh") {
foldersToRefresh.add("");
const repo = change.repository_name;
@@ -191,7 +182,6 @@ export function applyFileChanges(ctx: {
}
return node.children ? { ...node, children: node.children.map(patchNode) } : node;
};
// Skip root: already merged above; re-matching folderUpdates.has("") here would overwrite preserved subtrees.
return { ...updated, children: updated.children?.map(patchNode) };
});
setLoadState("loaded");
@@ -234,109 +224,10 @@ function useLoadingTimers() {
return { visibleLoadingPaths, showLoading, hideLoading, isLoading };
}
type TreeLoaderContext = {
sessionId: string;
clearRetryTimer: () => void;
retryAttemptRef: React.MutableRefObject<number>;
retryTimerRef: React.MutableRefObject<NodeJS.Timeout | null>;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
setIsLoadingTree: React.Dispatch<React.SetStateAction<boolean>>;
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
setLoadError: React.Dispatch<React.SetStateAction<string | null>>;
};
// Thin wrapper so loadTree callers don't each pay a complexity point for isDebug().
function logLoad(event: string, data: Record<string, unknown>) {
if (isDebug()) debugLoad(event, data);
}
function useTreeLoader(ctx: TreeLoaderContext) {
const {
clearRetryTimer,
retryAttemptRef,
retryTimerRef,
setTree,
setIsLoadingTree,
setLoadState,
setLoadError,
} = ctx;
// Use ref for sessionId so loadTree stays stable across session switches.
// This prevents the tree-clearing effect from re-firing when only sessionId changes.
const sessionIdRef = useRef(ctx.sessionId);
sessionIdRef.current = ctx.sessionId;
const loadInFlightRef = useRef(false);
const loadTree = useCallback(
async (options?: { resetRetry?: boolean }) => {
if (loadInFlightRef.current) {
logLoad("skip-in-flight", { sessionId: sessionIdRef.current });
return;
}
loadInFlightRef.current = true;
setIsLoadingTree(true);
setLoadState("loading");
setLoadError(null);
if (options?.resetRetry) {
retryAttemptRef.current = 0;
clearRetryTimer();
}
logLoad("start", {
sessionId: sessionIdRef.current,
resetRetry: options?.resetRetry === true,
retryAttempt: retryAttemptRef.current,
});
try {
const client = getWebSocketClient();
if (!client) throw new Error("WebSocket client not available");
const response = await requestFileTree(client, sessionIdRef.current, "", 1);
setTree(response.root ?? null);
setLoadState("loaded");
retryAttemptRef.current = 0;
clearRetryTimer();
logLoad("loaded", {
sessionId: sessionIdRef.current,
rootPath: response.root?.path ?? null,
children: response.root?.children?.length ?? 0,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load file tree";
setLoadError(message);
if (retryAttemptRef.current < MAX_RETRY_ATTEMPTS) {
const delay =
RETRY_DELAYS_MS[Math.min(retryAttemptRef.current, RETRY_DELAYS_MS.length - 1)];
retryAttemptRef.current += 1;
setLoadState("waiting");
clearRetryTimer();
logLoad("retry", {
sessionId: sessionIdRef.current,
attempt: retryAttemptRef.current,
delayMs: delay,
error: message,
});
retryTimerRef.current = setTimeout(() => {
void loadTree();
}, delay);
} else {
setLoadState("manual");
logLoad("gave-up", { sessionId: sessionIdRef.current, error: message });
}
} finally {
setIsLoadingTree(false);
loadInFlightRef.current = false;
}
},
[
clearRetryTimer,
retryAttemptRef,
retryTimerRef,
setIsLoadingTree,
setLoadError,
setLoadState,
setTree,
],
);
return loadTree;
}
type TreeLoadEffectsContext = {
sessionId: string;
effectiveResetKey: string;
@@ -346,16 +237,21 @@ type TreeLoadEffectsContext = {
treeRef: React.MutableRefObject<FileTreeNode | null>;
retryAttemptRef: React.MutableRefObject<number>;
hasInitializedExpandedRef: React.MutableRefObject<string | null>;
restoreExpandedPathsRef: React.MutableRefObject<string[]>;
expandedPathsRef: React.MutableRefObject<ReadonlySet<string>>;
clearRetryTimer: () => void;
loadTree: (options?: { resetRetry?: boolean }) => Promise<void> | void;
loadTree: (options?: {
resetRetry?: boolean;
restoreExpandedPaths?: string[];
}) => Promise<void> | void;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
setIsLoadingTree: React.Dispatch<React.SetStateAction<boolean>>;
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
setLoadError: React.Dispatch<React.SetStateAction<string | null>>;
setExpandedPaths: (paths: Set<string>) => void;
setExpandedPaths: React.Dispatch<React.SetStateAction<Set<string>>>;
lastResetKeyRef: React.MutableRefObject<string | null>;
};
/** Owns the two effects that drive initial load and the waiting→ready flip. */
function useTreeLoadEffects(ctx: TreeLoadEffectsContext) {
const {
sessionId,
@@ -366,6 +262,8 @@ function useTreeLoadEffects(ctx: TreeLoadEffectsContext) {
treeRef,
retryAttemptRef,
hasInitializedExpandedRef,
restoreExpandedPathsRef,
expandedPathsRef,
clearRetryTimer,
loadTree,
setTree,
@@ -373,19 +271,28 @@ function useTreeLoadEffects(ctx: TreeLoadEffectsContext) {
setLoadState,
setLoadError,
setExpandedPaths,
lastResetKeyRef,
} = ctx;
useEffect(() => {
setTree(null);
setIsLoadingTree(true);
setLoadState(agentctlIsReadyRef.current ? "loading" : "waiting");
setLoadError(null);
retryAttemptRef.current = 0;
const resetKeyChanged = lastResetKeyRef.current !== effectiveResetKey;
lastResetKeyRef.current = effectiveResetKey;
clearRetryTimer();
hasInitializedExpandedRef.current = null;
const savedPaths = getFilesPanelExpandedPaths(effectiveResetKey);
setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set());
if (savedPaths.length > 0) hasInitializedExpandedRef.current = effectiveResetKey;
retryAttemptRef.current = 0;
if (resetKeyChanged) {
setTree(null);
setIsLoadingTree(true);
setLoadState(agentctlIsReadyRef.current ? "loading" : "waiting");
setLoadError(null);
hasInitializedExpandedRef.current = null;
const savedPaths = restoredExpandedPaths(getFilesPanelExpandedPaths(effectiveResetKey));
restoreExpandedPathsRef.current = savedPaths;
setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set());
}
const savedPaths = resetKeyChanged
? restoreExpandedPathsRef.current
: Array.from(expandedPathsRef.current);
restoreExpandedPathsRef.current = savedPaths;
logLoad("init-effect", {
sessionId,
effectiveResetKey,
@@ -393,20 +300,15 @@ function useTreeLoadEffects(ctx: TreeLoadEffectsContext) {
savedPaths: savedPaths.length,
willLoad: agentctlIsReadyRef.current,
});
if (agentctlIsReadyRef.current) void loadTree({ resetRetry: true });
else setIsLoadingTree(false);
if (agentctlIsReadyRef.current) {
void loadTree({ resetRetry: true, restoreExpandedPaths: savedPaths });
} else setIsLoadingTree(false);
return () => {
clearRetryTimer();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs intentionally omitted
}, [clearRetryTimer, loadTree, effectiveResetKey, setExpandedPaths]);
}, [clearRetryTimer, loadTree, effectiveResetKey, sessionId, setExpandedPaths]);
// Fire the initial load on the waiting → ready transition. `loadState` is
// read via a ref so a failed load (which sets state back to "waiting") does
// not re-fire this effect and cancel the retry timer via `resetRetry: true`.
// The `treeRef.current` check alongside "loaded" is load-bearing:
// `applyFileChanges` can flip state to "loaded" while the tree is still
// null, and without the tree check this effect would skip the real load.
useEffect(() => {
let reason: string | null = null;
if (!agentctlIsReady) reason = "agentctl-not-ready";
@@ -417,7 +319,7 @@ function useTreeLoadEffects(ctx: TreeLoadEffectsContext) {
return;
}
logLoad("ready-flip", { sessionId, loadState: loadStateRef.current });
void loadTree({ resetRetry: true });
void loadTree({ resetRetry: true, restoreExpandedPaths: restoreExpandedPathsRef.current });
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs intentionally omitted
}, [agentctlIsReady, loadTree, sessionId]);
}
@@ -433,9 +335,6 @@ function useFileChangeSubscription({
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
}) {
// expandedPaths is read via ref so that toggling a directory (which mints a
// new Set) does not tear down and re-attach this WS listener — any event
// arriving during the swap would otherwise be silently dropped.
useEffect(() => {
const client = getWebSocketClient();
if (!client) return;
@@ -464,22 +363,11 @@ function useFileChangeSubscription({
}, [sessionIdRef, expandedPathsRef, setTree, setLoadState]);
}
/**
* Hook for tree loading with retry logic, file-change subscription, and expanded state.
* @param sessionId - Session ID for API calls (agentctl routing).
* @param resetKey - Optional stable key (e.g. environmentId) that controls when the tree
* does a full reset. When sessions share the same environment, this prevents the tree
* from flashing on tab switch.
*/
export function useFileBrowserTree(sessionId: string, resetKey?: string) {
const effectiveResetKey = resetKey ?? sessionId;
const [tree, setTree] = useState<FileTreeNode | null>(null);
// Expansion + flat visibleRows come from the shared useTree hook. tree is
// a single root node with children; useTree wants an array, so feed it the
// root's children (pre-sorted — see sortRootChildren).
const treeNodes = useMemo(() => sortRootChildren(tree), [tree]);
const treeApi = useTree<FileTreeNode>({
nodes: treeNodes,
nodes: useMemo(() => sortRootChildren(tree), [tree]),
getPath: FB_GET_PATH,
getChildren: FB_GET_CHILDREN,
isDir: FB_IS_DIR,
@@ -487,41 +375,40 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
const expandedPaths = treeApi.expanded;
const setExpandedPaths = treeApi.setExpanded;
const visibleRows = treeApi.visibleRows;
// Stable ref over `expandedPaths` so the WS file-change subscription does
// not re-attach on every toggle (each toggle mints a new Set reference).
const expandedPathsRef = useRef<ReadonlySet<string>>(expandedPaths);
expandedPathsRef.current = expandedPaths;
const [isLoadingTree, setIsLoadingTree] = useState(true);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [loadError, setLoadError] = useState<string | null>(null);
const hasInitializedExpandedRef = useRef<string | null>(null);
const restoreExpandedPathsRef = useRef<string[]>([]);
const lastResetKeyRef = useRef<string | null>(null);
const retryAttemptRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const agentctlStatus = useSessionAgentctl(sessionId);
const { visibleLoadingPaths, showLoading, hideLoading, isLoading } = useLoadingTimers();
const clearRetryTimer = useCallback(() => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
}, []);
const loadTree = useTreeLoader({
sessionId,
effectiveResetKey,
clearRetryTimer,
retryAttemptRef,
retryTimerRef,
restoreExpandedPathsRef,
hasInitializedExpandedRef,
setTree,
setExpandedPaths,
setIsLoadingTree,
setLoadState,
setLoadError,
});
// Refs for effects below to read without adding deps — avoids infinite
// retry loops and wipe-on-reconnect when `isReady`/`loadState`/`tree` tick.
const agentctlIsReadyRef = useRef(agentctlStatus.isReady);
const loadStateRef = useRef(loadState);
const treeRef = useRef(tree);
@@ -537,6 +424,8 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
treeRef,
retryAttemptRef,
hasInitializedExpandedRef,
restoreExpandedPathsRef,
expandedPathsRef,
clearRetryTimer,
loadTree,
setTree,
@@ -544,25 +433,13 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
setLoadState,
setLoadError,
setExpandedPaths,
lastResetKeyRef,
});
useEffect(() => {
if (!tree || isLoadingTree || hasInitializedExpandedRef.current === effectiveResetKey) return;
hasInitializedExpandedRef.current = effectiveResetKey;
setExpandedPaths(new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tree?.children?.length, isLoadingTree, effectiveResetKey]);
useEffect(() => {
if (expandedPaths.size > 0 || hasInitializedExpandedRef.current === effectiveResetKey) {
setFilesPanelExpandedPaths(effectiveResetKey, Array.from(expandedPaths));
}
}, [expandedPaths, effectiveResetKey]);
if (isLoadingTree || hasInitializedExpandedRef.current !== effectiveResetKey) return;
setFilesPanelExpandedPaths(effectiveResetKey, Array.from(expandedPaths));
}, [expandedPaths, effectiveResetKey, isLoadingTree]);
useFileChangeSubscription({ sessionIdRef, expandedPathsRef, setTree, setLoadState });
const collapseAll = treeApi.collapseAll;
return {
tree,
setTree,
@@ -577,13 +454,10 @@ export function useFileBrowserTree(sessionId: string, resetKey?: string) {
showLoading,
hideLoading,
isLoading,
collapseAll,
collapseAll: treeApi.collapseAll,
};
}
// useScrollPersistence, loadNodeChildren, toggleFolderExpand, fetchAndOpenFile,
// and the ToggleFolderExpandDeps type live in ./file-browser-actions.ts to keep
// this file within the 600-line lint limit.
export {
useScrollPersistence,
loadNodeChildren,
@@ -0,0 +1,129 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const requestFileTreeMock = vi.fn();
const getWebSocketClientMock = vi.fn(() => ({ on: vi.fn(() => () => {}) }));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => getWebSocketClientMock(),
}));
vi.mock("@/lib/ws/workspace-files", () => ({
requestFileTree: (...args: unknown[]) => requestFileTreeMock(...args),
requestFileContent: vi.fn(),
searchWorkspaceFiles: vi.fn(),
}));
vi.mock("@/hooks/domains/session/use-session-agentctl", () => ({
useSessionAgentctl: () => ({ isReady: true }),
}));
import { useFileBrowserTree } from "./file-browser-hooks";
const ROOT_PATH = "";
const CODEX_PATH = ".codex";
const AGENTS_PATH = `${CODEX_PATH}/agents`;
const CONFIG_PATH = `${AGENTS_PATH}/config.toml`;
const EXPANDED_PATHS = [CODEX_PATH, AGENTS_PATH];
const ROOT = { name: "", path: ROOT_PATH, is_dir: true, size: 0 };
const CODEX = { name: CODEX_PATH, path: CODEX_PATH, is_dir: true, size: 0 };
const AGENTS = { name: "agents", path: AGENTS_PATH, is_dir: true, size: 0 };
const CONFIG = { name: "config.toml", path: CONFIG_PATH, is_dir: false, size: 1 };
const SESSION = "session-1";
const ENVIRONMENT = "environment-1";
const STORAGE_KEY = `kandev.filesPanel.expanded.${ENVIRONMENT}`;
describe("useFileBrowserTree persisted expansion", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(EXPANDED_PATHS));
requestFileTreeMock.mockImplementation((_client: unknown, _sessionId: string, path: string) => {
if (path === ROOT_PATH) return Promise.resolve({ root: { ...ROOT, children: [CODEX] } });
if (path === CODEX_PATH) return Promise.resolve({ root: { ...CODEX, children: [AGENTS] } });
if (path === AGENTS_PATH) {
return Promise.resolve({ root: { ...AGENTS, children: [CONFIG] } });
}
throw new Error(`Unexpected path: ${path}`);
});
});
afterEach(() => vi.useRealTimers());
it("hydrates every persisted expanded ancestor before marking the tree loaded", async () => {
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(requestFileTreeMock.mock.calls.map((call) => call[2])).toEqual([
ROOT_PATH,
...EXPANDED_PATHS,
]);
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
expect(result.current.visibleRows.map((row) => row.path)).toEqual([
CODEX_PATH,
AGENTS_PATH,
CONFIG_PATH,
]);
});
it.each([
["deep-only", [AGENTS_PATH]],
["deep-first", [AGENTS_PATH, CODEX_PATH]],
])("synthesizes ancestor expansion for %s saved paths", async (_label, savedPaths) => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(savedPaths));
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(requestFileTreeMock.mock.calls.map((call) => call[2])).toEqual([
ROOT_PATH,
...EXPANDED_PATHS,
]);
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
});
it("retries a transient restored-folder failure without pruning persisted expansion", async () => {
vi.useFakeTimers();
let codexAttempts = 0;
requestFileTreeMock.mockImplementation((_client: unknown, _sessionId: string, path: string) => {
if (path === ROOT_PATH) return Promise.resolve({ root: { ...ROOT, children: [CODEX] } });
if (path === CODEX_PATH && codexAttempts++ === 0) {
return Promise.reject(new Error("folder unavailable"));
}
if (path === CODEX_PATH) return Promise.resolve({ root: { ...CODEX, children: [AGENTS] } });
if (path === AGENTS_PATH) return Promise.resolve({ root: { ...AGENTS, children: [CONFIG] } });
throw new Error(`Unexpected path: ${path}`);
});
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
await act(async () => Promise.resolve());
expect(result.current.loadState).toBe("waiting");
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify(EXPANDED_PATHS));
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(result.current.loadState).toBe("loaded");
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
expect(result.current.visibleRows.map((row) => row.path)).toEqual([
CODEX_PATH,
AGENTS_PATH,
CONFIG_PATH,
]);
});
it("prunes an expanded folder and descendants when its restore response is null", async () => {
requestFileTreeMock.mockImplementation((_client: unknown, _sessionId: string, path: string) => {
if (path === ROOT_PATH) return Promise.resolve({ root: { ...ROOT, children: [CODEX] } });
if (path === CODEX_PATH) return Promise.resolve({ root: null });
throw new Error(`Unexpected path: ${path}`);
});
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(result.current.expandedPaths).toEqual(new Set());
expect(result.current.visibleRows.map((row) => row.path)).toEqual([CODEX_PATH]);
expect(sessionStorage.getItem(STORAGE_KEY)).toBe("[]");
});
});
@@ -0,0 +1,342 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const requestFileTreeMock = vi.fn();
const getWebSocketClientMock = vi.fn(() => ({ on: vi.fn(() => () => {}) }));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => getWebSocketClientMock(),
}));
vi.mock("@/lib/ws/workspace-files", () => ({
requestFileTree: (...args: unknown[]) => requestFileTreeMock(...args),
requestFileContent: vi.fn(),
searchWorkspaceFiles: vi.fn(),
}));
vi.mock("@/hooks/domains/session/use-session-agentctl", () => ({
useSessionAgentctl: () => ({ isReady: true }),
}));
import { useFileBrowserTree } from "./file-browser-hooks";
const SESSION = "session-1";
const SUCCESSOR_SESSION = "session-2";
const ENVIRONMENT = "environment-1";
const SUCCESSOR_ENVIRONMENT = "environment-2";
const ROOT_PATH = "";
const CODEX_PATH = ".codex";
const AGENTS_PATH = ".codex/agents";
const CONFIG_PATH = ".codex/agents/config.toml";
const EXPANDED_PATHS = [CODEX_PATH, AGENTS_PATH];
const STORAGE_KEY = `kandev.filesPanel.expanded.${ENVIRONMENT}`;
const SUCCESSOR_STORAGE_KEY = `kandev.filesPanel.expanded.${SUCCESSOR_ENVIRONMENT}`;
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
const ROOT = { name: "", path: ROOT_PATH, is_dir: true, size: 0 };
const CODEX = { name: ".codex", path: CODEX_PATH, is_dir: true, size: 0 };
const AGENTS = { name: "agents", path: AGENTS_PATH, is_dir: true, size: 0 };
const CONFIG = { name: "config.toml", path: CONFIG_PATH, is_dir: false, size: 1 };
const NEW_CONFIG = { name: "config.toml", path: ".new/config.toml", is_dir: false, size: 1 };
function deferred<T>() {
let resolve!: (value: T) => void;
return { promise: new Promise<T>((done) => (resolve = done)), resolve };
}
function mockHydratedTree() {
requestFileTreeMock.mockImplementation((_client: unknown, _sessionId: string, path: string) => {
if (path === ROOT_PATH) return Promise.resolve({ root: { ...ROOT, children: [CODEX] } });
if (path === CODEX_PATH) return Promise.resolve({ root: { ...CODEX, children: [AGENTS] } });
if (path === AGENTS_PATH) return Promise.resolve({ root: { ...AGENTS, children: [CONFIG] } });
throw new Error(`Unexpected path: ${path}`);
});
}
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(EXPANDED_PATHS));
mockHydratedTree();
});
afterEach(() => vi.useRealTimers());
it("starts a successor load after a session switch and ignores the stale predecessor", async () => {
const firstRoot = deferred<{ root: typeof ROOT & { children: (typeof CODEX)[] } }>();
requestFileTreeMock.mockImplementation((_client: unknown, sessionId: string, path: string) => {
if (sessionId === SESSION && path === ROOT_PATH) return firstRoot.promise;
if (sessionId === SUCCESSOR_SESSION && path === ROOT_PATH) {
return Promise.resolve({
root: { ...ROOT, children: [{ ...CODEX, name: ".new", path: ".new" }] },
});
}
return Promise.resolve({ root: { ...CODEX, children: [] } });
});
const { result, rerender } = renderHook(
({ sessionId }) => useFileBrowserTree(sessionId, ENVIRONMENT),
{ initialProps: { sessionId: SESSION } },
);
rerender({ sessionId: SUCCESSOR_SESSION });
firstRoot.resolve({ root: { ...ROOT, children: [CODEX] } });
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(requestFileTreeMock.mock.calls.map((call) => [call[1], call[2]])).toContainEqual([
SUCCESSOR_SESSION,
ROOT_PATH,
]);
expect(result.current.visibleRows.map((row) => row.path)).toEqual([".new"]);
});
it("replaces a completed session tree with the successor's root and restored expansion", async () => {
requestFileTreeMock.mockImplementation((_client: unknown, sessionId: string, path: string) => {
if (sessionId === SESSION && path === ROOT_PATH) {
return Promise.resolve({
root: { ...ROOT, children: [{ ...CODEX, name: ".old", path: ".old" }, CODEX] },
});
}
if (path === ROOT_PATH) return Promise.resolve({ root: { ...ROOT, children: [CODEX] } });
if (path === CODEX_PATH) return Promise.resolve({ root: { ...CODEX, children: [AGENTS] } });
if (path === AGENTS_PATH) return Promise.resolve({ root: { ...AGENTS, children: [CONFIG] } });
throw new Error(`Unexpected successor path: ${path}`);
});
const { result, rerender } = renderHook(
({ sessionId }) => useFileBrowserTree(sessionId, ENVIRONMENT),
{ initialProps: { sessionId: SESSION } },
);
await waitFor(() => expect(result.current.visibleRows.map((row) => row.path)).toContain(".old"));
rerender({ sessionId: SUCCESSOR_SESSION });
await waitFor(() =>
expect(result.current.visibleRows.map((row) => row.path)).toEqual([
CODEX_PATH,
AGENTS_PATH,
CONFIG_PATH,
]),
);
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify(EXPANDED_PATHS));
expect(result.current.visibleRows.map((row) => row.path)).toEqual([
CODEX_PATH,
AGENTS_PATH,
CONFIG_PATH,
]);
expect(requestFileTreeMock.mock.calls.map((call) => [call[1], call[2]])).toContainEqual([
SUCCESSOR_SESSION,
ROOT_PATH,
]);
expect(requestFileTreeMock.mock.calls.map((call) => [call[1], call[2]])).toContainEqual([
SUCCESSOR_SESSION,
AGENTS_PATH,
]);
});
it("hydrates folders expanded after restore when a same-key session is replaced", async () => {
requestFileTreeMock.mockImplementation((_client: unknown, _sessionId: string, path: string) => {
if (path === ROOT_PATH)
return Promise.resolve({
root: { ...ROOT, children: [CODEX, { ...CODEX, name: ".new", path: ".new" }] },
});
if (path === CODEX_PATH) return Promise.resolve({ root: { ...CODEX, children: [AGENTS] } });
if (path === AGENTS_PATH) return Promise.resolve({ root: { ...AGENTS, children: [CONFIG] } });
if (path === ".new")
return Promise.resolve({
root: { ...CODEX, name: ".new", path: ".new", children: [NEW_CONFIG] },
});
throw new Error(`Unexpected path: ${path}`);
});
const { result, rerender } = renderHook(
({ sessionId }) => useFileBrowserTree(sessionId, ENVIRONMENT),
{ initialProps: { sessionId: SESSION } },
);
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
act(() => result.current.setExpandedPaths((paths) => new Set(paths).add(".new")));
rerender({ sessionId: SUCCESSOR_SESSION });
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(requestFileTreeMock.mock.calls.map((call) => [call[1], call[2]])).toContainEqual([
SUCCESSOR_SESSION,
".new",
]);
expect(result.current.visibleRows.map((row) => row.path)).toContain(NEW_CONFIG.path);
});
it("invalidates an in-flight request when the reset key changes", async () => {
const firstRoot = deferred<{ root: typeof ROOT & { children: (typeof CODEX)[] } }>();
requestFileTreeMock.mockImplementationOnce(() => firstRoot.promise);
mockHydratedTree();
sessionStorage.setItem(`kandev.filesPanel.expanded.${SUCCESSOR_ENVIRONMENT}`, "[]");
const { result, rerender } = renderHook(({ resetKey }) => useFileBrowserTree(SESSION, resetKey), {
initialProps: { resetKey: ENVIRONMENT },
});
rerender({ resetKey: SUCCESSOR_ENVIRONMENT });
firstRoot.resolve({ root: { ...ROOT, children: [CODEX] } });
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(result.current.visibleRows.map((row) => row.path)).toEqual([CODEX_PATH]);
});
it("replaces a completed reset-key tree with the new key's root and saved expansion", async () => {
sessionStorage.setItem(SUCCESSOR_STORAGE_KEY, JSON.stringify([".new"]));
let rootRequests = 0;
requestFileTreeMock.mockImplementation((_client: unknown, _sessionId: string, path: string) => {
if (path === ROOT_PATH) {
rootRequests += 1;
return Promise.resolve(
rootRequests === 1
? { root: { ...ROOT, children: [CODEX] } }
: { root: { ...ROOT, children: [{ ...CODEX, name: ".new", path: ".new" }] } },
);
}
if (path === CODEX_PATH) return Promise.resolve({ root: { ...CODEX, children: [AGENTS] } });
if (path === AGENTS_PATH) return Promise.resolve({ root: { ...AGENTS, children: [CONFIG] } });
if (path === ".new")
return Promise.resolve({
root: { ...CODEX, name: ".new", path: ".new", children: [NEW_CONFIG] },
});
throw new Error(`Unexpected path: ${path}`);
});
const { result, rerender } = renderHook(({ resetKey }) => useFileBrowserTree(SESSION, resetKey), {
initialProps: { resetKey: ENVIRONMENT },
});
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
rerender({ resetKey: SUCCESSOR_ENVIRONMENT });
await waitFor(() => expect(rootRequests).toBe(2));
await waitFor(() =>
expect(requestFileTreeMock.mock.calls.map((call) => call[2])).toEqual([
ROOT_PATH,
CODEX_PATH,
AGENTS_PATH,
ROOT_PATH,
".new",
]),
);
await waitFor(() => expect(result.current.expandedPaths).toEqual(new Set([".new"])));
expect(sessionStorage.getItem(SUCCESSOR_STORAGE_KEY)).toBe(JSON.stringify([".new"]));
expect(result.current.visibleRows.map((row) => row.path)).toEqual([".new", NEW_CONFIG.path]);
});
it("does not persist the previous reset key's expansion under a new key", async () => {
const successorStorageKey = `kandev.filesPanel.expanded.${SUCCESSOR_ENVIRONMENT}`;
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
sessionStorage.setItem(successorStorageKey, JSON.stringify([".new"]));
const { result, rerender } = renderHook(({ resetKey }) => useFileBrowserTree(SESSION, resetKey), {
initialProps: { resetKey: ENVIRONMENT },
});
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
setItemSpy.mockClear();
rerender({ resetKey: SUCCESSOR_ENVIRONMENT });
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(setItemSpy).not.toHaveBeenCalledWith(successorStorageKey, JSON.stringify(EXPANDED_PATHS));
setItemSpy.mockRestore();
});
it("recovers from manual retry after all automatic root retries preserve saved expansion", async () => {
vi.useFakeTimers();
let rootAttempts = 0;
requestFileTreeMock.mockImplementation((_client: unknown, _sessionId: string, path: string) => {
if (path === ROOT_PATH && rootAttempts++ <= RETRY_DELAYS_MS.length) {
return Promise.reject(new Error("root unavailable"));
}
if (path === ROOT_PATH) return Promise.resolve({ root: { ...ROOT, children: [CODEX] } });
if (path === CODEX_PATH) return Promise.resolve({ root: { ...CODEX, children: [AGENTS] } });
if (path === AGENTS_PATH) return Promise.resolve({ root: { ...AGENTS, children: [CONFIG] } });
throw new Error(`Unexpected path: ${path}`);
});
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
for (const delay of RETRY_DELAYS_MS) {
await act(async () => vi.advanceTimersByTimeAsync(delay));
}
expect(result.current.loadState).toBe("manual");
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
await act(async () => result.current.loadTree({ resetRetry: true }));
expect(result.current.loadState).toBe("loaded");
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
expect(result.current.visibleRows.map((row) => row.path)).toEqual([
CODEX_PATH,
AGENTS_PATH,
CONFIG_PATH,
]);
expect(rootAttempts).toBe(RETRY_DELAYS_MS.length + 2);
});
it("deduplicates concurrent load requests for one generation", async () => {
const root = deferred<{ root: typeof ROOT & { children: (typeof CODEX)[] } }>();
mockHydratedTree();
requestFileTreeMock.mockImplementationOnce(() => root.promise);
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
const duplicate = result.current.loadTree();
expect(requestFileTreeMock).toHaveBeenCalledTimes(1);
root.resolve({ root: { ...ROOT, children: [CODEX] } });
await duplicate;
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
});
it("retains restored paths while an automatic root retry recovers", async () => {
vi.useFakeTimers();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(EXPANDED_PATHS));
requestFileTreeMock.mockRejectedValueOnce(new Error("root unavailable"));
mockHydratedTree();
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
await act(async () => Promise.resolve());
expect(result.current.loadState).toBe("waiting");
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(result.current.expandedPaths).toEqual(new Set(EXPANDED_PATHS));
expect(result.current.loadState).toBe("loaded");
});
it("does not let a scheduled predecessor retry request for a replacement owner", async () => {
vi.useFakeTimers();
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
requestFileTreeMock.mockRejectedValueOnce(new Error("root unavailable"));
mockHydratedTree();
const { result, rerender } = renderHook(
({ sessionId }) => useFileBrowserTree(sessionId, ENVIRONMENT),
{ initialProps: { sessionId: SESSION } },
);
await act(async () => Promise.resolve());
rerender({ sessionId: SUCCESSOR_SESSION });
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(
requestFileTreeMock.mock.calls.filter(
(call) => call[1] === SUCCESSOR_SESSION && call[2] === ROOT_PATH,
),
).toHaveLength(1);
expect(result.current.loadState).toBe("loaded");
clearTimeoutSpy.mockRestore();
});
it("prunes persisted folders when the restored root is null", async () => {
requestFileTreeMock.mockResolvedValue({ root: null });
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(result.current.expandedPaths).toEqual(new Set());
await waitFor(() => expect(sessionStorage.getItem(STORAGE_KEY)).toBe("[]"));
});
it("does not request children for empty persisted paths", async () => {
sessionStorage.setItem(STORAGE_KEY, "[]");
requestFileTreeMock.mockResolvedValue({ root: { ...ROOT, children: [CODEX] } });
const { result } = renderHook(() => useFileBrowserTree(SESSION, ENVIRONMENT));
await waitFor(() => expect(result.current.loadState).toBe("loaded"));
expect(requestFileTreeMock.mock.calls.map((call) => call[2])).toEqual([ROOT_PATH]);
expect(result.current.expandedPaths).toEqual(new Set());
});
@@ -0,0 +1,77 @@
import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileTree } from "@/lib/ws/workspace-files";
import type { FileTreeNode } from "@/lib/types/backend";
import { findNodeByPath, mergeTreeNodes } from "./file-tree-utils";
import type { TreeLoadOwner } from "./file-browser-tree-loader";
export type RestoredTree = {
root: FileTreeNode | null;
tree: FileTreeNode | null;
failedPaths: string[];
};
export function restoredExpandedPaths(paths: string[]): string[] {
const restored = new Set<string>();
for (const path of paths) {
if (!path) continue;
const parts = path.split("/");
for (let i = 1; i <= parts.length; i++) restored.add(parts.slice(0, i).join("/"));
}
return Array.from(restored).sort((a, b) => {
const depth = a.split("/").length - b.split("/").length;
return depth || a.localeCompare(b);
});
}
export function removeFailedExpansions(paths: string[], failedPaths: string[]): Set<string> {
return new Set(
paths.filter(
(expanded) =>
!failedPaths.some((failed) => expanded === failed || expanded.startsWith(`${failed}/`)),
),
);
}
function mergeLoadedFolder(tree: FileTreeNode, incoming: FileTreeNode): FileTreeNode {
if (tree.path === incoming.path) return mergeTreeNodes(tree, incoming);
if (!tree.children) return tree;
return { ...tree, children: tree.children.map((child) => mergeLoadedFolder(child, incoming)) };
}
export async function fetchRestoredTree({
client,
owner,
paths,
isCurrentLoad,
}: {
client: NonNullable<ReturnType<typeof getWebSocketClient>>;
owner: TreeLoadOwner;
paths: string[];
isCurrentLoad: () => boolean;
}): Promise<RestoredTree | null> {
const rootResponse = await requestFileTree(client, owner.sessionId, "", 1);
if (!isCurrentLoad()) return null;
let tree = rootResponse.root ?? null;
const failedPaths: string[] = [];
for (const path of paths) {
if (failedPaths.some((failed) => path === failed || path.startsWith(`${failed}/`))) continue;
if (!tree || !findNodeByPath(tree, path)?.is_dir) {
failedPaths.push(path);
continue;
}
const response = await requestFileTree(client, owner.sessionId, path, 1);
if (!isCurrentLoad()) return null;
if (response.root) tree = mergeLoadedFolder(tree, response.root);
else failedPaths.push(path);
}
return { root: rootResponse.root ?? null, tree, failedPaths };
}
export function completeRestoredTree(
restored: RestoredTree | null,
isCurrentLoad: () => boolean,
onFailedPaths: (paths: string[]) => void,
): RestoredTree | null {
if (!restored || !isCurrentLoad()) return null;
if (restored.failedPaths.length > 0) onFailedPaths(restored.failedPaths);
return restored;
}
@@ -0,0 +1,211 @@
import { useCallback, useRef } from "react";
import type React from "react";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { FileTreeNode } from "@/lib/types/backend";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import {
completeRestoredTree,
fetchRestoredTree,
removeFailedExpansions,
} from "./file-browser-restore";
import type { LoadState } from "./file-browser-hooks";
const debugLoad = createDebugLogger("file-browser:load");
const MAX_RETRY_ATTEMPTS = 4;
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
export type TreeLoadOwner = { sessionId: string; resetKey: string; generation: number };
export type TreeLoaderContext = {
sessionId: string;
effectiveResetKey: string;
clearRetryTimer: () => void;
retryAttemptRef: React.MutableRefObject<number>;
retryTimerRef: React.MutableRefObject<NodeJS.Timeout | null>;
restoreExpandedPathsRef: React.MutableRefObject<string[]>;
hasInitializedExpandedRef: React.MutableRefObject<string | null>;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
setExpandedPaths: React.Dispatch<React.SetStateAction<Set<string>>>;
setIsLoadingTree: React.Dispatch<React.SetStateAction<boolean>>;
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
setLoadError: React.Dispatch<React.SetStateAction<string | null>>;
};
function logLoad(event: string, data: Record<string, unknown>) {
if (isDebug()) debugLoad(event, data);
}
function useTreeLoadOwner(sessionId: string, resetKey: string) {
const ownerRef = useRef<TreeLoadOwner>({ sessionId, resetKey, generation: 0 });
if (ownerRef.current.sessionId !== sessionId || ownerRef.current.resetKey !== resetKey) {
ownerRef.current = { sessionId, resetKey, generation: ownerRef.current.generation + 1 };
}
return ownerRef;
}
function requireWebSocketClient() {
const client = getWebSocketClient();
if (!client) throw new Error("WebSocket client not available");
return client;
}
function scheduleRetry({
error,
isCurrentLoad,
owner,
retryAttemptRef,
retryTimerRef,
clearRetryTimer,
setLoadError,
setLoadState,
retry,
}: {
error: unknown;
isCurrentLoad: () => boolean;
owner: TreeLoadOwner;
retryAttemptRef: React.MutableRefObject<number>;
retryTimerRef: React.MutableRefObject<NodeJS.Timeout | null>;
clearRetryTimer: () => void;
setLoadError: React.Dispatch<React.SetStateAction<string | null>>;
setLoadState: React.Dispatch<React.SetStateAction<LoadState>>;
retry: () => void;
}) {
if (!isCurrentLoad()) return;
const message = error instanceof Error ? error.message : "Failed to load file tree";
setLoadError(message);
if (retryAttemptRef.current >= MAX_RETRY_ATTEMPTS) {
setLoadState("manual");
logLoad("gave-up", { sessionId: owner.sessionId, error: message });
return;
}
const delay = RETRY_DELAYS_MS[Math.min(retryAttemptRef.current, RETRY_DELAYS_MS.length - 1)];
retryAttemptRef.current += 1;
setLoadState("waiting");
clearRetryTimer();
logLoad("retry", {
sessionId: owner.sessionId,
attempt: retryAttemptRef.current,
delayMs: delay,
error: message,
});
retryTimerRef.current = setTimeout(retry, delay);
}
async function restoreTree({
owner,
paths,
isCurrentLoad,
setExpandedPaths,
}: {
owner: TreeLoadOwner;
paths: string[];
isCurrentLoad: () => boolean;
setExpandedPaths: React.Dispatch<React.SetStateAction<Set<string>>>;
}) {
const hydrated = await fetchRestoredTree({
client: requireWebSocketClient(),
owner,
paths,
isCurrentLoad,
});
return completeRestoredTree(hydrated, isCurrentLoad, (failedPaths) =>
setExpandedPaths((previous) => removeFailedExpansions(Array.from(previous), failedPaths)),
);
}
export function useTreeLoader(ctx: TreeLoaderContext) {
const {
clearRetryTimer,
retryAttemptRef,
retryTimerRef,
restoreExpandedPathsRef,
hasInitializedExpandedRef,
setTree,
setExpandedPaths,
setIsLoadingTree,
setLoadState,
setLoadError,
} = ctx;
const ownerRef = useTreeLoadOwner(ctx.sessionId, ctx.effectiveResetKey);
const loadInFlightGenerationRef = useRef<number | null>(null);
const loadRequestRef = useRef(0);
const loadTree = useCallback(
async (options?: { resetRetry?: boolean; restoreExpandedPaths?: string[] }) => {
const owner = ownerRef.current;
if (loadInFlightGenerationRef.current === owner.generation) {
logLoad("skip-in-flight", { sessionId: owner.sessionId, resetKey: owner.resetKey });
return;
}
const requestId = ++loadRequestRef.current;
const isCurrentLoad = () =>
loadRequestRef.current === requestId && ownerRef.current.generation === owner.generation;
const restorePaths = options?.restoreExpandedPaths ?? restoreExpandedPathsRef.current;
loadInFlightGenerationRef.current = owner.generation;
setIsLoadingTree(true);
setLoadState("loading");
setLoadError(null);
if (options?.resetRetry) {
retryAttemptRef.current = 0;
clearRetryTimer();
}
logLoad("start", {
sessionId: owner.sessionId,
resetKey: owner.resetKey,
resetRetry: options?.resetRetry === true,
retryAttempt: retryAttemptRef.current,
});
try {
const completed = await restoreTree({
owner,
paths: restorePaths,
isCurrentLoad,
setExpandedPaths,
});
if (!completed) return;
hasInitializedExpandedRef.current = owner.resetKey;
setTree(completed.tree);
setLoadState("loaded");
retryAttemptRef.current = 0;
clearRetryTimer();
logLoad("loaded", {
sessionId: owner.sessionId,
rootPath: completed.root?.path ?? null,
children: completed.root?.children?.length ?? 0,
});
} catch (error) {
scheduleRetry({
error,
isCurrentLoad,
owner,
retryAttemptRef,
retryTimerRef,
clearRetryTimer,
setLoadError,
setLoadState,
retry: () => {
retryTimerRef.current = null;
if (!isCurrentLoad()) return;
void loadTree({ restoreExpandedPaths: restorePaths });
},
});
} finally {
if (isCurrentLoad()) {
setIsLoadingTree(false);
loadInFlightGenerationRef.current = null;
}
}
},
[
clearRetryTimer,
retryAttemptRef,
retryTimerRef,
restoreExpandedPathsRef,
hasInitializedExpandedRef,
setTree,
setExpandedPaths,
setIsLoadingTree,
setLoadError,
setLoadState,
],
);
return loadTree;
}