"use client" import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react" import { revealItemInDir } from "@/lib/platform" import ignore from "ignore" import { Check, ChevronRight } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useFolderContext } from "@/contexts/folder-context" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useTabContext } from "@/contexts/tab-context" import { useTerminalContext } from "@/contexts/terminal-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner" import { createFileTreeEntry, deleteFileTreeEntry, gitAddFiles, getFileTree, getGitBranch, gitListAllBranches, gitRollbackFile, gitStatus, readFileForEdit, readFilePreview, openCommitWindow, renameFileTreeEntry, saveFileCopy, } from "@/lib/api" import { emitAttachFileToSession } from "@/lib/session-attachment-events" import { ScrollArea } from "@/components/ui/scroll-area" import type { FileTreeNode, GitBranchList, GitStatusEntry } from "@/lib/types" import { FileTree, FileTreeFolder, FileTreeFile, } from "@/components/ai-elements/file-tree" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { Skeleton } from "@/components/ui/skeleton" import { joinFsPath } from "@/lib/path-utils" function parentDir(filePath: string): string { const slashIndex = filePath.lastIndexOf("/") const backslashIndex = filePath.lastIndexOf("\\") const splitIndex = Math.max(slashIndex, backslashIndex) if (splitIndex < 0) return filePath if (splitIndex === 0) return filePath.slice(0, 1) return filePath.slice(0, splitIndex) } function baseName(path: string): string { return path.split(/[/\\]/).pop() || path } const FILE_TREE_ROOT_PATH = "__workspace_root__" const GITIGNORE_MUTED_CLASS = "text-muted-foreground/55" interface FileActionTarget { kind: "file" | "dir" path: string name: string } interface ExternalConflictPrompt { path: string diskContent: string unsavedContent: string signature: string } type GitFileState = | "untracked" | "modified" | "staged" | "conflicted" | "deleted" | "renamed" function normalizeGitStatusPath(path: string): string { const normalized = path.trim() const renameSeparator = " -> " const renameIndex = normalized.lastIndexOf(renameSeparator) if (renameIndex < 0) return normalized return normalized.slice(renameIndex + renameSeparator.length).trim() } function normalizeComparePath(path: string): string { return path.replace(/\\/g, "/").replace(/\/+$/, "") } function prefixFileTreeNodePaths( nodes: FileTreeNode[], prefix: string ): FileTreeNode[] { return nodes.map((node) => { const nextPath = prefix ? `${prefix}/${node.path}` : node.path if (node.kind === "file") { return { ...node, path: nextPath, } } return { ...node, path: nextPath, children: prefixFileTreeNodePaths(node.children, nextPath), } }) } function applyLazyTreeOverrides( nodes: FileTreeNode[], overrides: ReadonlyMap ): FileTreeNode[] { return nodes.map((node) => { if (node.kind === "file") return node const overrideChildren = overrides.get(node.path) const baseChildren = overrideChildren ?? node.children return { ...node, children: applyLazyTreeOverrides(baseChildren, overrides), } }) } function findDirectoryChildren( nodes: FileTreeNode[], targetPath: string ): FileTreeNode[] | null { for (const node of nodes) { if (node.kind !== "dir") continue if (normalizeComparePath(node.path) === targetPath) { return node.children } const nested = findDirectoryChildren(node.children, targetPath) if (nested) return nested } return null } function classifyGitFileState(status: string): GitFileState | null { const code = status.trim().toUpperCase() if (!code) return null if (code === "??") return "untracked" if (code.includes("U")) return "conflicted" if (code.includes("R") || code.includes("C")) return "renamed" if (code.includes("D")) return "deleted" if (code.includes("M") || code.includes("T")) return "modified" if (code.includes("A")) return "staged" return null } function getGitFileStateClassName(status?: string): string { if (!status) return "" const state = classifyGitFileState(status) if (state === "untracked") return "text-red-500 dark:text-red-400" if (state === "modified") return "text-emerald-600 dark:text-emerald-400" if (state === "staged") return "text-emerald-500 dark:text-emerald-400" if (state === "conflicted") return "text-amber-500 dark:text-amber-400" if (state === "deleted") return "text-orange-500 dark:text-orange-400" if (state === "renamed") return "text-violet-500 dark:text-violet-400" return "" } function getParentPath(path: string): string | null { const splitIdx = path.lastIndexOf("/") if (splitIdx < 0) return null return path.slice(0, splitIdx) } function hasIgnoredAncestor(path: string, ignoredPaths: ReadonlySet) { let current = path while (true) { const parent = getParentPath(current) if (!parent) return false if (ignoredPaths.has(parent)) return true current = parent } } type DirectoryGitAction = "add" | "rollback" interface DirectoryGitCandidateEntry { path: string status: string } type DirectoryGitTreeNode = DirectoryGitTreeDirNode | DirectoryGitTreeFileNode interface DirectoryGitTreeDirNode { kind: "dir" name: string path: string children: DirectoryGitTreeNode[] fileCount: number } interface DirectoryGitTreeFileNode { kind: "file" name: string path: string status: string } interface MutableDirectoryGitTreeDirNode { kind: "dir" name: string path: string children: Map< string, MutableDirectoryGitTreeDirNode | DirectoryGitTreeFileNode > } const DIRECTORY_GIT_TREE_ROOT_PATH = "__directory_git_tree_root__" function isPathInDirectory(path: string, directoryPath: string): boolean { const normalizedPath = normalizeComparePath(path) const normalizedDir = normalizeComparePath(directoryPath) if (!normalizedDir) return normalizedPath.length > 0 return ( normalizedPath === normalizedDir || normalizedPath.startsWith(`${normalizedDir}/`) ) } function scopeGitStatusEntriesForDirectory( entries: GitStatusEntry[], directoryPath: string ): DirectoryGitCandidateEntry[] { const normalizedDirPath = normalizeComparePath(directoryPath) const scopedEntries: DirectoryGitCandidateEntry[] = [] const dedupByPath = new Set() for (const entry of entries) { const normalizedPath = normalizeComparePath( normalizeGitStatusPath(entry.file) ) if (!normalizedPath) continue if (!isPathInDirectory(normalizedPath, normalizedDirPath)) continue if (normalizedPath === normalizedDirPath) continue if (dedupByPath.has(normalizedPath)) continue dedupByPath.add(normalizedPath) scopedEntries.push({ path: normalizedPath, status: entry.status }) } return scopedEntries.sort((left, right) => left.path.localeCompare(right.path, undefined, { sensitivity: "base" }) ) } function filterDirectoryGitCandidates( entries: DirectoryGitCandidateEntry[], action: DirectoryGitAction ): DirectoryGitCandidateEntry[] { if (action === "add") { return entries.filter((entry) => { const fileState = classifyGitFileState(entry.status) return fileState === "untracked" }) } return entries.filter((entry) => { const fileState = classifyGitFileState(entry.status) return fileState !== "untracked" }) } function buildDirectoryGitTree( entries: DirectoryGitCandidateEntry[], directoryPath: string ): DirectoryGitTreeNode[] { const normalizedDirPath = normalizeComparePath(directoryPath) const root: MutableDirectoryGitTreeDirNode = { kind: "dir", name: "", path: "", children: new Map(), } for (const entry of entries) { let relativePath = normalizeComparePath(entry.path) if (normalizedDirPath && relativePath.startsWith(`${normalizedDirPath}/`)) { relativePath = relativePath.slice(normalizedDirPath.length + 1) } const segments = relativePath.split("/").filter(Boolean) if (segments.length === 0) continue let current = root for (const [index, segment] of segments.entries()) { const isLeaf = index === segments.length - 1 const nestedPath = segments.slice(0, index + 1).join("/") const nodePath = normalizedDirPath ? `${normalizedDirPath}/${nestedPath}` : nestedPath if (isLeaf) { current.children.set(`file:${nodePath}`, { kind: "file", name: segment, path: nodePath, status: entry.status, }) continue } const dirKey = `dir:${nodePath}` const existing = current.children.get(dirKey) if (existing && existing.kind === "dir") { current = existing continue } const nextDir: MutableDirectoryGitTreeDirNode = { kind: "dir", name: segment, path: nodePath, children: new Map(), } current.children.set(dirKey, nextDir) current = nextDir } } const toSortedTreeNodes = ( dir: MutableDirectoryGitTreeDirNode ): DirectoryGitTreeNode[] => { return Array.from(dir.children.values()) .map((node) => { if (node.kind === "file") return node return { kind: "dir" as const, name: node.name, path: node.path, children: toSortedTreeNodes(node), fileCount: 0, } }) .sort((left, right) => { if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1 return left.name.localeCompare(right.name, undefined, { sensitivity: "base", }) }) } const annotateDirectory = ( node: DirectoryGitTreeDirNode ): DirectoryGitTreeDirNode => { const nextChildren = node.children.map((child) => { if (child.kind === "file") return child return annotateDirectory(child) }) const fileCount = nextChildren.reduce((count, child) => { if (child.kind === "file") return count + 1 return count + child.fileCount }, 0) return { ...node, children: nextChildren, fileCount, } } return toSortedTreeNodes(root).map((node) => { if (node.kind === "file") return node return annotateDirectory(node) }) } function collectDirectoryGitTreeExpandedPaths( nodes: DirectoryGitTreeNode[], expanded = new Set() ): Set { for (const node of nodes) { if (node.kind !== "dir") continue expanded.add(node.path) collectDirectoryGitTreeExpandedPaths(node.children, expanded) } return expanded } function collectDirectoryGitTreeLeafPaths( node: DirectoryGitTreeNode ): string[] { if (node.kind === "file") return [node.path] return node.children.flatMap(collectDirectoryGitTreeLeafPaths) } interface RenderNodeProps { node: FileTreeNode expandedPaths: ReadonlySet workspacePath: string activeSessionTabId: string | null gitEnabled: boolean gitStatusByPath: ReadonlyMap gitChangedDirPaths: ReadonlySet untrackedDirPaths: ReadonlySet gitignoreIgnoredPaths: ReadonlySet ancestorGitignoreIgnored: boolean ancestorUntracked: boolean onOpenFilePreview: (path: string) => void onOpenFileDiff: (path: string) => void onOpenDirDiff: (path: string) => void onOpenCommitWindow: () => void onRequestCompareWithBranch: (target: FileActionTarget) => void onRequestRollback: (target: FileActionTarget) => void onOpenDirInTerminal: (dirPath: string, fileName: string) => Promise onRequestAddToVcs: (target: FileActionTarget) => void onRequestRename: (target: FileActionTarget) => void onRequestCreate: (parentPath: string, kind: "file" | "dir") => void onRequestDelete: (target: FileActionTarget) => void onRefresh: () => void } function RenderNode({ node, expandedPaths, workspacePath, activeSessionTabId, gitEnabled, gitStatusByPath, gitChangedDirPaths, untrackedDirPaths, gitignoreIgnoredPaths, ancestorGitignoreIgnored, ancestorUntracked, onOpenFilePreview, onOpenFileDiff, onOpenDirDiff, onOpenCommitWindow, onRequestCompareWithBranch, onRequestRollback, onOpenDirInTerminal, onRequestAddToVcs, onRequestCreate, onRequestRename, onRequestDelete, onRefresh, }: RenderNodeProps) { const t = useTranslations("Folder.fileTreeTab") const tCommon = useTranslations("Folder.common") const isGitignoreIgnored = ancestorGitignoreIgnored || gitignoreIgnoredPaths.has(node.path) const systemExplorerLabel = typeof navigator === "undefined" ? t("openInFileManager") : (() => { const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase() if (platform.includes("mac")) return t("openInFinder") if (platform.includes("win")) return t("openInExplorer") return t("openInFileManager") })() if (node.kind === "file") { const gitStatusCode = gitStatusByPath.get(node.path) ?? (ancestorUntracked ? "??" : undefined) const absolutePath = joinFsPath(workspacePath, node.path) const dirPath = parentDir(absolutePath) const isGitMenuDisabled = !gitEnabled || isGitignoreIgnored const handleAttachToSession = () => { if (!activeSessionTabId) return emitAttachFileToSession({ tabId: activeSessionTabId, path: absolutePath, }) } const handleOpenInSystemExplorer = async () => { try { await revealItemInDir(absolutePath) } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.openDirectoryFailed"), { description: message }) } } return ( onOpenFilePreview(node.path)}> {tCommon("openFile")} void handleAttachToSession()} disabled={!activeSessionTabId} > {t("attachToCurrentSession")} {t("new")} onRequestCreate(node.path, "file")} > {t("newFile")} onRequestCreate(node.path, "dir")} > {t("newDirectory")} {t("git")} onOpenCommitWindow()} disabled={isGitMenuDisabled} > {t("actions.commitCode")} onRequestAddToVcs(node)} disabled={ isGitMenuDisabled || classifyGitFileState(gitStatusCode ?? "") !== "untracked" } > {t("actions.addToVcs")} onOpenFileDiff(node.path)} disabled={isGitMenuDisabled} > {tCommon("viewDiff")} onRequestCompareWithBranch(node)} disabled={isGitMenuDisabled} > {t("compareWithBranch")} onRequestRollback(node)} disabled={isGitMenuDisabled} > {t("actions.rollback")} onRequestRename(node)}> {tCommon("rename")} {t("reloadFromDisk")} {t("openIn")} void handleOpenInSystemExplorer()} > {systemExplorerLabel} void onOpenDirInTerminal(dirPath, node.name)} > {t("openInTerminal")} onRequestDelete(node)} variant="destructive" > {tCommon("delete")} ) } const absolutePath = joinFsPath(workspacePath, node.path) const isThisDirUntracked = ancestorUntracked || untrackedDirPaths.has(node.path) const dirHasChanges = !isGitignoreIgnored && (gitChangedDirPaths.has(node.path) || isThisDirUntracked) const isGitMenuDisabled = !gitEnabled || isGitignoreIgnored const shouldRenderChildren = expandedPaths.has(node.path) const handleAttachDirToSession = () => { if (!activeSessionTabId) return emitAttachFileToSession({ tabId: activeSessionTabId, path: absolutePath, }) } const handleOpenDirInSystemExplorer = async () => { try { await revealItemInDir(absolutePath) } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.openDirectoryFailed"), { description: message }) } } return ( {shouldRenderChildren ? node.children.map((child) => ( )) : null} {t("attachToCurrentSession")} {t("new")} onRequestCreate(node.path, "file")} > {t("newFile")} onRequestCreate(node.path, "dir")}> {t("newDirectory")} {t("git")} onOpenCommitWindow()} disabled={isGitMenuDisabled} > {t("actions.commitCode")} onRequestAddToVcs(node)} disabled={isGitMenuDisabled} > {t("actions.addToVcs")} onOpenDirDiff(node.path)} disabled={isGitMenuDisabled} > {tCommon("viewDiff")} onRequestCompareWithBranch(node)} disabled={isGitMenuDisabled} > {t("compareWithBranch")} onRequestRollback(node)} disabled={isGitMenuDisabled} > {t("actions.rollback")} onRequestRename(node)}> {tCommon("rename")} {t("openIn")} void handleOpenDirInSystemExplorer()} > {systemExplorerLabel} void onOpenDirInTerminal(absolutePath, node.name)} > {t("openInTerminal")} {t("reloadFromDisk")} onRequestDelete(node)} variant="destructive" > {tCommon("delete")} ) } export function FileTreeTab() { const t = useTranslations("Folder.fileTreeTab") const tCommon = useTranslations("Folder.common") const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext() const { folder } = useFolderContext() const { tabs, activeTabId } = useTabContext() const { createTerminalInDirectory } = useTerminalContext() const { activeFileTab, activeFilePath, openBranchDiff, openExternalConflictDiff, openFilePreview, openWorkingTreeDiff, } = useWorkspaceContext() const workspaceState = useWorkspaceStateStore(folder?.path ?? null) const [nodes, setNodes] = useState([]) const [gitStatusByPath, setGitStatusByPath] = useState>( new Map() ) const [gitEnabled, setGitEnabled] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [renameTarget, setRenameTarget] = useState( null ) const [renameValue, setRenameValue] = useState("") const [renaming, setRenaming] = useState(false) const [createParentPath, setCreateParentPath] = useState(null) const [createKind, setCreateKind] = useState<"file" | "dir">("file") const [createName, setCreateName] = useState("") const [creating, setCreating] = useState(false) const [deleteTarget, setDeleteTarget] = useState( null ) const [deleting, setDeleting] = useState(false) const [rollbackTarget, setRollbackTarget] = useState( null ) const [rollingBack, setRollingBack] = useState(false) const [compareTarget, setCompareTarget] = useState( null ) const [externalConflictPrompt, setExternalConflictPrompt] = useState(null) const [savingExternalConflictCopy, setSavingExternalConflictCopy] = useState(false) const [directoryGitActionType, setDirectoryGitActionType] = useState(null) const [directoryGitActionTarget, setDirectoryGitActionTarget] = useState(null) const [directoryGitCandidates, setDirectoryGitCandidates] = useState< DirectoryGitCandidateEntry[] >([]) const [directoryGitSelectedPaths, setDirectoryGitSelectedPaths] = useState< Set >(new Set()) const [directoryGitExpandedPaths, setDirectoryGitExpandedPaths] = useState< Set >(new Set([DIRECTORY_GIT_TREE_ROOT_PATH])) const [directoryGitLoading, setDirectoryGitLoading] = useState(false) const [directoryGitSubmitting, setDirectoryGitSubmitting] = useState(false) const [directoryGitError, setDirectoryGitError] = useState( null ) const [compareBranchFilter, setCompareBranchFilter] = useState("") const [compareCurrentBranch, setCompareCurrentBranch] = useState< string | null >(null) const [compareBranchList, setCompareBranchList] = useState({ local: [], remote: [], worktree_branches: [], }) const [compareBranchLoading, setCompareBranchLoading] = useState(false) const [compareRecentOpen, setCompareRecentOpen] = useState(true) const [compareLocalOpen, setCompareLocalOpen] = useState(false) const [compareRemoteOpen, setCompareRemoteOpen] = useState(false) const [comparing, setComparing] = useState(false) const [expandedPaths, setExpandedPaths] = useState>( () => new Set([FILE_TREE_ROOT_PATH]) ) const [gitignoreIgnoredPaths, setGitignoreIgnoredPaths] = useState< Set >(new Set()) const activeFileTabRef = useRef(activeFileTab) const filePathSetRef = useRef>(new Set()) const previousWorkspaceSeqRef = useRef(0) const previousExpandedPathsRef = useRef>( new Set([FILE_TREE_ROOT_PATH]) ) const lazyLoadedChildrenByPathRef = useRef>( new Map() ) const lazyLoadingDirPathsRef = useRef>(new Set()) const loadDirectoryChildrenRef = useRef< ((dirPath: string) => Promise) | null >(null) const expandedPathsRef = useRef>(new Set([FILE_TREE_ROOT_PATH])) const workspaceTreeRef = useRef([]) const externalConflictSignatureByPathRef = useRef>( new Map() ) useEffect(() => { activeFileTabRef.current = activeFileTab }, [activeFileTab]) useEffect(() => { setExpandedPaths(new Set([FILE_TREE_ROOT_PATH])) previousExpandedPathsRef.current = new Set([FILE_TREE_ROOT_PATH]) setGitignoreIgnoredPaths(new Set()) setExternalConflictPrompt(null) setSavingExternalConflictCopy(false) lazyLoadedChildrenByPathRef.current.clear() lazyLoadingDirPathsRef.current.clear() externalConflictSignatureByPathRef.current.clear() previousWorkspaceSeqRef.current = 0 }, [folder?.path]) // Handle pending reveal path: expand all ancestor directories once tree is loaded const hasNodes = nodes.length > 0 useEffect(() => { if (!pendingRevealPath || !hasNodes) return consumePendingRevealPath() setExpandedPaths((prev) => { const next = new Set(prev) next.add(FILE_TREE_ROOT_PATH) let idx = pendingRevealPath.indexOf("/") while (idx !== -1) { next.add(pendingRevealPath.slice(0, idx)) idx = pendingRevealPath.indexOf("/", idx + 1) } next.add(pendingRevealPath) return next }) }, [pendingRevealPath, consumePendingRevealPath, hasNodes]) useEffect(() => { if (!activeFileTab || activeFileTab.kind !== "file") return if (!activeFileTab.path) return if (activeFileTab.loading || activeFileTab.isDirty) return const activeFilePath = activeFileTab.path externalConflictSignatureByPathRef.current.delete(activeFilePath) setExternalConflictPrompt((current) => current && normalizeComparePath(current.path) === normalizeComparePath(activeFilePath) ? null : current ) }, [activeFileTab]) const activeSessionTabId = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) if (!activeTab) return null if (activeTab.kind !== "conversation") { return null } return activeTab.id }, [tabs, activeTabId]) const fetchTree = useCallback( async (options?: { skipTree?: boolean skipStatus?: boolean silent?: boolean maxDepth?: number }) => { void options if (!folder?.path) { setNodes([]) setGitStatusByPath(new Map()) setGitEnabled(false) setLoading(false) setError(null) return } // Drop the lazy-load override cache so the fresh snapshot is not // masked by stale children (e.g. after deletes / renames / rollbacks // or files the agent just created). Reading expanded paths via a ref // keeps fetchTree's identity stable across expand/collapse so // downstream memoization is not invalidated on every tree interaction. const pathsToReload = Array.from(expandedPathsRef.current).filter( (path) => path !== FILE_TREE_ROOT_PATH ) lazyLoadedChildrenByPathRef.current.clear() await workspaceState.requestResync("manual_refresh") // Re-hydrate children for directories beyond WORKSPACE_TREE_MAX_DEPTH // that are still expanded — the backend snapshot does not include them. const loader = loadDirectoryChildrenRef.current if (loader) { for (const path of pathsToReload) { void loader(path) } } }, [folder?.path, workspaceState] ) // Tree updates are the only source that should cause a full setNodes. // applyLazyTreeOverrides rebuilds every directory node object, which forces // React to re-render the entire tree. Keeping this effect narrow avoids // wasted work on health / seq / error / git transitions that don't touch // the tree shape (e.g. the intermediate "resyncing" patch during a refresh). useEffect(() => { workspaceTreeRef.current = workspaceState.tree setNodes( applyLazyTreeOverrides( workspaceState.tree, lazyLoadedChildrenByPathRef.current ) ) }, [folder?.path, workspaceState.tree]) useEffect(() => { const nextStatusByPath = new Map() for (const entry of workspaceState.git) { nextStatusByPath.set(entry.path, entry.status) } setGitStatusByPath(nextStatusByPath) setGitEnabled(true) }, [workspaceState.git]) useEffect(() => { setLoading( workspaceState.health === "resyncing" && workspaceState.seq === 0 ) setError(workspaceState.health === "degraded" ? workspaceState.error : null) }, [workspaceState.error, workspaceState.health, workspaceState.seq]) const loadDirectoryChildren = useCallback( async (dirPath: string) => { const rootPath = folder?.path if (!rootPath) return const normalizedDirPath = normalizeComparePath(dirPath) if (!normalizedDirPath) return if (lazyLoadedChildrenByPathRef.current.has(normalizedDirPath)) return if (lazyLoadingDirPathsRef.current.has(normalizedDirPath)) return // Check the backend tree (source of truth), not the rendered `nodes`. // `nodes` carries stale lazy-cache overrides that don't invalidate // until a tree_replace delta arrives — but for directories beyond // WORKSPACE_TREE_MAX_DEPTH the backend never emits tree_replace for // changes inside them (their children are not in tree_snapshot, so // the refreshed tree compares equal to the old one). Checking // `nodes` would cause fetchTree's forced reload to short-circuit on // the stale override and miss deletions / creations in deep dirs. const existingChildren = findDirectoryChildren( workspaceTreeRef.current, normalizedDirPath ) if (existingChildren && existingChildren.length > 0) { return } lazyLoadingDirPathsRef.current.add(normalizedDirPath) try { const subtree = await getFileTree( joinFsPath(rootPath, normalizedDirPath), 1 ) const prefixed = prefixFileTreeNodePaths(subtree, normalizedDirPath) lazyLoadedChildrenByPathRef.current.set(normalizedDirPath, prefixed) setNodes((prev) => applyLazyTreeOverrides(prev, lazyLoadedChildrenByPathRef.current) ) } catch { // Ignore lazy load failures and keep current collapsed/empty state. } finally { lazyLoadingDirPathsRef.current.delete(normalizedDirPath) } }, [folder?.path] ) useEffect(() => { loadDirectoryChildrenRef.current = loadDirectoryChildren }, [loadDirectoryChildren]) useEffect(() => { expandedPathsRef.current = expandedPaths }, [expandedPaths]) // Subscribe to workspace envelopes to invalidate lazy-loaded overrides for // directories beyond WORKSPACE_TREE_MAX_DEPTH. Those directories are never // reflected in the backend's depth-2 tree_snapshot, so changes inside them // don't emit a tree_replace delta — the frontend has to target invalidation // by matching each `changed_paths` entry against its cached ancestors. // The backend already debounces raw FS events (1s / 3s max), so we only // need a microtask hop here to merge paths that hit the same cached // ancestor within one envelope (or any synchronous burst of envelopes). const subscribeWorkspaceEnvelopes = workspaceState.subscribeEnvelopes useEffect(() => { if (!subscribeWorkspaceEnvelopes) return const pendingPaths = new Set() let flushScheduled = false let disposed = false const flushPending = () => { flushScheduled = false if (disposed || pendingPaths.size === 0) return const paths = Array.from(pendingPaths) pendingPaths.clear() const loader = loadDirectoryChildrenRef.current const cache = lazyLoadedChildrenByPathRef.current const invalidated = new Set() for (const changed of paths) { const normalized = normalizeComparePath(changed) if (!normalized) continue // When the changed path is itself a cached directory (FS events // that report the directory directly, e.g. a rename or a dir-level // notification), its own entry is stale — invalidate it. if (cache.has(normalized)) { invalidated.add(normalized) } // Independently of the above, walk up to the nearest cached // ancestor: the ancestor's children listing may also be stale // (a child was added, removed, or renamed). Without this, cases // where both a parent and child are cached leave the parent // holding a ghost reference to the old child. let cursor = normalized while (cursor.length > 0) { const slash = cursor.lastIndexOf("/") const parent = slash === -1 ? "" : cursor.slice(0, slash) if (parent.length === 0) break if (cache.has(parent)) { invalidated.add(parent) break } cursor = parent } } if (invalidated.size === 0) return for (const path of invalidated) { cache.delete(path) } if (!loader) return // Skip refetching directories that are no longer expanded — their // cleared cache will be re-hydrated on the next expansion via the // expandedPaths effect. This avoids spurious getFileTree traffic // for collapsed branches under bursty FS activity. const expanded = expandedPathsRef.current for (const path of invalidated) { if (!expanded.has(path)) continue void loader(path) } } const unsubscribe = subscribeWorkspaceEnvelopes(({ changed_paths }) => { if (!changed_paths || changed_paths.length === 0) return for (const path of changed_paths) { pendingPaths.add(path) } if (flushScheduled) return flushScheduled = true queueMicrotask(flushPending) }) return () => { disposed = true unsubscribe() pendingPaths.clear() } }, [subscribeWorkspaceEnvelopes]) useEffect(() => { const previousExpanded = previousExpandedPathsRef.current for (const path of expandedPaths) { if (path === FILE_TREE_ROOT_PATH) continue if (previousExpanded.has(path)) continue void loadDirectoryChildren(path) } previousExpandedPathsRef.current = new Set(expandedPaths) }, [expandedPaths, folder?.path, loadDirectoryChildren]) const filePathSet = useMemo(() => { const paths = new Set() const collect = (items: FileTreeNode[]) => { for (const item of items) { if (item.kind === "file") { paths.add(item.path) } else { collect(item.children) } } } collect(nodes) return paths }, [nodes]) const dirChildrenByPath = useMemo(() => { const next = new Map() next.set("", nodes) const collect = (items: FileTreeNode[]) => { for (const item of items) { if (item.kind !== "dir") continue next.set(item.path, item.children) collect(item.children) } } collect(nodes) return next }, [nodes]) const expandedDirPaths = useMemo(() => { const dirs = new Set([""]) for (const path of expandedPaths) { if (path === FILE_TREE_ROOT_PATH) continue dirs.add(path) } return Array.from(dirs) }, [expandedPaths]) useEffect(() => { filePathSetRef.current = filePathSet }, [filePathSet]) useEffect(() => { if (!folder?.path) { setGitignoreIgnoredPaths(new Set()) return } let canceled = false const loadIgnoredPaths = async () => { const nextIgnoredPaths = new Set() const sortedDirs = [...expandedDirPaths].sort( (left, right) => left.length - right.length ) for (const dirPath of sortedDirs) { if (hasIgnoredAncestor(dirPath, nextIgnoredPaths)) continue const children = dirChildrenByPath.get(dirPath) if (!children || children.length === 0) continue const gitignoreNode = children.find( (child) => child.kind === "file" && child.name === ".gitignore" ) if (!gitignoreNode || gitignoreNode.kind !== "file") continue try { const result = await readFilePreview(folder.path, gitignoreNode.path) const matcher = ignore().add(result.content) // Collect all descendant nodes so multi-level patterns like // "public/vs" can be matched using relative paths. const descendants: FileTreeNode[] = [] const collectDescendants = (parent: string) => { const items = dirChildrenByPath.get(parent) if (!items) return for (const item of items) { descendants.push(item) if (item.kind === "dir") collectDescendants(item.path) } } collectDescendants(dirPath) for (const desc of descendants) { if (hasIgnoredAncestor(desc.path, nextIgnoredPaths)) continue const relativePath = dirPath === "" ? desc.path : desc.path.slice(dirPath.length + 1) if (!relativePath) continue const ignored = desc.kind === "dir" ? matcher.ignores(`${relativePath}/`) || matcher.ignores(`${relativePath}/.codeg-ignore-probe`) : matcher.ignores(relativePath) if (ignored) { nextIgnoredPaths.add(desc.path) } } } catch { // Ignore parser/read failures for non-critical visual hints. } } if (!canceled) { setGitignoreIgnoredPaths(nextIgnoredPaths) } } void loadIgnoredPaths() return () => { canceled = true } }, [dirChildrenByPath, expandedDirPaths, folder?.path]) const gitChangedDirPaths = useMemo(() => { const dirs = new Set() for (const filePath of gitStatusByPath.keys()) { let current = filePath // Walk up the path collecting all parent directories while (true) { const slashIdx = current.lastIndexOf("/") const backslashIdx = current.lastIndexOf("\\") const splitIdx = Math.max(slashIdx, backslashIdx) if (splitIdx <= 0) break current = current.slice(0, splitIdx) dirs.add(current) } } return dirs }, [gitStatusByPath]) // Directories that are entirely untracked (from git status -unormal) const untrackedDirPaths = useMemo(() => { const dirs = new Set() for (const [path, status] of gitStatusByPath.entries()) { if (status.trim() === "??") { // Check if this path is a directory in the file tree if (dirChildrenByPath.has(path)) { dirs.add(path) } } } return dirs }, [gitStatusByPath, dirChildrenByPath]) const handleTreeSelect = useCallback( (path: string) => { if (!filePathSet.has(path)) return void openFilePreview(path) }, [filePathSet, openFilePreview] ) const handleOpenDirInTerminal = useCallback( async (dirPath: string, fileName: string) => { const terminalTitle = t("terminalTitle", { name: baseName(fileName) }) const terminalId = await createTerminalInDirectory(dirPath, terminalTitle) if (!terminalId) { toast.error(t("toasts.openBuiltinTerminalFailed")) } }, [createTerminalInDirectory, t] ) const handleOpenCommitWindow = useCallback(() => { if (!folder) return openCommitWindow(folder.id).catch((error) => { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.openCommitWindowFailed"), { description: message, }) }) }, [folder, t]) const handleRequestCreate = useCallback( (parentPath: string, kind: "file" | "dir") => { setCreateParentPath(parentPath) setCreateKind(kind) setCreateName("") }, [] ) const handleRequestRename = useCallback((target: FileActionTarget) => { setRenameTarget(target) setRenameValue(target.name) }, []) const handleRequestDelete = useCallback((target: FileActionTarget) => { setDeleteTarget(target) }, []) const resetDirectoryGitActionDialog = useCallback(() => { setDirectoryGitActionType(null) setDirectoryGitActionTarget(null) setDirectoryGitCandidates([]) setDirectoryGitSelectedPaths(new Set()) setDirectoryGitExpandedPaths(new Set([DIRECTORY_GIT_TREE_ROOT_PATH])) setDirectoryGitError(null) setDirectoryGitLoading(false) setDirectoryGitSubmitting(false) }, []) const openDirectoryGitActionDialog = useCallback( async (action: DirectoryGitAction, target: FileActionTarget) => { if (!folder?.path) return setDirectoryGitActionType(action) setDirectoryGitActionTarget(target) setDirectoryGitCandidates([]) setDirectoryGitSelectedPaths(new Set()) setDirectoryGitExpandedPaths(new Set([DIRECTORY_GIT_TREE_ROOT_PATH])) setDirectoryGitError(null) setDirectoryGitLoading(true) try { const statusEntries = await gitStatus(folder.path) const scopedEntries = scopeGitStatusEntriesForDirectory( statusEntries, target.path ) const candidates = filterDirectoryGitCandidates(scopedEntries, action) if (candidates.length === 0) { resetDirectoryGitActionDialog() toast.info( action === "add" ? t("toasts.noAddableFilesInDir") : t("toasts.noRollbackFilesInDir") ) return } const treeNodes = buildDirectoryGitTree(candidates, target.path) const expanded = collectDirectoryGitTreeExpandedPaths(treeNodes) expanded.add(DIRECTORY_GIT_TREE_ROOT_PATH) setDirectoryGitCandidates(candidates) setDirectoryGitSelectedPaths( new Set(candidates.map((entry) => entry.path)) ) setDirectoryGitExpandedPaths(expanded) } catch (error) { const message = error instanceof Error ? error.message : String(error) setDirectoryGitError(message) } finally { setDirectoryGitLoading(false) } }, [folder?.path, resetDirectoryGitActionDialog, t] ) const handleRequestRollback = useCallback( (target: FileActionTarget) => { if (target.kind === "dir") { void openDirectoryGitActionDialog("rollback", target) return } setRollbackTarget(target) }, [openDirectoryGitActionDialog] ) const handleAddToVcs = useCallback( async (target: FileActionTarget) => { if (target.kind === "dir") { await openDirectoryGitActionDialog("add", target) return } if (!folder?.path) return try { await gitAddFiles(folder.path, [target.path]) toast.success(t("toasts.addedToVcs", { name: target.name })) await fetchTree() } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.addToVcsFailed"), { description: message }) } }, [fetchTree, folder?.path, openDirectoryGitActionDialog, t] ) const loadCompareBranches = useCallback(async () => { if (!folder?.path) { setCompareBranchList({ local: [], remote: [], worktree_branches: [] }) setCompareCurrentBranch(null) return } setCompareBranchLoading(true) try { const [branchesResult, currentBranchResult] = await Promise.allSettled([ gitListAllBranches(folder.path), getGitBranch(folder.path), ]) if (branchesResult.status === "fulfilled") { setCompareBranchList(branchesResult.value) } else { setCompareBranchList({ local: [], remote: [], worktree_branches: [] }) const message = branchesResult.reason instanceof Error ? branchesResult.reason.message : String(branchesResult.reason) toast.error(t("toasts.loadBranchesFailed"), { description: message }) } if (currentBranchResult.status === "fulfilled") { setCompareCurrentBranch(currentBranchResult.value) } else { setCompareCurrentBranch(null) } } catch (error) { setCompareBranchList({ local: [], remote: [], worktree_branches: [] }) setCompareCurrentBranch(null) const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.loadBranchesFailed"), { description: message }) } finally { setCompareBranchLoading(false) } }, [folder?.path, t]) const handleRequestCompareWithBranch = useCallback( (target: FileActionTarget) => { setCompareTarget(target) setCompareBranchFilter("") setCompareRecentOpen(true) setCompareLocalOpen(false) setCompareRemoteOpen(false) void loadCompareBranches() }, [loadCompareBranches] ) const compareFilterKeyword = useMemo( () => compareBranchFilter.trim().toLowerCase(), [compareBranchFilter] ) const filteredCompareRecentBranches = useMemo(() => { if (!compareCurrentBranch) return [] if (!compareFilterKeyword) return [compareCurrentBranch] return compareCurrentBranch.toLowerCase().includes(compareFilterKeyword) ? [compareCurrentBranch] : [] }, [compareCurrentBranch, compareFilterKeyword]) const filteredCompareBranches = useMemo(() => { if (!compareFilterKeyword) { return compareBranchList } return { local: compareBranchList.local.filter((branch) => branch.toLowerCase().includes(compareFilterKeyword) ), remote: compareBranchList.remote.filter((branch) => branch.toLowerCase().includes(compareFilterKeyword) ), } }, [compareBranchList, compareFilterKeyword]) const groupedCompareRemoteBranches = useMemo(() => { const groups: Record = {} for (const b of filteredCompareBranches.remote) { const slashIndex = b.indexOf("/") const remoteName = slashIndex > 0 ? b.substring(0, slashIndex) : "origin" if (!groups[remoteName]) groups[remoteName] = [] groups[remoteName].push(b) } return groups }, [filteredCompareBranches.remote]) const compareRemoteNames = Object.keys(groupedCompareRemoteBranches) const hasMultipleCompareRemotes = compareRemoteNames.length > 1 const directoryGitTreeNodes = useMemo(() => { if (!directoryGitActionTarget) return [] return buildDirectoryGitTree( directoryGitCandidates, directoryGitActionTarget.path ) }, [directoryGitActionTarget, directoryGitCandidates]) const directoryGitAllFilePaths = useMemo( () => directoryGitCandidates.map((entry) => entry.path), [directoryGitCandidates] ) const directoryGitAllSelected = useMemo( () => directoryGitAllFilePaths.length > 0 && directoryGitAllFilePaths.every((path) => directoryGitSelectedPaths.has(path) ), [directoryGitAllFilePaths, directoryGitSelectedPaths] ) const directoryGitFilePathSet = useMemo( () => new Set(directoryGitAllFilePaths), [directoryGitAllFilePaths] ) const directoryGitLeafPathsByDirPath = useMemo(() => { const next = new Map() const collect = (node: DirectoryGitTreeNode) => { if (node.kind === "file") return next.set(node.path, collectDirectoryGitTreeLeafPaths(node)) for (const child of node.children) { if (child.kind === "dir") collect(child) } } for (const node of directoryGitTreeNodes) { if (node.kind === "dir") collect(node) } return next }, [directoryGitTreeNodes]) const handleToggleDirectoryGitFile = useCallback((path: string) => { setDirectoryGitSelectedPaths((prev) => { const next = new Set(prev) if (next.has(path)) { next.delete(path) } else { next.add(path) } return next }) }, []) const handleToggleDirectoryGitSelectAll = useCallback(() => { setDirectoryGitSelectedPaths((prev) => { if ( directoryGitAllFilePaths.length > 0 && directoryGitAllFilePaths.every((path) => prev.has(path)) ) { return new Set() } return new Set(directoryGitAllFilePaths) }) }, [directoryGitAllFilePaths]) const handleToggleDirectoryGitDir = useCallback( (dirPath: string) => { const leafPaths = directoryGitLeafPathsByDirPath.get(dirPath) ?? [] if (leafPaths.length === 0) return setDirectoryGitSelectedPaths((prev) => { const next = new Set(prev) const allSelected = leafPaths.every((path) => next.has(path)) if (allSelected) { for (const path of leafPaths) next.delete(path) } else { for (const path of leafPaths) next.add(path) } return next }) }, [directoryGitLeafPathsByDirPath] ) const handleDirectoryGitTreeSelect = useCallback( (path: string) => { if (path === DIRECTORY_GIT_TREE_ROOT_PATH) { handleToggleDirectoryGitSelectAll() return } if (directoryGitLeafPathsByDirPath.has(path)) { handleToggleDirectoryGitDir(path) return } if (directoryGitFilePathSet.has(path)) { handleToggleDirectoryGitFile(path) } }, [ directoryGitFilePathSet, directoryGitLeafPathsByDirPath, handleToggleDirectoryGitDir, handleToggleDirectoryGitFile, handleToggleDirectoryGitSelectAll, ] ) const renderDirectoryGitTreeNode = useCallback( (node: DirectoryGitTreeNode): ReactNode => { if (node.kind === "dir") { const leafPaths = directoryGitLeafPathsByDirPath.get(node.path) ?? [] const allSelected = leafPaths.length > 0 && leafPaths.every((path) => directoryGitSelectedPaths.has(path)) const partiallySelected = !allSelected && leafPaths.some((path) => directoryGitSelectedPaths.has(path)) return ( {node.children.map(renderDirectoryGitTreeNode)} ) } const selected = directoryGitSelectedPaths.has(node.path) return ( {node.status} ) }, [ directoryGitLeafPathsByDirPath, directoryGitSelectedPaths, directoryGitSubmitting, handleToggleDirectoryGitFile, t, ] ) const handleCreateConfirm = useCallback(async () => { if (!folder?.path || createParentPath === null) return const trimmedName = createName.trim() if (!trimmedName) { setCreateParentPath(null) return } setCreating(true) try { await createFileTreeEntry( folder.path, createParentPath, trimmedName, createKind ) setCreateParentPath(null) setCreateName("") await fetchTree() } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.createFailed"), { description: message }) } finally { setCreating(false) } }, [createKind, createName, createParentPath, fetchTree, folder?.path, t]) const handleRenameConfirm = useCallback(async () => { if (!folder?.path || !renameTarget) return const nextName = renameValue.trim() if (!nextName || nextName === renameTarget.name) { setRenameTarget(null) return } setRenaming(true) try { await renameFileTreeEntry(folder.path, renameTarget.path, nextName) setRenameTarget(null) setRenameValue("") await fetchTree() } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.renameFailed"), { description: message }) } finally { setRenaming(false) } }, [fetchTree, folder?.path, renameTarget, renameValue, t]) const handleDeleteConfirm = useCallback(async () => { if (!folder?.path || !deleteTarget) return setDeleting(true) try { await deleteFileTreeEntry(folder.path, deleteTarget.path) setDeleteTarget(null) await fetchTree() } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.deleteFailed"), { description: message }) } finally { setDeleting(false) } }, [deleteTarget, fetchTree, folder?.path, t]) const handleRollbackConfirm = useCallback(async () => { if (!folder?.path || !rollbackTarget) return setRollingBack(true) try { await gitRollbackFile(folder.path, rollbackTarget.path) toast.success(t("toasts.rolledBack", { name: rollbackTarget.name })) setRollbackTarget(null) await fetchTree() } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.rollbackFailed"), { description: message }) } finally { setRollingBack(false) } }, [fetchTree, folder?.path, rollbackTarget, t]) const handleDirectoryGitActionConfirm = useCallback(async () => { if (!folder?.path || !directoryGitActionType) return if (directoryGitSelectedPaths.size === 0) return const selectedPaths = Array.from(directoryGitSelectedPaths) setDirectoryGitSubmitting(true) setDirectoryGitError(null) try { if (directoryGitActionType === "add") { await gitAddFiles(folder.path, selectedPaths) toast.success( t("toasts.addedFilesToVcs", { count: selectedPaths.length, }) ) } else { for (const filePath of selectedPaths) { await gitRollbackFile(folder.path, filePath) } toast.success( t("toasts.rolledBackFiles", { count: selectedPaths.length, }) ) } resetDirectoryGitActionDialog() await fetchTree() } catch (error) { const message = error instanceof Error ? error.message : String(error) setDirectoryGitError(message) toast.error( directoryGitActionType === "add" ? t("toasts.addToVcsFailed") : t("toasts.rollbackFailed"), { description: message, } ) } finally { setDirectoryGitSubmitting(false) } }, [ directoryGitActionType, directoryGitSelectedPaths, fetchTree, folder?.path, resetDirectoryGitActionDialog, t, ]) const handleCompareBranchClick = useCallback( async (branch: string) => { const nextBranch = branch.trim() if (!compareTarget || !nextBranch || comparing) return setComparing(true) try { if (compareTarget.kind === "dir") { await openBranchDiff(nextBranch, compareTarget.path, { mode: "overview", }) } else { await openBranchDiff(nextBranch, compareTarget.path) } setCompareTarget(null) setCompareBranchFilter("") setCompareCurrentBranch(null) } finally { setComparing(false) } }, [compareTarget, comparing, openBranchDiff] ) const handleCompareExternalConflict = useCallback(() => { if (!externalConflictPrompt) return const latestTab = activeFileTabRef.current const unsavedContent = latestTab && latestTab.kind === "file" && latestTab.path && normalizeComparePath(latestTab.path) === normalizeComparePath(externalConflictPrompt.path) && !latestTab.loading ? latestTab.content : externalConflictPrompt.unsavedContent openExternalConflictDiff( externalConflictPrompt.path, externalConflictPrompt.diskContent, unsavedContent ) setExternalConflictPrompt(null) }, [externalConflictPrompt, openExternalConflictDiff]) const handleReloadExternalConflict = useCallback(() => { if (!externalConflictPrompt) return externalConflictSignatureByPathRef.current.delete( externalConflictPrompt.path ) setExternalConflictPrompt(null) void openFilePreview(externalConflictPrompt.path) }, [externalConflictPrompt, openFilePreview]) const handleSaveExternalConflictCopy = useCallback(async () => { if (!folder?.path || !externalConflictPrompt) return const latestTab = activeFileTabRef.current const unsavedContent = latestTab && latestTab.kind === "file" && latestTab.path && normalizeComparePath(latestTab.path) === normalizeComparePath(externalConflictPrompt.path) && !latestTab.loading ? latestTab.content : externalConflictPrompt.unsavedContent setSavingExternalConflictCopy(true) try { const result = await saveFileCopy( folder.path, externalConflictPrompt.path, unsavedContent ) toast.success(t("toasts.savedAsCopy"), { description: result.path, }) setExternalConflictPrompt(null) void fetchTree({ silent: true }) } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.saveCopyFailed"), { description: message }) } finally { setSavingExternalConflictCopy(false) } }, [externalConflictPrompt, fetchTree, folder?.path, t]) const rootNodeName = useMemo(() => { if (!folder?.path) return t("workspace") return baseName(folder.path) }, [folder?.path, t]) const systemExplorerLabel = typeof navigator === "undefined" ? t("openInFileManager") : (() => { const platform = `${navigator.platform} ${navigator.userAgent}`.toLowerCase() if (platform.includes("mac")) return t("openInFinder") if (platform.includes("win")) return t("openInExplorer") return t("openInFileManager") })() const rootTarget: FileActionTarget = useMemo( () => ({ kind: "dir", path: "", name: rootNodeName }), [rootNodeName] ) type ActiveFileChangeDecision = | { kind: "none" } | { kind: "reload"; path: string } | { kind: "conflict" path: string diskContent: string unsavedContent: string signature: string } const resolveActiveFileChangeDecision = useCallback( async (path: string): Promise => { const rootPath = folder?.path if (!rootPath) return { kind: "none" } const currentTab = activeFileTabRef.current if (!currentTab || currentTab.kind !== "file") return { kind: "none" } if ( normalizeComparePath(currentTab.path ?? "") !== normalizeComparePath(path) ) { return { kind: "none" } } if (currentTab.loading) return { kind: "none" } const knownTabEtag = currentTab.etag ?? null try { const latest = await readFileForEdit(rootPath, path) const latestTab = activeFileTabRef.current if (!latestTab || latestTab.kind !== "file") return { kind: "none" } if ( normalizeComparePath(latestTab.path ?? "") !== normalizeComparePath(path) ) { return { kind: "none" } } if (latestTab.loading) return { kind: "none" } const latestTabEtag = latestTab.etag ?? null if (latest.etag === latestTabEtag) return { kind: "none" } if (latestTab.isDirty) { return { kind: "conflict", path, diskContent: latest.content, unsavedContent: latestTab.content, signature: `${path}:${latest.etag}`, } } return { kind: "reload", path } } catch { const latestTab = activeFileTabRef.current if (!latestTab || latestTab.kind !== "file") return { kind: "none" } if ( normalizeComparePath(latestTab.path ?? "") !== normalizeComparePath(path) ) { return { kind: "none" } } if (latestTab.loading) return { kind: "none" } if (latestTab.isDirty) return { kind: "none" } if (!knownTabEtag) return { kind: "reload", path } return { kind: "reload", path } } }, [folder?.path] ) useEffect(() => { const rootPath = folder?.path if (!rootPath) return const nextSeq = workspaceState.seq if (nextSeq <= previousWorkspaceSeqRef.current) return previousWorkspaceSeqRef.current = nextSeq const currentTab = activeFileTabRef.current if (!currentTab || currentTab.kind !== "file") return if (!currentTab.path || currentTab.loading) return const activePath = currentTab.path void (async () => { const decision = await resolveActiveFileChangeDecision(activePath) if (decision.kind === "none") return if (decision.kind === "reload") { externalConflictSignatureByPathRef.current.delete(decision.path) void openFilePreview(decision.path) return } const shownSignature = externalConflictSignatureByPathRef.current.get( decision.path ) if (shownSignature === decision.signature) return externalConflictSignatureByPathRef.current.set( decision.path, decision.signature ) setExternalConflictPrompt((current) => { if (current?.signature === decision.signature) return current return { path: decision.path, diskContent: decision.diskContent, unsavedContent: decision.unsavedContent, signature: decision.signature, } }) })() }, [ folder?.path, openFilePreview, resolveActiveFileChangeDecision, workspaceState.seq, ]) if (loading && nodes.length === 0) { return (
) } if (error) { return (

{error}

) } return (
{workspaceState.degraded && ( )} {folder?.path && ( {nodes.map((node) => ( { void openFilePreview(path) }} onOpenFileDiff={(path) => { void openWorkingTreeDiff(path) }} onOpenDirDiff={(path) => { void openWorkingTreeDiff(path, { mode: "overview", }) }} onOpenCommitWindow={handleOpenCommitWindow} onRequestCompareWithBranch={ handleRequestCompareWithBranch } onRequestRollback={handleRequestRollback} onOpenDirInTerminal={handleOpenDirInTerminal} onRequestCreate={handleRequestCreate} onRequestAddToVcs={handleAddToVcs} onRequestRename={handleRequestRename} onRequestDelete={handleRequestDelete} onRefresh={fetchTree} /> ))} {t("new")} handleRequestCreate("", "file")} > {t("newFile")} handleRequestCreate("", "dir")} > {t("newDirectory")} {t("git")} handleOpenCommitWindow()} disabled={!gitEnabled} > {t("actions.commitCode")} void handleAddToVcs(rootTarget)} disabled={!gitEnabled} > {t("actions.addToVcs")} void openWorkingTreeDiff(".", { mode: "overview", }) } disabled={!gitEnabled} > {tCommon("viewDiff")} handleRequestCompareWithBranch(rootTarget) } disabled={!gitEnabled} > {t("compareWithBranch")} handleRequestRollback(rootTarget)} disabled={!gitEnabled} > {t("actions.rollback")} { void fetchTree() }} > {t("reloadFromDisk")} {t("openIn")} { void revealItemInDir(folder.path) }} > {systemExplorerLabel} { void handleOpenDirInTerminal( folder.path, rootNodeName ) }} > {t("openInTerminal")} )} {t("new")} handleRequestCreate("", "file")}> {t("newFile")} handleRequestCreate("", "dir")}> {t("newDirectory")} { void fetchTree() }} > {t("reloadFromDisk")} { if (open) return setCreateParentPath(null) setCreateName("") }} > { e.preventDefault() const input = ( e.currentTarget as HTMLElement | null )?.querySelector("input") if (input) requestAnimationFrame(() => input.focus()) }} > {createKind === "dir" ? t("createDialog.newDirectory") : t("createDialog.newFile")} {t("createDialog.description", { kind: createKind === "dir" ? t("newDirectory").toLowerCase() : t("newFile").toLowerCase(), })}
{ event.preventDefault() void handleCreateConfirm() }} className="space-y-4" > setCreateName(event.target.value)} disabled={creating} placeholder={ createKind === "dir" ? t("createDialog.placeholderDirectory") : t("createDialog.placeholderFile") } />
{ if (open) return setRenameTarget(null) setRenameValue("") }} > { e.preventDefault() const input = ( e.currentTarget as HTMLElement | null )?.querySelector("input") if (input) requestAnimationFrame(() => input.focus()) }} > {renameTarget?.kind === "dir" ? t("renameDialog.renameDirectory") : t("renameDialog.renameFile")} {t("renameDialog.description")}
{ event.preventDefault() void handleRenameConfirm() }} className="space-y-4" > setRenameValue(event.target.value)} disabled={renaming} placeholder={ renameTarget?.kind === "dir" ? t("renameDialog.placeholderDirectory") : t("renameDialog.placeholderFile") } />
{ if (open) return resetDirectoryGitActionDialog() }} > {directoryGitActionType === "add" ? t("actions.addToVcs") : t("actions.rollback")} {directoryGitActionTarget ? directoryGitActionType === "add" ? t("directoryDialog.descriptionAdd", { path: directoryGitActionTarget.path, }) : t("directoryDialog.descriptionRollback", { path: directoryGitActionTarget.path, }) : t("directoryDialog.descriptionFallback")}
{t("directoryDialog.selectionCount", { selected: directoryGitSelectedPaths.size, total: directoryGitAllFilePaths.length, })}
{directoryGitLoading ? (
{t("directoryDialog.loadingCandidates")}
) : directoryGitError ? (
{directoryGitError}
) : directoryGitTreeNodes.length > 0 && directoryGitActionTarget ? ( {directoryGitTreeNodes.map(renderDirectoryGitTreeNode)} ) : (
{t("directoryDialog.noOperableFiles")}
)}
{ if (open) return setCompareTarget(null) setCompareBranchFilter("") setCompareCurrentBranch(null) }} > {t("compareDialog.title")} {compareTarget ? t("compareDialog.descriptionWithTarget", { kind: compareTarget.kind === "dir" ? t("compareDialog.kindDirectory") : t("compareDialog.kindFile"), path: compareTarget.path, }) : t("compareDialog.descriptionFallback")}
setCompareBranchFilter(event.target.value)} placeholder={t("compareDialog.filterPlaceholder")} autoFocus disabled={comparing} />
{t("compareDialog.singleClickHint")}
{compareBranchLoading ? (
{t("compareDialog.loadingBranches")}
) : ( <> {t("compareDialog.recentBranches", { count: filteredCompareRecentBranches.length, })} {filteredCompareRecentBranches.length > 0 ? ( filteredCompareRecentBranches.map((branch) => ( )) ) : (
{t("compareDialog.noCurrentBranch")}
)}
{t("compareDialog.localBranches", { count: filteredCompareBranches.local.length, })} {filteredCompareBranches.local.length > 0 ? ( filteredCompareBranches.local.map((branch) => ( )) ) : (
{t("compareDialog.noMatchingBranches")}
)}
{t("compareDialog.remoteBranches", { count: filteredCompareBranches.remote.length, })} {filteredCompareBranches.remote.length > 0 ? ( hasMultipleCompareRemotes ? ( compareRemoteNames.map((remoteName) => ( {remoteName} ( { groupedCompareRemoteBranches[remoteName] .length } ) {groupedCompareRemoteBranches[remoteName].map( (branch) => ( ) )} )) ) : ( filteredCompareBranches.remote.map((branch) => { const slashIndex = branch.indexOf("/") const shortName = slashIndex > 0 ? branch.substring(slashIndex + 1) : branch return ( ) }) ) ) : (
{t("compareDialog.noMatchingBranches")}
)}
)}
{ if (open) return setExternalConflictPrompt(null) }} > {t("externalConflictDialog.title")} {externalConflictPrompt ? t("externalConflictDialog.descriptionWithPath", { path: externalConflictPrompt.path, }) : t("externalConflictDialog.descriptionFallback")} { if (open) return setDeleteTarget(null) }} > {t("deleteConfirm.title")} {deleteTarget ? t("deleteConfirm.descriptionWithTarget", { kind: deleteTarget.kind === "dir" ? t("deleteConfirm.kindDirectory") : t("deleteConfirm.kindFile"), name: deleteTarget.name, }) : t("deleteConfirm.descriptionFallback")} {tCommon("cancel")} { void handleDeleteConfirm() }} > {tCommon("delete")} { if (open) return setRollbackTarget(null) }} > {t("rollbackConfirm.title")} {rollbackTarget ? t("rollbackConfirm.descriptionWithTarget", { name: rollbackTarget.name, }) : t("rollbackConfirm.descriptionFallback")} {tCommon("cancel")} { void handleRollbackConfirm() }} > {t("actions.rollback")}
) }