"use client"; import { useEffect, useState, useCallback, useRef } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileTree, requestFileContent, searchWorkspaceFiles, } from "@/lib/ws/workspace-files"; import type { FileTreeNode, FileContentResponse, OpenFileTab } from "@/lib/types/backend"; import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl"; import { getFilesPanelExpandedPaths, setFilesPanelExpandedPaths, getFilesPanelScrollPosition, setFilesPanelScrollPosition, } from "@/lib/local-storage"; import type { useToast } from "@/components/toast-provider"; import { mergeTreeNodes } from "./file-browser-parts"; 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. */ function applyFileChanges(ctx: { client: ReturnType; sessionId: string; expandedPaths: Set; changes: Array<{ path: string }>; setTree: React.Dispatch>; setLoadState: React.Dispatch>; }) { const { client, sessionId, expandedPaths, changes, setTree, setLoadState } = ctx; const candidates = new Set(); for (const change of changes) { const p = change.path; const lastSlash = p.lastIndexOf("/"); candidates.add(lastSlash === -1 ? "" : p.substring(0, lastSlash)); candidates.add(p); } const foldersToRefresh = new Set(); for (const c of candidates) { if (c === "" || expandedPaths.has(c)) foldersToRefresh.add(c); } if (foldersToRefresh.size === 0) return; 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; }; return patchNode(updated); }); setLoadState("loaded"); } catch (error) { console.error("[FileBrowser] Failed to refresh file tree:", error); } })(); } function useLoadingTimers() { const loadingTimersRef = useRef>(new Map()); const [visibleLoadingPaths, setVisibleLoadingPaths] = useState>(new Set()); const showLoading = useCallback((path: string) => { 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) => { 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; }); }, []); return { visibleLoadingPaths, showLoading, hideLoading }; } type TreeLoaderContext = { sessionId: string; clearRetryTimer: () => void; retryAttemptRef: React.MutableRefObject; retryTimerRef: React.MutableRefObject; setTree: React.Dispatch>; setIsLoadingTree: React.Dispatch>; setLoadState: React.Dispatch>; setLoadError: React.Dispatch>; }; 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) return; loadInFlightRef.current = true; setIsLoadingTree(true); setLoadState("loading"); setLoadError(null); if (options?.resetRetry) { retryAttemptRef.current = 0; clearRetryTimer(); } 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(); } 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(); retryTimerRef.current = setTimeout(() => { void loadTree(); }, delay); } else { setLoadState("manual"); } } finally { setIsLoadingTree(false); loadInFlightRef.current = false; } }, [ clearRetryTimer, retryAttemptRef, retryTimerRef, setIsLoadingTree, setLoadError, setLoadState, setTree, ], ); return loadTree; } function useFileChangeSubscription({ sessionIdRef, expandedPaths, setTree, setLoadState, }: { sessionIdRef: React.MutableRefObject; expandedPaths: Set; setTree: React.Dispatch>; setLoadState: React.Dispatch>; }) { 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) return; applyFileChanges({ client, sessionId: sessionIdRef.current, expandedPaths, changes, setTree, setLoadState, }); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [expandedPaths]); } /** * 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); const [expandedPaths, setExpandedPaths] = useState>(new Set()); 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 } = 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; 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; // Only load now if agentctl is ready; otherwise the sibling effect fires // on the ready flip. Prevents burning the retry budget during slow prepare. if (agentctlIsReadyRef.current) void loadTree({ resetRetry: true }); else setIsLoadingTree(false); return () => { clearRetryTimer(); }; }, [clearRetryTimer, loadTree, effectiveResetKey]); // 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`. // "loaded" only counts when a tree is actually present — `applyFileChanges` // can flip state to "loaded" while the tree is still null. useEffect(() => { if (!agentctlStatus.isReady) return; if (loadStateRef.current === "loading") return; if (loadStateRef.current === "loaded" && treeRef.current) return; void loadTree({ resetRetry: true }); }, [agentctlStatus.isReady, loadTree]); 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, expandedPaths, setTree, setLoadState }); const collapseAll = useCallback(() => { setExpandedPaths(new Set()); }, []); return { tree, setTree, expandedPaths, setExpandedPaths, visibleLoadingPaths, isLoadingTree, loadState, loadError, loadTree, showLoading, hideLoading, collapseAll, }; } /** Hook for scroll position persistence in the file browser. */ export function useScrollPersistence( sessionId: string, isTreeLoaded: boolean, scrollAreaRef: React.RefObject, tree: FileTreeNode | null, ) { const scrollSaveTimeoutRef = useRef(null); const hasRestoredScrollRef = useRef(null); // Restore scroll position after tree loads useEffect(() => { if (!isTreeLoaded || hasRestoredScrollRef.current === sessionId) return; const savedScroll = getFilesPanelScrollPosition(sessionId); if (savedScroll > 0 && scrollAreaRef.current) { const viewport = scrollAreaRef.current.querySelector("[data-radix-scroll-area-viewport]"); if (viewport) { viewport.scrollTop = savedScroll; hasRestoredScrollRef.current = sessionId; } } else { hasRestoredScrollRef.current = sessionId; } }, [isTreeLoaded, sessionId, scrollAreaRef]); // Attach scroll listener to ScrollArea viewport useEffect(() => { const el = scrollAreaRef.current; if (!el) return; const viewport = el.querySelector("[data-radix-scroll-area-viewport]"); if (!viewport) return; const onScroll = (event: Event) => { const target = event.target as HTMLElement; if (scrollSaveTimeoutRef.current) clearTimeout(scrollSaveTimeoutRef.current); scrollSaveTimeoutRef.current = setTimeout(() => { setFilesPanelScrollPosition(sessionId, target.scrollTop); }, 150); }; viewport.addEventListener("scroll", onScroll); return () => { viewport.removeEventListener("scroll", onScroll); if (scrollSaveTimeoutRef.current) clearTimeout(scrollSaveTimeoutRef.current); }; }, [sessionId, tree, scrollAreaRef]); } /** Fetch children for a folder node if not already loaded. */ export async function loadNodeChildren( node: FileTreeNode, sessionId: string, treeState: ReturnType, ) { if (node.children && node.children.length > 0) return; treeState.showLoading(node.path); try { const client = getWebSocketClient(); if (!client) return; const response = await requestFileTree(client, sessionId, node.path, 1); const updateNode = (n: FileTreeNode): FileTreeNode => { if (n.path === node.path) return { ...n, children: response.root.children }; return n.children ? { ...n, children: n.children.map(updateNode) } : n; }; if (treeState.tree) treeState.setTree(updateNode(treeState.tree)); } catch (error) { console.error("Failed to load children:", error); } finally { treeState.hideLoading(node.path); } } /** Fetch and open a file by path. */ export async function fetchAndOpenFile( sessionId: string, path: string, onOpenFile: (file: OpenFileTab) => void, toast: ReturnType["toast"], ) { try { const client = getWebSocketClient(); if (!client) return; const response: FileContentResponse = await requestFileContent(client, sessionId, path); const { calculateHash } = await import("@/lib/utils/file-diff"); const hash = await calculateHash(response.content); const name = path.split("/").pop() || path; onOpenFile({ path, name, content: response.content, originalContent: response.content, originalHash: hash, isDirty: false, isBinary: response.is_binary, }); } catch (error) { const reason = error instanceof Error ? error.message : "Unknown error"; toast({ title: "Failed to open file", description: reason, variant: "error" }); } }