"use client"; import { useEffect, useState, useCallback, useRef, useMemo } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileTree, searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import type { FileTreeNode } from "@/lib/types/backend"; import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl"; import { getFilesPanelExpandedPaths, setFilesPanelExpandedPaths } from "@/lib/local-storage"; import { useTree, type VisibleRow } from "@/hooks/use-tree"; import { mergeTreeNodes } from "./file-browser-parts"; import { compareTreeNodes, sortRootChildren } from "./file-tree-utils"; import { createDebugLogger, IS_DEBUG } 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; 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. */ export function useFileBrowserSearch(sessionId: string) { const [isSearchActive, setIsSearchActive] = useState(false); const [localSearchQuery, setLocalSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [isSearching, setIsSearching] = useState(false); const searchTimeoutRef = useRef(null); const searchInputRef = useRef(null); // Focus search input when search opens useEffect(() => { if (isSearchActive && searchInputRef.current) { searchInputRef.current.focus(); } }, [isSearchActive]); // Clear search when closing useEffect(() => { if (!isSearchActive) { setLocalSearchQuery(""); setSearchResults(null); setIsSearching(false); if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); } }, [isSearchActive]); // Cleanup on unmount useEffect(() => { return () => { if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); }; }, []); const handleSearchChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; setLocalSearchQuery(value); if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); if (!value.trim()) { setSearchResults(null); setIsSearching(false); return; } setIsSearching(true); searchTimeoutRef.current = setTimeout(async () => { try { const client = getWebSocketClient(); if (!client) return; const response = await searchWorkspaceFiles(client, sessionId, value, 50); setSearchResults(response.files || []); } catch (error) { console.error("Failed to search files:", error); setSearchResults([]); } finally { setIsSearching(false); } }, 300); }, [sessionId], ); const handleCloseSearch = useCallback(() => { setIsSearchActive(false); setLocalSearchQuery(""); setSearchResults(null); setIsSearching(false); if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); }, []); return { isSearchActive, setIsSearchActive, localSearchQuery, searchResults, isSearching, searchInputRef, handleSearchChange, handleCloseSearch, }; } /** Apply incoming file changes to the tree by refreshing affected folders. */ export function applyFileChanges(ctx: { client: ReturnType; sessionId: string; expandedPaths: ReadonlySet; changes: Array<{ path: string; operation?: string; repository_name?: string }>; setTree: React.Dispatch>; setLoadState: React.Dispatch>; }) { const { client, sessionId, expandedPaths, changes, setTree, setLoadState } = ctx; const foldersToRefresh = new Set(); 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; for (const exp of expandedPaths) { if (!repo || exp === repo || exp.startsWith(repo + "/")) { foldersToRefresh.add(exp); } } continue; } const p = change.path; const lastSlash = p.lastIndexOf("/"); const parent = lastSlash === -1 ? "" : p.substring(0, lastSlash); if (parent === "" || expandedPaths.has(parent)) foldersToRefresh.add(parent); if (p === "" || expandedPaths.has(p)) foldersToRefresh.add(p); } if (foldersToRefresh.size === 0) { if (IS_DEBUG) debugChanges("no-folders-to-refresh", { sessionId, candidates: changes.length, expandedPaths: expandedPaths.size, }); return; } if (IS_DEBUG) debugChanges("refresh", { sessionId, folders: Array.from(foldersToRefresh).slice(0, 5), total: foldersToRefresh.size, }); void (async () => { try { const folderUpdates = new Map(); await Promise.all( Array.from(foldersToRefresh).map(async (folder) => { try { const res = await requestFileTree(client!, sessionId, folder || "", 1); folderUpdates.set(folder, res.root?.children); } catch { /* Folder may have been removed */ } }), ); setTree((prev) => { if (!prev) return prev; let updated = prev; if (folderUpdates.has("")) { const freshRootChildren = folderUpdates.get(""); const existingByPath = new Map((updated.children ?? []).map((c) => [c.path, c])); const mergedRootChildren = freshRootChildren?.map((incoming) => { const existing = existingByPath.get(incoming.path); return existing && existing.is_dir && incoming.is_dir ? mergeTreeNodes(existing, incoming) : incoming; }); updated = { ...updated, children: mergedRootChildren }; } const subFolders = Array.from(folderUpdates.keys()).filter((k) => k !== ""); if (subFolders.length === 0) return updated; const patchNode = (node: FileTreeNode): FileTreeNode => { if (node.is_dir && folderUpdates.has(node.path)) { return { ...node, children: folderUpdates.get(node.path)?.map(patchNode) }; } 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"); } catch (error) { console.error("[FileBrowser] Failed to refresh file tree:", error); } })(); } function useLoadingTimers() { const loadingTimersRef = useRef>(new Map()); const activeLoadsRef = useRef>(new Set()); const [visibleLoadingPaths, setVisibleLoadingPaths] = useState>(new Set()); const showLoading = useCallback((path: string) => { activeLoadsRef.current.add(path); const timer = setTimeout(() => { setVisibleLoadingPaths((prev) => new Set(prev).add(path)); loadingTimersRef.current.delete(path); }, 150); loadingTimersRef.current.set(path, timer); }, []); const hideLoading = useCallback((path: string) => { activeLoadsRef.current.delete(path); const timer = loadingTimersRef.current.get(path); if (timer) { clearTimeout(timer); loadingTimersRef.current.delete(path); } setVisibleLoadingPaths((prev) => { const next = new Set(prev); next.delete(path); return next; }); }, []); const isLoading = useCallback((path: string) => activeLoadsRef.current.has(path), []); return { visibleLoadingPaths, showLoading, hideLoading, isLoading }; } type TreeLoaderContext = { sessionId: string; clearRetryTimer: () => void; retryAttemptRef: React.MutableRefObject; retryTimerRef: React.MutableRefObject; setTree: React.Dispatch>; setIsLoadingTree: React.Dispatch>; setLoadState: React.Dispatch>; setLoadError: React.Dispatch>; }; // Thin wrapper so loadTree callers don't each pay a complexity point for IS_DEBUG. function logLoad(event: string, data: Record) { if (IS_DEBUG) 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; agentctlIsReady: boolean; agentctlIsReadyRef: React.MutableRefObject; loadStateRef: React.MutableRefObject; treeRef: React.MutableRefObject; retryAttemptRef: React.MutableRefObject; hasInitializedExpandedRef: React.MutableRefObject; clearRetryTimer: () => void; loadTree: (options?: { resetRetry?: boolean }) => Promise | void; setTree: React.Dispatch>; setIsLoadingTree: React.Dispatch>; setLoadState: React.Dispatch>; setLoadError: React.Dispatch>; setExpandedPaths: (paths: Set) => void; }; /** Owns the two effects that drive initial load and the waiting→ready flip. */ function useTreeLoadEffects(ctx: TreeLoadEffectsContext) { const { sessionId, effectiveResetKey, agentctlIsReady, agentctlIsReadyRef, loadStateRef, treeRef, retryAttemptRef, hasInitializedExpandedRef, clearRetryTimer, loadTree, setTree, setIsLoadingTree, setLoadState, setLoadError, setExpandedPaths, } = ctx; useEffect(() => { setTree(null); setIsLoadingTree(true); setLoadState(agentctlIsReadyRef.current ? "loading" : "waiting"); setLoadError(null); retryAttemptRef.current = 0; clearRetryTimer(); hasInitializedExpandedRef.current = null; const savedPaths = getFilesPanelExpandedPaths(effectiveResetKey); setExpandedPaths(savedPaths.length > 0 ? new Set(savedPaths) : new Set()); if (savedPaths.length > 0) hasInitializedExpandedRef.current = effectiveResetKey; logLoad("init-effect", { sessionId, effectiveResetKey, agentctlReady: agentctlIsReadyRef.current, savedPaths: savedPaths.length, willLoad: agentctlIsReadyRef.current, }); if (agentctlIsReadyRef.current) void loadTree({ resetRetry: true }); else setIsLoadingTree(false); return () => { clearRetryTimer(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- refs intentionally omitted }, [clearRetryTimer, loadTree, effectiveResetKey, 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"; else if (loadStateRef.current === "loading") reason = "already-loading"; else if (loadStateRef.current === "loaded" && treeRef.current) reason = "already-loaded"; if (reason) { logLoad("ready-effect-skip", { sessionId, reason, loadState: loadStateRef.current }); return; } logLoad("ready-flip", { sessionId, loadState: loadStateRef.current }); void loadTree({ resetRetry: true }); // eslint-disable-next-line react-hooks/exhaustive-deps -- refs intentionally omitted }, [agentctlIsReady, loadTree, sessionId]); } function useFileChangeSubscription({ sessionIdRef, expandedPathsRef, setTree, setLoadState, }: { sessionIdRef: React.MutableRefObject; expandedPathsRef: React.MutableRefObject>; setTree: React.Dispatch>; setLoadState: React.Dispatch>; }) { // 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; return client.on("session.workspace.file.changes", (msg) => { const changes = msg.payload?.changes; if (!changes || changes.length === 0) { if (IS_DEBUG) debugChanges("event-empty", { sessionId: sessionIdRef.current }); return; } if (IS_DEBUG) debugChanges("event", { sessionId: sessionIdRef.current, count: changes.length, expandedPaths: expandedPathsRef.current.size, firstPaths: changes.slice(0, 3).map((c: { path: string }) => c.path), }); applyFileChanges({ client, sessionId: sessionIdRef.current, expandedPaths: expandedPathsRef.current, changes, setTree, setLoadState, }); }); }, [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(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({ nodes: treeNodes, getPath: FB_GET_PATH, getChildren: FB_GET_CHILDREN, isDir: FB_IS_DIR, }); 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>(expandedPaths); expandedPathsRef.current = expandedPaths; const [isLoadingTree, setIsLoadingTree] = useState(true); const [loadState, setLoadState] = useState("loading"); const [loadError, setLoadError] = useState(null); const hasInitializedExpandedRef = useRef(null); const retryAttemptRef = useRef(0); const retryTimerRef = useRef(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, clearRetryTimer, retryAttemptRef, retryTimerRef, setTree, 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); agentctlIsReadyRef.current = agentctlStatus.isReady; loadStateRef.current = loadState; treeRef.current = tree; useTreeLoadEffects({ sessionId, effectiveResetKey, agentctlIsReady: agentctlStatus.isReady, agentctlIsReadyRef, loadStateRef, treeRef, retryAttemptRef, hasInitializedExpandedRef, clearRetryTimer, loadTree, setTree, setIsLoadingTree, setLoadState, setLoadError, setExpandedPaths, }); 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]); useFileChangeSubscription({ sessionIdRef, expandedPathsRef, setTree, setLoadState }); const collapseAll = treeApi.collapseAll; return { tree, setTree, expandedPaths, setExpandedPaths, visibleRows, visibleLoadingPaths, isLoadingTree, loadState, loadError, loadTree, showLoading, hideLoading, isLoading, 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, toggleFolderExpand, fetchAndOpenFile, } from "./file-browser-actions"; export type { ToggleFolderExpandDeps } from "./file-browser-actions";