import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react"; import { listFileSpaces, listManagedFileSnapshot, resolveFilePatterns, shareFilesWithTarget, type FileFolder, type FileSpace, type ManagedFile } from "../../../api/files"; import { Button, ConfirmDialog, Dialog, DismissibleAlert, LoadingFrame, type FilesManagedAttachmentSelection, type FilesManagedFileChooserProps, type FilesManagedFileLinkTarget, type FilesManagedFolderSelection, i18nMessage } from "@govoplan/core-webui"; import { useFileTreeState } from "../hooks/useFileTreeState"; import type { FolderNode, SortColumn, SortDirection } from "../types"; import { buildExplorerEntries, buildFolderTree, formatBytes, formatDate, normalizeFilePath, normalizeFolder, parentFolderPath, sortExplorerEntries, treeNodeKey } from "../utils/fileManagerUtils"; type ManagedFileChooserMode = FilesManagedFileChooserProps["mode"]; const DEFAULT_CHOOSER_FILE_ROW_HEIGHT = 52; const DEFAULT_PATTERN_RESULT_ROW_HEIGHT = 58; const DEFAULT_CHOOSER_TREE_ROW_HEIGHT = 32; const CHOOSER_FILE_ROW_GAP = 4; const CHOOSER_FILE_OVERSCAN_ROWS = 8; type RememberedChooserState = { spaceId?: string; folder?: string; pattern?: string; }; type ManagedFileSource = { ownerType: "user" | "group"; ownerId: string; }; const MANAGED_FILE_SOURCE_PREFIX = "managed:"; type ChooserExplorerEntry = ReturnType[number]; type VirtualFolderTreeRow = {node: FolderNode;depth: number;}; export type ManagedFolderSelection = FilesManagedFolderSelection; export type ManagedAttachmentSelection = FilesManagedAttachmentSelection; export default function ManagedFileChooser({ open, settings, mode, storageScope, source, basePath = ".", initialPattern = "", rememberKey = mode, previewContext, linkTarget, renderPatternPreview, onClose, onSelectFolder, onSelectAttachment }: FilesManagedFileChooserProps) { const parsedSource = useMemo(() => parseManagedFileSource(source), [source]); const normalizedBasePath = normalizeManagedBasePath(basePath); const storageKey = useMemo( () => `govoplan.files.managedFileChooser.${storageScope || linkTarget?.id || "global"}.${rememberKey}`, [linkTarget?.id, rememberKey, storageScope] ); const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]); const [spaces, setSpaces] = useState([]); const [selectedSpaceId, setSelectedSpaceId] = useState(""); const [files, setFiles] = useState([]); const [folders, setFolders] = useState([]); const [currentFolder, setCurrentFolder] = useState(""); const [pattern, setPattern] = useState(""); const patternRef = useRef(""); const rememberDraftTimerRef = useRef(null); const [patternHasText, setPatternHasText] = useState(false); const [exactSelectionPattern, setExactSelectionPattern] = useState(""); const [patternMatches, setPatternMatches] = useState(null); const [pendingExactFile, setPendingExactFile] = useState(null); const [loading, setLoading] = useState(false); const [resolving, setResolving] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null; const sourceLocked = mode === "attachment" && Boolean(parsedSource); const sourceMatchesSpace = selectedSpace ? mode === "folder" || !parsedSource || selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId : false; const effectiveRoot = mode === "attachment" ? normalizedBasePath : ""; const entries = useMemo( () => buildExplorerEntries(files, folders, currentFolder, false), [currentFolder, files, folders] ); const [sortColumn, setSortColumn] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const sortedEntries = useMemo( () => sortExplorerEntries(entries, sortColumn, sortDirection), [entries, sortColumn, sortDirection] ); const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot); const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]); const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]); const openFolder = useCallback((path: string) => { const normalized = normalizeFolder(path); if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return; setCurrentFolder(normalized); setPatternMatches(null); }, [effectiveRoot, mode]); const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({ activeSpaceId: selectedSpaceId, currentFolder, onOpenFolder: (_spaceId, path) => openFolder(path) }); const toggleSort = useCallback((column: SortColumn) => { if (sortColumn === column) { setSortDirection((current) => current === "asc" ? "desc" : "asc"); return; } setSortColumn(column); setSortDirection(column === "name" ? "asc" : "desc"); }, [sortColumn]); const applyExactFile = useCallback((file: ManagedFile) => { const exactPattern = relativePath(file.display_path, effectiveRoot); patternRef.current = exactPattern; setPattern(exactPattern); setPatternHasText(true); setExactSelectionPattern(exactPattern); setPatternMatches([file]); setPendingExactFile(null); }, [effectiveRoot]); const scheduleRememberedState = useCallback((patternValue = patternRef.current) => { if (!open || !selectedSpaceId) return; if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current); rememberDraftTimerRef.current = window.setTimeout(() => { writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern: patternValue }); rememberDraftTimerRef.current = null; }, 350); }, [currentFolder, open, selectedSpaceId, storageKey]); useEffect(() => () => { if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current); }, []); const requestExactFile = useCallback((file: ManagedFile) => { const exactPattern = relativePath(file.display_path, effectiveRoot); const currentPattern = patternRef.current.trim(); if (currentPattern && currentPattern !== exactPattern) { setPendingExactFile(file); return; } applyExactFile(file); }, [applyExactFile, effectiveRoot]); useEffect(() => { if (!open) return; let cancelled = false; setLoading(true); setError(""); const initialPatternValue = remembered.pattern ?? initialPattern.trim(); patternRef.current = initialPatternValue; setPattern(initialPatternValue); setPatternHasText(Boolean(initialPatternValue.trim())); setExactSelectionPattern(""); setPatternMatches(null); setPendingExactFile(null); void listFileSpaces(settings). then((response) => { if (cancelled) return; const managedSpaces = response.spaces.filter((space) => space.space_type !== "connector"); setSpaces(managedSpaces); const sourceSpace = managedSpaces.find((item) => sourceMatches(item, parsedSource)); const rememberedSpace = managedSpaces.find((item) => item.id === remembered.spaceId); const firstSpace = sourceSpace ?? rememberedSpace ?? managedSpaces[0] ?? null; setSelectedSpaceId(firstSpace?.id ?? ""); }). catch((reason: unknown) => { if (!cancelled) setError(errorMessage(reason)); }). finally(() => { if (!cancelled) setLoading(false); }); return () => {cancelled = true;}; }, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]); useEffect(() => { if (!open || !selectedSpace) return; let cancelled = false; setLoading(true); setError(""); setPatternMatches(null); const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : ""; const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot; setCurrentFolder(initialFolder || effectiveRoot); void listManagedFileSnapshot(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id, path_prefix: mode === "attachment" ? effectiveRoot || undefined : undefined }). then((response) => { if (cancelled) return; setFiles(response.files); setFolders(response.folders); }). catch((reason: unknown) => { if (!cancelled) setError(errorMessage(reason)); }). finally(() => { if (!cancelled) setLoading(false); }); return () => {cancelled = true;}; }, [effectiveRoot, mode, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]); useEffect(() => { scheduleRememberedState(); }, [currentFolder, pattern, scheduleRememberedState]); function updatePatternInput(value: string) { patternRef.current = value; setPatternHasText((current) => { const next = Boolean(value.trim()); return current === next ? current : next; }); setExactSelectionPattern(""); if (patternMatches !== null) setPatternMatches(null); scheduleRememberedState(value); } async function previewPattern(): Promise { if (!selectedSpace) return []; const trimmed = patternRef.current.trim(); if (!trimmed) { setPatternMatches([]); setPattern(""); setPatternHasText(false); return []; } const previewPatternValue = renderChooserPattern(trimmed, previewContext, renderPatternPreview); if (!previewPatternValue) { setPatternMatches([]); setPattern(trimmed); return []; } setPattern(trimmed); setPatternHasText(true); setResolving(true); setError(""); try { const response = await resolveFilePatterns(settings, { patterns: [previewPatternValue], owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id, path_prefix: effectiveRoot || undefined, include_unmatched: false }); const matches = response.patterns[0]?.matches ?? []; setPatternMatches(matches); return matches; } catch (reason) { setError(errorMessage(reason)); return []; } finally { setResolving(false); } } async function shareMatches(matches: ManagedFile[]) { if (!linkTarget) return; const fileIds = matches. filter((file) => !isLinkedToTarget(file, linkTarget)). map((file) => file.id); const uniqueIds = [...new Set(fileIds)]; if (uniqueIds.length > 0) await shareFilesWithTarget(settings, uniqueIds, linkTarget); } async function confirmSelection() { if (!selectedSpace || !sourceMatchesSpace) return; setSubmitting(true); setError(""); try { if (mode === "folder") { onSelectFolder?.({ space: selectedSpace, folderPath: currentFolder, source: encodeManagedFileSource(selectedSpace) }); return; } const matches = patternMatches ?? (await previewPattern()); await shareMatches(matches); const trimmedPattern = patternRef.current.trim(); onSelectAttachment?.({ space: selectedSpace, folderPath: effectiveRoot, source: encodeManagedFileSource(selectedSpace), fileFilter: trimmedPattern, matchCount: matches.length, fileIds: matches.map((file) => file.id), selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern" }); } catch (reason) { setError(errorMessage(reason)); } finally { setSubmitting(false); } } if (!open || typeof document === "undefined") return null; const dialog = <> }> {error && {error}} {mode === "attachment" && !parsedSource && i18n:govoplan-files.this_attachment_base_path_is_not_connected_to_a_.3962b48f i18n:govoplan-files.attachments_attachment_sources.87d92d5b i18n:govoplan-files.first.49ec0838 } {mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && i18n:govoplan-files.the_configured_managed_file_space_is_no_longer_a.5e93b729 }
{breadcrumbs.map((crumb) => )}
{mode === "attachment" && void previewPattern()} onBackToFolder={() => setPatternMatches(null)} /> } {patternMatches !== null && mode === "attachment" ? : } {mode === "folder" &&

i18n:govoplan-files.choose_the_folder_that_should_act_as_this_campai.9d0b7ef7

} {mode === "attachment" &&

i18n:govoplan-files.clicking_a_file_sets_its_exact_relative_path_as_.590abd24

}
pendingExactFile && applyExactFile(pendingExactFile)} onCancel={() => setPendingExactFile(null)} /> ; return createPortal(dialog, document.body); } function ChooserSortButton({ column, label, activeColumn, direction, onSort }: {column: SortColumn;label: string;activeColumn: SortColumn;direction: SortDirection;onSort: (column: SortColumn) => void;}) { const active = activeColumn === column; const Icon = active ? direction === "asc" ? ArrowUp : ArrowDown : ArrowUpDown; return ( ); } function ManagedPatternEditor({ value, effectiveRoot, resolving, matchCount, previewContext, renderPatternPreview, onDraftChange, onPreview, onBackToFolder }: {value: string;effectiveRoot: string;resolving: boolean;matchCount: number | null;previewContext?: Record;renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"];onDraftChange: (value: string) => void;onPreview: () => void;onBackToFolder: () => void;}) { const [draft, setDraft] = useState(value); const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext, renderPatternPreview), [draft, previewContext, renderPatternPreview]); const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim()); useEffect(() => { setDraft(value); }, [value]); function updateDraft(next: string) { setDraft(next); onDraftChange(next); } return (
{matchCount !== null &&
{matchCount} i18n:govoplan-files.current_file.1fbfcf3d{matchCount === 1 ? "" : "s"} i18n:govoplan-files.match.c79af5c2
} {patternIsRendered && i18n:govoplan-files.preview_resolves_to.a8d99432 {renderedPattern}.}
); } function VirtualChooserFolderTree({ nodes, activeSpaceId, spaceId, currentFolder, expandedKeys, onOpen, onToggle, disabled }: {nodes: FolderNode[];activeSpaceId: string;spaceId: string;currentFolder: string;expandedKeys: Set;onOpen: (spaceId: string, path: string) => void;onToggle: (spaceId: string, path: string) => void;disabled?: boolean;}) { const rows = useMemo(() => flattenVisibleFolderTree(nodes, spaceId, expandedKeys), [expandedKeys, nodes, spaceId]); const resetKey = useMemo( () => `${spaceId}:${currentFolder}:${rows.length}:${Array.from(expandedKeys).sort().join("|")}`, [currentFolder, expandedKeys, rows.length, spaceId] ); const virtualRows = useFixedVirtualRows({ itemCount: rows.length, resetKey, defaultRowHeight: DEFAULT_CHOOSER_TREE_ROW_HEIGHT }); if (rows.length === 0) return null; return (
virtualRows.setScrollTop(event.currentTarget.scrollTop)}> {virtualRows.topSpacerHeight > 0 &&