/** * FileBrowser — markdown/text file tree for the sidebar * * Displays collapsible trees of markdown/text files from user-configured directories. * Clicking a file opens it in the main viewer for annotation. */ import React from "react"; import { Search, X } from "lucide-react"; import type { VaultNode } from "../../types"; import type { DirState } from "../../hooks/useFileBrowser"; import { CountBadge } from "./CountBadge"; import { ObsidianIconRaw } from "../icons/ObsidianIcons"; import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; import { normalizeBrowserPath } from "@plannotator/core/browser-paths"; interface FileBrowserProps { dirs: DirState[]; expandedFolders: Set; onToggleFolder: (key: string) => void; collapsedDirs: Set; onToggleCollapse: (dirPath: string) => void; onSelectFile: (absolutePath: string, dirPath: string) => void; activeFile: string | null; onFetchAll: () => void; onRetryVaultDir?: (vaultPath: string) => void; annotationCounts?: Map; highlightedFiles?: Set; editStatuses?: Map; /** Prevent competing destination changes while a document is activating. */ selectionPending?: boolean; } export interface FileEditStatus { key?: string; path?: string; status: "clean" | "dirty" | "saving" | "saved" | "conflict" | "error" | "missing"; dirty: boolean; conflict?: boolean; } interface AggregateWorkspaceChange { additions: number; deletions: number; files: number; } // Display-name stripping only — deliberately narrower than the annotatable // set. Config files (config.yaml vs config.json) keep their extensions so // same-named siblings stay distinguishable in the tree. const FILE_EXTENSION_RE = /\.(mdx?|txt|html?)$/i; function normalizeFilterText(value: string): string { return value.replace(/\\/g, "/").toLowerCase(); } export function getFileTreeFilterTokens(query: string): string[] { return normalizeFilterText(query) .trim() .split(/\s+/) .filter(Boolean); } function nodeMatchesFilter(node: VaultNode, tokens: string[]): boolean { if (tokens.length === 0) return true; const displayName = node.name.replace(FILE_EXTENSION_RE, ""); const haystack = normalizeFilterText(`${node.name} ${displayName} ${node.path}`); return tokens.every((token) => haystack.includes(token)); } export function filterFileTree(nodes: VaultNode[], tokens: string[]): VaultNode[] { if (tokens.length === 0) return nodes; return nodes.flatMap((node) => { if (node.type === "file") return nodeMatchesFilter(node, tokens) ? [node] : []; if (nodeMatchesFilter(node, tokens)) return [node]; const children = filterFileTree(node.children ?? [], tokens); if (children.length === 0) return []; return [{ ...node, children }]; }); } export function normalizePathForLookup(path: string): string { return normalizeBrowserPath(path); } function joinLookupPath(rootPath: string, relativePath: string): string { const root = normalizePathForLookup(rootPath); const relative = normalizePathForLookup(relativePath).replace(/^\/+/, ""); if (!relative || relative === ".") return root; if (root === "/" || /^[A-Za-z]:\/$/.test(root)) return normalizePathForLookup(`${root}${relative}`); return normalizePathForLookup(`${root}/${relative}`); } export function getPathLookupCandidates( absolutePath: string, relativePath?: string, workspaceStatus?: WorkspaceStatusPayload, ): string[] { const candidates = [absolutePath, normalizePathForLookup(absolutePath)]; if (relativePath && workspaceStatus?.rootPath) { candidates.push(joinLookupPath(workspaceStatus.rootPath, relativePath)); } const seen = new Set(); return candidates.filter((path) => { const normalized = normalizePathForLookup(path); if (seen.has(normalized)) return false; seen.add(normalized); return true; }); } function getPathMapValue(map: Map | undefined, paths: string | string[]): T | undefined { if (!map) return undefined; const candidates = Array.isArray(paths) ? paths : [paths]; const normalizedCandidates = candidates.map(normalizePathForLookup); for (let index = 0; index < candidates.length; index += 1) { const path = candidates[index]; const normalized = normalizedCandidates[index]; if (map.has(path)) return map.get(path); if (map.has(normalized)) return map.get(normalized); } for (const [path, value] of map.entries()) { if (normalizedCandidates.includes(normalizePathForLookup(path))) return value; } return undefined; } function pathSetHas(paths: Set | undefined, candidates: string | string[]): boolean { if (!paths) return false; const candidatePaths = Array.isArray(candidates) ? candidates : [candidates]; const normalizedCandidates = candidatePaths.map(normalizePathForLookup); for (let index = 0; index < candidatePaths.length; index += 1) { if (paths.has(candidatePaths[index]) || paths.has(normalizedCandidates[index])) return true; } for (const path of paths) { if (normalizedCandidates.includes(normalizePathForLookup(path))) return true; } return false; } export function getFileEditStatus( absolutePath: string, editStatuses?: Map, relativePath?: string, workspaceStatus?: WorkspaceStatusPayload, ): FileEditStatus | undefined { return getPathMapValue(editStatuses, getPathLookupCandidates(absolutePath, relativePath, workspaceStatus)); } function normalizeWorkspaceStatus( workspaceStatus?: WorkspaceStatusPayload ): WorkspaceStatusPayload | undefined { if (!workspaceStatus) return workspaceStatus; const files: WorkspaceStatusPayload["files"] = {}; for (const [path, change] of Object.entries(workspaceStatus.files ?? {})) { const normalizedPath = normalizePathForLookup(path); const normalizedOldPath = change.oldPath ? normalizePathForLookup(change.oldPath) : undefined; files[normalizedPath] = { ...change, path: normalizedPath, oldPath: normalizedOldPath, }; } return { ...workspaceStatus, rootPath: normalizePathForLookup(workspaceStatus.rootPath), files, }; } /** Recursively sum annotation counts for all descendant files of a folder node */ function getAggregateCount( node: VaultNode, dirPath: string, counts: Map, workspaceStatus?: WorkspaceStatusPayload, ): number { if (node.type === "file") { return getPathMapValue(counts, getPathLookupCandidates(`${dirPath}/${node.path}`, node.path, workspaceStatus)) ?? 0; } let total = 0; for (const child of node.children ?? []) { total += getAggregateCount(child, dirPath, counts, workspaceStatus); } return total; } export function getWorkspaceChange( absolutePath: string, workspaceStatus?: WorkspaceStatusPayload, relativePath?: string, ): WorkspaceFileChange | undefined { const files = workspaceStatus?.files; if (!files) return undefined; const candidates = getPathLookupCandidates(absolutePath, relativePath, workspaceStatus); const normalizedCandidates = candidates.map(normalizePathForLookup); for (let index = 0; index < candidates.length; index += 1) { const direct = files[candidates[index]] ?? files[normalizedCandidates[index]]; if (direct) return direct; } for (const [path, change] of Object.entries(files)) { if (normalizedCandidates.includes(normalizePathForLookup(path))) return change; } return undefined; } export function isFileTreeSelectionDisabled( workspaceChange: WorkspaceFileChange | undefined, editStatus: FileEditStatus | undefined, ): boolean { return workspaceChange?.status === "deleted" && editStatus?.status !== "missing"; } export function getAggregateWorkspaceChange( node: VaultNode, dirPath: string, workspaceStatus?: WorkspaceStatusPayload ): AggregateWorkspaceChange { if (node.type === "file") { const change = getWorkspaceChange(`${dirPath}/${node.path}`, workspaceStatus, node.path); return change ? { additions: change.additions, deletions: change.deletions, files: 1 } : { additions: 0, deletions: 0, files: 0 }; } return (node.children ?? []).reduce((total, child) => { const childTotal = getAggregateWorkspaceChange(child, dirPath, workspaceStatus); return { additions: total.additions + childTotal.additions, deletions: total.deletions + childTotal.deletions, files: total.files + childTotal.files, }; }, { additions: 0, deletions: 0, files: 0 }); } const TreeNode: React.FC<{ node: VaultNode; depth: number; dirPath: string; expandedFolders: Set; onToggleFolder: (key: string) => void; onSelectFile: (absolutePath: string, dirPath: string) => void; activeFile: string | null; annotationCounts?: Map; highlightedFiles?: Set; editStatuses?: Map; workspaceStatus?: WorkspaceStatusPayload; forceExpandFolders?: boolean; selectionPending?: boolean; }> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus, forceExpandFolders = false, selectionPending = false }) => { const folderKey = `${dirPath}:${node.path}`; const absolutePath = `${dirPath}/${node.path}`; const isExpanded = forceExpandFolders || expandedFolders.has(folderKey); const isActive = node.type === "file" && absolutePath === activeFile; const paddingLeft = 8 + depth * 14; if (node.type === "folder") { const aggregateCount = annotationCounts ? getAggregateCount(node, dirPath, annotationCounts, workspaceStatus) : 0; const aggregateChange = getAggregateWorkspaceChange(node, dirPath, workspaceStatus); const folderButtonClassName = forceExpandFolders ? "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground transition-colors rounded-sm cursor-default disabled:opacity-100" : "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors rounded-sm"; return ( <> {isExpanded && node.children?.map((child) => ( ))} ); } const displayName = node.name.replace(/\.(mdx?|txt|html?)$/i, ""); const lookupCandidates = getPathLookupCandidates(absolutePath, node.path, workspaceStatus); const fileCount = getPathMapValue(annotationCounts, lookupCandidates) ?? 0; const isHighlighted = pathSetHas(highlightedFiles, lookupCandidates); const editStatus = getFileEditStatus(absolutePath, editStatuses, node.path, workspaceStatus); const workspaceChange = getWorkspaceChange(absolutePath, workspaceStatus, node.path); const isDeleted = workspaceChange?.status === "deleted"; const isSelectionDisabled = isFileTreeSelectionDisabled(workspaceChange, editStatus); const editMarker = editStatus?.status === "conflict" || editStatus?.status === "error" ? { label: "!", className: "bg-destructive/15 text-destructive", title: editStatus.status === "conflict" ? "Save conflict" : "Save failed" } : editStatus?.status === "missing" ? { label: "!", className: "bg-warning/15 text-warning-foreground", title: "File missing on disk" } : editStatus?.status === "saving" ? { label: "...", className: "bg-primary/10 text-primary", title: "Saving" } : editStatus?.dirty ? { label: "•", className: "bg-primary/10 text-primary", title: "Unsaved edits" } : editStatus?.status === "saved" ? { label: "✓", className: "bg-success/15 text-success", title: "Saved" } : null; const statusMarker = workspaceChange?.status === "added" ? { label: "A", className: "text-success", title: "Added file" } : workspaceChange?.status === "untracked" ? { label: "U", className: "text-primary", title: "Untracked file" } : workspaceChange?.status === "deleted" ? { label: "D", className: "text-destructive", title: "Deleted file" } : workspaceChange?.status === "renamed" ? { label: "R", className: "text-[#007aff]", title: workspaceChange.oldPath ? `Renamed from ${workspaceChange.oldPath}` : "Renamed file" } : workspaceChange?.status === "conflicted" ? { label: "!", className: "text-destructive", title: "Git conflict" } : null; return ( ); }; const DirSection: React.FC<{ dir: DirState; expandedFolders: Set; onToggleFolder: (key: string) => void; onSelectFile: (absolutePath: string, dirPath: string) => void; activeFile: string | null; onRetry: () => void; annotationCounts?: Map; highlightedFiles?: Set; editStatuses?: Map; forceExpandFolders?: boolean; selectionPending?: boolean; }> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses, forceExpandFolders = false, selectionPending = false }) => { const workspaceStatus = React.useMemo(() => normalizeWorkspaceStatus(dir.workspaceStatus), [dir.workspaceStatus]); if (dir.isLoading) { return (
Loading...
); } if (dir.error) { return (
{dir.error}
); } if (dir.tree.length === 0) { return (
No annotatable files found
); } return (
{dir.tree.map((node) => ( ))}
); }; export const FileBrowser: React.FC = ({ dirs, expandedFolders, onToggleFolder, collapsedDirs, onToggleCollapse, onSelectFile, activeFile, onFetchAll, onRetryVaultDir, annotationCounts, highlightedFiles, editStatuses, selectionPending = false, }) => { const [isFilterOpen, setIsFilterOpen] = React.useState(false); const [filterQuery, setFilterQuery] = React.useState(""); const inputRef = React.useRef(null); const deferredFilterQuery = React.useDeferredValue(filterQuery); const filterTokens = React.useMemo(() => getFileTreeFilterTokens(deferredFilterQuery), [deferredFilterQuery]); const isFiltering = filterTokens.length > 0; const showFilterInput = isFilterOpen || filterQuery.trim().length > 0; const visibleDirs = React.useMemo(() => { if (!isFiltering) return dirs; return dirs .map((dir) => ({ ...dir, tree: filterFileTree(dir.tree, filterTokens) })) .filter((dir) => dir.isLoading || dir.error || dir.tree.length > 0); }, [dirs, filterTokens, isFiltering]); const handleFilterBlur = React.useCallback((event: React.FocusEvent) => { if (filterQuery.trim()) return; if (event.currentTarget.contains(event.relatedTarget)) return; setIsFilterOpen(false); }, [filterQuery]); React.useEffect(() => { if (!showFilterInput) return; inputRef.current?.focus(); }, [showFilterInput]); if (dirs.length === 0) { return (
No directories configured. Add directories in Settings → Files.
); } // Summary header const totalCount = annotationCounts ? Array.from(annotationCounts.values()).reduce((s, c) => s + c, 0) : 0; const fileCount = annotationCounts?.size ?? 0; const workspaceTotals = dirs.reduce( (total, dir) => { if (!dir.workspaceStatus?.available) return total; return { files: total.files + dir.workspaceStatus.totals.files, additions: total.additions + dir.workspaceStatus.totals.additions, deletions: total.deletions + dir.workspaceStatus.totals.deletions, }; }, { files: 0, additions: 0, deletions: 0 } ); return (
{totalCount > 0 && (
{totalCount} annotation{totalCount === 1 ? '' : 's'} in {fileCount} file{fileCount === 1 ? '' : 's'}
)} {workspaceTotals.files > 0 && (
{workspaceTotals.files} changed {workspaceTotals.additions > 0 && +{workspaceTotals.additions}} {workspaceTotals.deletions > 0 && 0 ? "" : "ml-auto"}`}>-{workspaceTotals.deletions}}
)}
{showFilterInput ? (
) : ( )}
{isFiltering && visibleDirs.length === 0 && (
No files match "{deferredFilterQuery.trim()}"
)} {visibleDirs.map((dir) => { const isCollapsed = !isFiltering && collapsedDirs.has(dir.path); return (
{!isCollapsed && ( onRetryVaultDir(dir.path) : onFetchAll} annotationCounts={annotationCounts} highlightedFiles={highlightedFiles} editStatuses={editStatuses} forceExpandFolders={isFiltering} selectionPending={selectionPending} /> )}
); })}
); };