import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { ArrowUp, ChevronRight, Copy, Download, File, Folder, Home, KeyRound, Link2, ListFilter, MoveRight, Plus, RefreshCw, Search, Share2, Trash2, UploadCloud } from "lucide-react"; import { FormGrid, ActionToolbar, Button, ConfirmDialog, DocumentationHelpLink, DismissibleAlert, FieldLabel, FileDropZone, FormField, LoadingIndicator, PasswordField, ResourceAccessExplanation, ToggleSwitch, hasScope, type ApiSettings, type AuthInfo, i18nMessage } from "@govoplan/core-webui"; import { useLocation, useNavigate } from "react-router"; import { bulkDeleteFiles, bulkRenameFiles, browseFileConnectorProfile, createFileConnectorSpace, createFolder, confirmArchiveUpload, deleteFolder, deleteFileConnectorSpace, downloadFile, downloadFilesAsZip, fetchResourceAccessExplanation, listFilesDelta, listFilesByProperties, listFileConnectorProfiles, listFileSpaces, listManagedFileSnapshot, previewArchiveUpload, resolveFilePatterns, syncFileConnectorFile, transferFiles, uploadFiles, virtualFolderResourceId, type ArchivePreviewEntry, type ArchivePreviewResponse, type ConflictResolution, type ConflictStrategy, type FileConnectorBrowseItem, type FileConnectorProfile, type FileDeltaResponse, type FileCampaignUsageFilter, type FileFolder, type FileSpace, type ManagedFile, type RenameResponse, type ResourceAccessExplanationResponse } from "../../api/files"; import { EMPTY_FILES, EMPTY_FOLDERS, EMPTY_SPACES, INTERNAL_DRAG_TYPE } from "./constants"; import { FileConflictDialog, FileContextMenu, FileDialog, FolderTree, RenamePreviewList, TransferFolderSelector } from "./components/FileManagerComponents"; import FileShareDialog from "./components/FileShareDialog"; import type { ContextMenuState, DialogKind, EntrySelectionKey, ExplorerEntry, FolderEntry, FileActionTarget, FileConflictItem, RenameMode, SelectionSets, SortColumn, SortDirection, TransferMode } from "./types"; import { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, entrySelectionKey, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parseDragState, parentFolderPath, sortExplorerEntries, treeNodeKey } from "./utils/fileManagerUtils"; import { useFileSelection } from "./hooks/useFileSelection"; import { useFileTreeState } from "./hooks/useFileTreeState"; import { useFileDialogs } from "./hooks/useFileDialogs"; import { useFileDragDropState } from "./hooks/useFileDragDropState"; type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing"; type AuditRelevantFilter = "" | "true" | "false"; type FileAccessExplanationTarget = { resourceType: "file" | "folder"; resourceId: string; label: string; action: string; }; const DEFAULT_FILE_LIST_ROW_HEIGHT = 58; const FILE_LIST_OVERSCAN_ROWS = 8; const ARCHIVE_FILENAME_PATTERN = /\.(?:zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i; const FILES_WORKFLOW_DOCUMENTATION = { topicId: "files.workflow.organize-managed-files", documentationType: "user" } as const; export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { const location = useLocation(); const navigate = useNavigate(); const canDownload = hasScope(auth, "files:download"); const canUpload = hasScope(auth, "files:upload"); const canOrganize = hasScope(auth, "files:organize"); const canDelete = hasScope(auth, "files:delete"); const canShare = hasScope(auth, "files:file:share"); const [spaces, setSpaces] = useState(EMPTY_SPACES); const [spacesLoaded, setSpacesLoaded] = useState(false); const [activeSpaceId, setActiveSpaceId] = useState(""); const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? spaces[0] ?? null; const activeSpaceIsConnector = activeSpace?.space_type === "connector"; const [filesBySpace, setFilesBySpace] = useState>({}); const [foldersBySpace, setFoldersBySpace] = useState>({}); const [fileDeltaWatermarksBySpace, setFileDeltaWatermarksBySpace] = useState>({}); const files = activeSpace ? filesBySpace[activeSpace.id] ?? EMPTY_FILES : EMPTY_FILES; const folders = activeSpace ? foldersBySpace[activeSpace.id] ?? EMPTY_FOLDERS : EMPTY_FOLDERS; const [currentFolder, setCurrentFolder] = useState(""); const [searchPattern, setSearchPattern] = useState(""); const [searchCaseSensitive, setSearchCaseSensitive] = useState(false); const [searchActive, setSearchActive] = useState(false); const [searchResults, setSearchResults] = useState(null); const [campaignUsageFilter, setCampaignUsageFilter] = useState<"" | FileCampaignUsageFilter>(""); const [auditRelevantFilter, setAuditRelevantFilter] = useState(""); const [propertyFiltersActive, setPropertyFiltersActive] = useState(false); const [propertyFilterResults, setPropertyFilterResults] = useState(null); const [propertyFilterTotal, setPropertyFilterTotal] = useState(null); const [sortColumn, setSortColumn] = useState("name"); const [sortDirection, setSortDirection] = useState("asc"); const [unmatchedCount, setUnmatchedCount] = useState(null); const [unpackZip, setUnpackZip] = useState(false); const [archiveFile, setArchiveFile] = useState(null); const [archivePreview, setArchivePreview] = useState(null); const [archivePassword, setArchivePassword] = useState(""); const [selectedArchivePaths, setSelectedArchivePaths] = useState>(new Set()); const [busy, setBusy] = useState(false); const [uploadActive, setUploadActive] = useState(false); const [uploadPhase, setUploadPhase] = useState("idle"); const [uploadProgress, setUploadProgress] = useState(null); const [connectorProfiles, setConnectorProfiles] = useState([]); const [connectorProfileId, setConnectorProfileId] = useState(""); const [connectorLibraryId, setConnectorLibraryId] = useState(null); const [connectorPath, setConnectorPath] = useState(""); const [connectorItems, setConnectorItems] = useState([]); const [connectorSelectedItem, setConnectorSelectedItem] = useState(null); const [connectorLoading, setConnectorLoading] = useState(false); const [connectorError, setConnectorError] = useState(""); const [connectorSpaceLabel, setConnectorSpaceLabel] = useState(""); const [connectorSpaceOwnerSpaceId, setConnectorSpaceOwnerSpaceId] = useState(""); const [connectorSpaceItemsBySpace, setConnectorSpaceItemsBySpace] = useState>({}); const [connectorSpaceLibraryBySpace, setConnectorSpaceLibraryBySpace] = useState>({}); const [connectorSpaceSelectedItem, setConnectorSpaceSelectedItem] = useState(null); const [connectorSpaceRemovalTarget, setConnectorSpaceRemovalTarget] = useState(null); const [connectorSpaceLoading, setConnectorSpaceLoading] = useState(false); const [connectorSpaceError, setConnectorSpaceError] = useState(""); const [message, setMessage] = useState(""); const [error, setError] = useState(""); const [accessExplanationTarget, setAccessExplanationTarget] = useState(null); const [resourceAccessExplanation, setResourceAccessExplanation] = useState(null); const [resourceAccessLoading, setResourceAccessLoading] = useState(false); const [shareDialogFile, setShareDialogFile] = useState(null); const { setDragActive, internalDrag, setInternalDrag, dropTargetKey, setDropTargetKey, clearDropState: clearDragDropState } = useFileDragDropState(); const { dialog, setDialog, dialogTarget, setDialogTarget, transferMode, setTransferMode, transferDialogState, setTransferDialogState, contextMenu, setContextMenu, conflictDialog, setConflictDialog, deleteDialog, setDeleteDialog, openDialog: openRawDialog, closeDialog: closeRawDialog } = useFileDialogs(); const [newFolderName, setNewFolderName] = useState(""); const [newFolderError, setNewFolderError] = useState(""); const [renamePreviewVisibleCount, setRenamePreviewVisibleCount] = useState(20); const [singleRenameName, setSingleRenameName] = useState(""); const [renameMode, setRenameMode] = useState("prefix"); const [renameFind, setRenameFind] = useState(""); const [renameReplacement, setRenameReplacement] = useState(""); const [renamePrefix, setRenamePrefix] = useState(""); const [renameSuffix, setRenameSuffix] = useState(""); const [renameRecursive, setRenameRecursive] = useState(false); const [renamePreview, setRenamePreview] = useState(null); const visibleFiles = useMemo(() => { if (searchActive && searchResults && propertyFiltersActive && propertyFilterResults) { const ids = new Set(propertyFilterResults.map((file) => file.id)); return searchResults.filter((file) => ids.has(file.id)); } if (propertyFiltersActive && propertyFilterResults) return propertyFilterResults; if (searchActive && searchResults) return searchResults; return files; }, [files, propertyFilterResults, propertyFiltersActive, searchActive, searchResults]); const explorerEntries = useMemo(() => { const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive || propertyFiltersActive); return sortExplorerEntries(entries, sortColumn, sortDirection); }, [visibleFiles, folders, currentFolder, searchActive, propertyFiltersActive, sortColumn, sortDirection]); const fileListViewportRef = useRef(null); const fileListMeasureRowRef = useRef(null); const managedSpaceLoadsInFlightRef = useRef>(new Set()); const [fileListViewportHeight, setFileListViewportHeight] = useState(0); const [fileListScrollTop, setFileListScrollTop] = useState(0); const [fileListRowHeight, setFileListRowHeight] = useState(DEFAULT_FILE_LIST_ROW_HEIGHT); const virtualExplorerRows = useMemo(() => { const rowHeight = Math.max(1, fileListRowHeight); const entryScrollTop = Math.max(0, fileListScrollTop - rowHeight); const viewportHeight = Math.max(fileListViewportHeight, rowHeight * FILE_LIST_OVERSCAN_ROWS); const startIndex = Math.max(0, Math.floor(entryScrollTop / rowHeight) - FILE_LIST_OVERSCAN_ROWS); const endIndex = Math.min( explorerEntries.length, Math.ceil((entryScrollTop + viewportHeight) / rowHeight) + FILE_LIST_OVERSCAN_ROWS ); return { startIndex, topSpacerHeight: startIndex * rowHeight, bottomSpacerHeight: Math.max(0, (explorerEntries.length - endIndex) * rowHeight), entries: explorerEntries.slice(startIndex, endIndex) }; }, [explorerEntries, fileListRowHeight, fileListScrollTop, fileListViewportHeight]); const visibleEntryKeys = useMemo(() => explorerEntries.map(entrySelectionKey), [explorerEntries]); const { selectedFileIds, setSelectedFileIds, selectedFolderPaths, setSelectedFolderPaths, selectedEntryCount, hasSelection, selectedSummary, currentSelectionKeys, setSelectionAnchor, applySelectionKeys, clearSelection, currentSelectionSets, setsForEntry, isEntrySelected, preventTextSelectionOnShift, handleEntrySelection } = useFileSelection(visibleEntryKeys, busy); const { expandedTreeNodes, setExpandedTreeNodes, cancelTreeDragExpand, scheduleTreeDragExpand, toggleTreeFolder } = useFileTreeState({ activeSpaceId, currentFolder, onOpenFolder: openFolder }); const selectedFiles = useMemo(() => files.filter((file) => selectedFileIds.has(file.id)), [files, selectedFileIds]); const shareManageableFile = useMemo(() => { if (!canShare || selectedFiles.length !== 1 || selectedFolderPaths.size > 0) return null; const file = selectedFiles[0]; const ownsFile = file.owner_type === "user" ? file.owner_id === auth.user.id : auth.groups.some((group) => group.id === file.owner_id); return ownsFile || hasScope(auth, "files:file:admin") ? file : null; }, [auth, canShare, selectedFiles, selectedFolderPaths]); const selectedDownloadFileIds = useMemo(() => fileIdsForSelection(files, selectedFileIds, selectedFolderPaths), [files, selectedFileIds, selectedFolderPaths]); const accessExplainableTarget = useMemo( () => accessTargetForSelection(selectedFileIds, selectedFolderPaths, activeSpaceId), [activeSpaceId, filesBySpace, foldersBySpace, selectedFileIds, selectedFolderPaths] ); const canExplainResourceAccess = hasScope(auth, "admin:users:read") || hasScope(auth, "admin:roles:read") || hasScope(auth, "access:membership:read") || hasScope(auth, "access:role:read"); const folderCrumbs = useMemo(() => folderBreadcrumbs(currentFolder), [currentFolder]); const activeDialogTarget = dialogTarget ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null); const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null; const downloadLabel = selectedDownloadFileIds.length > 1 ? "i18n:govoplan-files.download_zip.7bd0e4b0" : "i18n:govoplan-files.download.a479c9c3"; const activeConnectorProfile = connectorProfiles.find((profile) => profile.id === connectorProfileId) ?? null; const activeConnectorSpaceItems = activeSpaceIsConnector && activeSpace ? connectorSpaceItemsBySpace[activeSpace.id] ?? [] : []; useEffect(() => { const viewport = fileListViewportRef.current; if (!viewport) return undefined; const updateViewportHeight = () => { setFileListViewportHeight(viewport.clientHeight); setFileListScrollTop(viewport.scrollTop); }; updateViewportHeight(); if (typeof ResizeObserver === "undefined") return undefined; const observer = new ResizeObserver(updateViewportHeight); observer.observe(viewport); return () => observer.disconnect(); }, []); useEffect(() => { const row = fileListMeasureRowRef.current; if (!row) return undefined; const updateRowHeight = () => { const measuredHeight = row.getBoundingClientRect().height; if (measuredHeight > 0) setFileListRowHeight(Math.round(measuredHeight)); }; updateRowHeight(); if (typeof ResizeObserver === "undefined") return undefined; const observer = new ResizeObserver(updateRowHeight); observer.observe(row); return () => observer.disconnect(); }, [activeSpaceId, currentFolder]); useEffect(() => { const viewport = fileListViewportRef.current; if (!viewport) return; viewport.scrollTop = 0; setFileListScrollTop(0); }, [activeSpaceId, currentFolder, propertyFiltersActive, propertyFilterResults, searchActive, searchResults, sortColumn, sortDirection]); async function loadSpaces() { setSpacesLoaded(false); setBusy(true); setError(""); try { const response = await listFileSpaces(settings); setSpaces(response.spaces); const nextActiveSpace = response.spaces.find((space) => space.id === activeSpaceId) ?? response.spaces[0] ?? null; setActiveSpaceId(nextActiveSpace?.id || ""); if (nextActiveSpace && !isConnectorSpace(nextActiveSpace)) await loadSpaceContents(nextActiveSpace, { silent: true }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSpacesLoaded(true); setBusy(false); } } function applyManagedSpaceDelta(spaceId: string, response: FileDeltaResponse) { if (response.full) { setFilesBySpace((current) => ({ ...current, [spaceId]: response.files })); setFoldersBySpace((current) => ({ ...current, [spaceId]: response.folders })); } else { const deletedFileIds = new Set(response.deleted.filter((item) => !item.resource_type || item.resource_type === "file").map((item) => item.id)); const deletedFolderIds = new Set(response.deleted.filter((item) => item.resource_type === "folder").map((item) => item.id)); setFilesBySpace((current) => { const byId = new Map((current[spaceId] ?? EMPTY_FILES).map((file) => [file.id, file])); deletedFileIds.forEach((id) => byId.delete(id)); response.files.forEach((file) => byId.set(file.id, file)); return { ...current, [spaceId]: Array.from(byId.values()) }; }); setFoldersBySpace((current) => { const byId = new Map((current[spaceId] ?? EMPTY_FOLDERS).map((folder) => [folder.id, folder])); deletedFolderIds.forEach((id) => byId.delete(id)); response.folders.forEach((folder) => byId.set(folder.id, folder)); return { ...current, [spaceId]: Array.from(byId.values()) }; }); } if (response.watermark) { setFileDeltaWatermarksBySpace((current) => ({ ...current, [spaceId]: response.watermark || "" })); } } async function loadSpaceContents(space = activeSpace, options: {silent?: boolean;} = {}) { if (!space) return; if (isConnectorSpace(space)) { await loadConnectorSpaceContents(space, { silent: options.silent, folderPath: "" }); return; } if (managedSpaceLoadsInFlightRef.current.has(space.id)) return; managedSpaceLoadsInFlightRef.current.add(space.id); if (!options.silent) { setBusy(true); setError(""); setMessage(""); } try { let since = fileDeltaWatermarksBySpace[space.id]; let response: FileDeltaResponse | null = null; if (!since) { const snapshot = await listManagedFileSnapshot(settings, { owner_type: space.owner_type, owner_id: space.owner_id }); setFilesBySpace((current) => ({ ...current, [space.id]: snapshot.files })); setFoldersBySpace((current) => ({ ...current, [space.id]: snapshot.folders })); if (snapshot.watermark) { setFileDeltaWatermarksBySpace((current) => ({ ...current, [space.id]: snapshot.watermark || "" })); } } else { do { response = await listFilesDelta(settings, { owner_type: space.owner_type, owner_id: space.owner_id, since: since || undefined }); applyManagedSpaceDelta(space.id, response); since = response.watermark || ""; } while (response.has_more && response.watermark); } setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); setUnmatchedCount(null); setRenamePreview(null); if (!options.silent) { setSearchActive(false); setSearchPattern(""); setSearchResults(null); setCampaignUsageFilter(""); setAuditRelevantFilter(""); setPropertyFiltersActive(false); setPropertyFilterResults(null); setPropertyFilterTotal(null); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { managedSpaceLoadsInFlightRef.current.delete(space.id); if (!options.silent) setBusy(false); } } useEffect(() => { void loadSpaces(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { const parameters = new URLSearchParams(location.search); if (parameters.get("quickAction") !== "upload" || !spacesLoaded) return; const uploadSpace = activeSpace && !isConnectorSpace(activeSpace) ? activeSpace : spaces.find((space) => !isConnectorSpace(space)) ?? null; if (canUpload && uploadSpace) { setActiveSpaceId(uploadSpace.id); openDialog("upload", { spaceId: uploadSpace.id, folderPath: "" }); } else if (!canUpload) { setError("File upload permission is required."); } else { setError("No authorized managed file space is available for upload."); } parameters.delete("quickAction"); const search = parameters.toString(); navigate( { pathname: location.pathname, search: search ? `?${search}` : "" }, { replace: true, state: location.state } ); }, [ activeSpace, canUpload, location.pathname, location.search, location.state, navigate, spaces, spacesLoaded ]); useEffect(() => { if (!contextMenu) return undefined; const close = () => setContextMenu(null); const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setContextMenu(null); }; window.addEventListener("click", close); window.addEventListener("resize", close); window.addEventListener("scroll", close, true); window.addEventListener("keydown", closeOnEscape); return () => { window.removeEventListener("click", close); window.removeEventListener("resize", close); window.removeEventListener("scroll", close, true); window.removeEventListener("keydown", closeOnEscape); }; }, [contextMenu]); useEffect(() => { setCurrentFolder(""); setSearchPattern(""); setSearchActive(false); setSearchResults(null); setCampaignUsageFilter(""); setAuditRelevantFilter(""); setPropertyFiltersActive(false); setPropertyFilterResults(null); setPropertyFilterTotal(null); setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); setConnectorSpaceSelectedItem(null); setConnectorSpaceError(""); const nextSpace = spaces.find((space) => space.id === activeSpaceId); if (nextSpace && isConnectorSpace(nextSpace)) { void loadConnectorSpaceContents(nextSpace, { folderPath: "", silent: true }); } else if (nextSpace && filesBySpace[nextSpace.id] === undefined && foldersBySpace[nextSpace.id] === undefined) { void loadSpaceContents(nextSpace, { silent: true }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSpaceId, spaces]); function resetTransientState(options: {keepSearch?: boolean;} = {}) { clearSelection(); if (!options.keepSearch) { setSearchActive(false); setSearchPattern(""); setSearchResults(null); setCampaignUsageFilter(""); setAuditRelevantFilter(""); setPropertyFiltersActive(false); setPropertyFilterResults(null); setPropertyFilterTotal(null); } setUnmatchedCount(null); setRenamePreview(null); } function currentActionTarget(): FileActionTarget | null { return activeDialogTarget; } function uploadRejectedReason(target: FileActionTarget | null): string { if (busy || uploadActive) return "Another file operation is already running. Wait until it finishes before dropping files."; if (!canUpload) return "You do not have permission to upload files here."; if (!target) return "Choose a destination folder before dropping files."; const targetSpace = findSpace(target.spaceId); if (!targetSpace) return "The selected upload destination is no longer available."; if (isConnectorSpace(targetSpace)) return "Files cannot be uploaded into connector spaces from this view."; return "The dropped item cannot be uploaded here."; } function openDialog(kind: DialogKind, target: FileActionTarget | null = null) { if ((kind === "upload" || kind === "connector-sync") && !canUpload) return; if (kind === "connector-space" && !canOrganize) return; if (kind && ["create-folder", "rename", "single-rename", "transfer"].includes(kind) && !canOrganize) return; if (target && isConnectorSpace(findSpace(target.spaceId)) && kind !== "connector-sync") return; if (kind === "create-folder") { setNewFolderName(""); setNewFolderError(""); } if (kind === "upload") { resetArchiveUploadState(); } openRawDialog(kind, target); } function resetArchiveUploadState() { setArchiveFile(null); setArchivePreview(null); setArchivePassword(""); setSelectedArchivePaths(new Set()); } function closeDialog() { closeRawDialog(); setNewFolderError(""); setUploadActive(false); setUploadPhase("idle"); setUploadProgress(null); setConnectorError(""); setConnectorSelectedItem(null); setConnectorSpaceLabel(""); resetArchiveUploadState(); } function updateActiveDialogFolder(spaceId: string, folderPath: string) { setDialogTarget({ spaceId, folderPath: normalizeFolder(folderPath) }); } function findSpace(spaceId: string): FileSpace | null { return spaces.find((space) => space.id === spaceId) ?? null; } function isConnectorSpace(space: FileSpace | null | undefined): boolean { return space?.space_type === "connector"; } function connectorSpaceLibrary(space: FileSpace): string | null { return connectorSpaceLibraryBySpace[space.id] ?? space.library_id ?? null; } function connectorSpaceRootPath(space: FileSpace): string { return normalizeFolder(space.remote_path || ""); } function connectorSpaceBrowsePath(space: FileSpace, folderPath: string): string { return normalizeFolder(joinFolder(connectorSpaceRootPath(space), folderPath)); } function connectorSpaceRelativePath(space: FileSpace, path: string): string { const root = connectorSpaceRootPath(space); const normalized = normalizeFolder(path); if (!root) return normalized; if (normalized === root) return ""; if (normalized.startsWith(`${root}/`)) return normalized.slice(root.length + 1); return normalized; } function connectorSpaceLibraryForSync(space: FileSpace, item: FileConnectorBrowseItem): string { return connectorSpaceLibrary(space) || connectorMetadataString(item, "library_id") || connectorMetadataString(item, "share") || space.connector_profile_id || "default"; } async function loadConnectorSpaceContents( space: FileSpace, options: {folderPath?: string;libraryId?: string | null;silent?: boolean;} = {}) { if (!space.connector_profile_id) { setConnectorSpaceError("i18n:govoplan-files.connector_profile_is_not_configured_for_this_spa.669f57b5"); return; } const relativeFolder = normalizeFolder(options.folderPath ?? currentFolder); const libraryId = options.libraryId !== undefined ? options.libraryId : connectorSpaceLibrary(space); if (!options.silent) { setBusy(true); setError(""); setMessage(""); } setConnectorSpaceLoading(true); setConnectorSpaceError(""); try { const response = await browseFileConnectorProfile(settings, space.connector_profile_id, { path: connectorSpaceBrowsePath(space, relativeFolder), library_id: libraryId || undefined }); setConnectorSpaceItemsBySpace((current) => ({ ...current, [space.id]: response.items })); setConnectorSpaceLibraryBySpace((current) => ({ ...current, [space.id]: response.library_id ?? libraryId ?? null })); setConnectorSpaceSelectedItem(null); setCurrentFolder(relativeFolder); } catch (err) { const detail = err instanceof Error ? err.message : String(err); setConnectorSpaceError(detail); setConnectorSpaceItemsBySpace((current) => ({ ...current, [space.id]: [] })); } finally { setConnectorSpaceLoading(false); if (!options.silent) setBusy(false); } } function openConnectorSpaceItem(item: FileConnectorBrowseItem) { if (!activeSpace || !isConnectorSpace(activeSpace)) return; if (item.kind === "file") { setConnectorSpaceSelectedItem(item); return; } if (item.kind === "library") { void loadConnectorSpaceContents(activeSpace, { folderPath: "", libraryId: item.external_id || item.path }); return; } void loadConnectorSpaceContents(activeSpace, { folderPath: connectorSpaceRelativePath(activeSpace, item.path), libraryId: connectorSpaceLibrary(activeSpace) }); } function browseConnectorSpaceParent() { if (!activeSpace || !isConnectorSpace(activeSpace)) return; if (currentFolder) { void loadConnectorSpaceContents(activeSpace, { folderPath: parentFolderPath(currentFolder), libraryId: connectorSpaceLibrary(activeSpace) }); return; } if (connectorSpaceLibrary(activeSpace) && connectorSpaceLibrary(activeSpace) !== (activeSpace.library_id ?? null)) { void loadConnectorSpaceContents(activeSpace, { folderPath: "", libraryId: activeSpace.library_id ?? null }); } } async function syncConnectorSpaceSelection(itemOverride?: FileConnectorBrowseItem) { const space = activeSpace; const selected = itemOverride ?? connectorSpaceSelectedItem; if (!canUpload || !space || !isConnectorSpace(space) || !space.connector_profile_id || !selected || selected.kind !== "file") return; const relativePath = connectorSpaceRelativePath(space, selected.path); const targetFolder = parentFolderPath(relativePath); setBusy(true); setError(""); setMessage(""); setConnectorSpaceError(""); try { const response = await syncFileConnectorFile(settings, space.connector_profile_id, { library_id: connectorSpaceLibraryForSync(space, selected), path: selected.path, owner_type: space.owner_type, owner_id: space.owner_id, target_folder: targetFolder, conflict_strategy: "rename", source_revision: connectorRevision(selected), metadata: { ...selected.metadata, connector_space_id: space.connector_space_id, browse_name: selected.name, browse_path: selected.path, browse_modified_at: selected.modified_at, browse_etag: selected.etag, synced_from_space: true } }); const actionLabel = response.action === "created" ? "i18n:govoplan-files.synced_new_file.d5e8243d" : response.action === "updated" ? "i18n:govoplan-files.synced_new_version.83784264" : "i18n:govoplan-files.connector_source_already_matched.7eaf5be1"; setMessage(i18nMessage("i18n:govoplan-files.value_value.36bc3a40", { value0: actionLabel, value1: response.file.display_path })); setConnectorSpaceSelectedItem(null); const ownerSpace = spaces.find((item) => item.space_type !== "connector" && item.owner_type === space.owner_type && item.owner_id === space.owner_id); if (ownerSpace) await loadSpaceContents(ownerSpace, { silent: ownerSpace.id !== activeSpaceId }); } catch (err) { const detail = err instanceof Error ? err.message : String(err); setError(detail); setConnectorSpaceError(detail); } finally { setBusy(false); } } async function loadArchivePreview( file: File, target: FileActionTarget, options: { preserveSelection?: boolean } = {} ) { const targetSpace = findSpace(target.spaceId); if (!targetSpace || isConnectorSpace(targetSpace)) { setError(uploadRejectedReason(target)); return; } setArchiveFile(file); setBusy(true); setUploadActive(true); setUploadPhase("uploading"); setUploadProgress(null); setError(""); setMessage(""); try { const response = await previewArchiveUpload(settings, file, { owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, path: target.folderPath, password: archivePassword || undefined }); const availableFiles = new Set( response.entries .filter((entry) => entry.kind === "file") .map((entry) => entry.path) ); setArchivePreview(response); setSelectedArchivePaths((current) => { if (!options.preserveSelection) return availableFiles; const retained = new Set( Array.from(current).filter((path) => availableFiles.has(path)) ); return retained.size > 0 ? retained : availableFiles; }); } catch (err) { setArchivePreview(null); setSelectedArchivePaths(new Set()); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); setUploadActive(false); setUploadPhase("idle"); setUploadProgress(null); } } function archiveFilesForEntry(entry: ArchivePreviewEntry): string[] { if (!archivePreview) return []; if (entry.kind === "file") return [entry.path]; const prefix = `${entry.path}/`; return archivePreview.entries .filter((candidate) => candidate.kind === "file" && candidate.path.startsWith(prefix)) .map((candidate) => candidate.path); } function archiveEntryIsSelected(entry: ArchivePreviewEntry): boolean { const paths = archiveFilesForEntry(entry); return paths.length > 0 && paths.every((path) => selectedArchivePaths.has(path)); } function toggleArchiveEntry(entry: ArchivePreviewEntry, selected: boolean) { const paths = archiveFilesForEntry(entry); setSelectedArchivePaths((current) => { const next = new Set(current); paths.forEach((path) => selected ? next.add(path) : next.delete(path)); return next; }); } function toggleAllArchiveFiles(selected: boolean) { setSelectedArchivePaths( selected && archivePreview ? new Set( archivePreview.entries .filter((entry) => entry.kind === "file") .map((entry) => entry.path) ) : new Set() ); } async function confirmCurrentArchive() { const target = currentActionTarget(); const targetSpace = target ? findSpace(target.spaceId) : null; if ( busy || !archiveFile || !archivePreview || !target || !targetSpace || isConnectorSpace(targetSpace) ) { return; } if (selectedArchivePaths.size === 0) { setError("Select at least one archive file to import."); return; } if (archivePreview.requires_password && !archivePassword) { setError("Enter the archive password before importing."); return; } setBusy(true); setUploadActive(true); setUploadPhase("uploading"); setUploadProgress(0); setError(""); setMessage("Uploading the archive for confirmed extraction."); try { const response = await confirmArchiveUpload(settings, archiveFile, { preview_token: archivePreview.preview_token, selected_paths: Array.from(selectedArchivePaths), owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, path: target.folderPath, password: archivePassword || undefined, conflict_strategy: "reject", onProgress: ({ percentage }) => { setUploadProgress(percentage); if (percentage !== null && percentage >= 100) { setUploadPhase("unpacking"); setMessage("Extracting the selected archive files."); } } }); setUploadPhase("finalizing"); setUploadProgress(100); const uploadedCount = response.files.length; const destination = target.folderPath || "i18n:govoplan-files.root.e96857c5"; closeDialog(); setMessage(i18nMessage("i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b", { value0: uploadedCount, value1: destination })); resetTransientState(); await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setMessage(""); } finally { setBusy(false); setUploadActive(false); setUploadPhase("idle"); setUploadProgress(null); } } async function handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) { const target = options.target ?? currentActionTarget(); const targetSpace = target ? findSpace(target.spaceId) : null; if (busy || uploadActive || !canUpload || !target || !targetSpace || isConnectorSpace(targetSpace)) { setError(uploadRejectedReason(target)); return; } const selected = Array.from(fileList); if (selected.length === 0) return; if (unpackZip) { if (selected.length !== 1 || !ARCHIVE_FILENAME_PATTERN.test(selected[0].name)) { setError("Choose one ZIP, TAR, TAR.GZ, TAR.BZ2, or TAR.XZ archive to preview and unpack."); return; } await loadArchivePreview(selected[0], target); return; } if (!options.bypassConflictDialog && !unpackZip) { const conflicts = uploadConflicts(selected, target); if (conflicts.length > 0) { closeDialog(); setConflictDialog({ operation: "upload", title: i18nMessage("i18n:govoplan-files.value_upload_conflict_s.3a04f117", { value0: conflicts.length }), message: "i18n:govoplan-files.files_with_the_same_visible_name_already_exist_i.1bb487b0", items: conflicts, pendingUpload: { files: selected, target }, review: false }); return; } } setBusy(true); setUploadActive(true); setUploadPhase("uploading"); setUploadProgress(0); setError(""); setMessage(i18nMessage("i18n:govoplan-files.uploading_value_file_s.715ba963", { value0: selected.length })); try { const response = await uploadFiles(settings, selected, { owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, path: target.folderPath, conflict_strategy: options.conflictStrategy ?? "reject", conflict_resolutions: options.conflictResolutions, onProgress: ({ percentage }) => { setUploadProgress(percentage); if (percentage !== null && percentage >= 100) { setUploadPhase("finalizing"); setMessage("i18n:govoplan-files.finalizing_upload.bcce936d"); } } }); setUploadPhase("finalizing"); setUploadProgress(100); setMessage(i18nMessage("i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b", { value0: response.files.length, value1: target.folderPath || "i18n:govoplan-files.root.e96857c5" })); closeDialog(); setConflictDialog(null); resetTransientState(); await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setMessage(""); } finally { setBusy(false); setUploadActive(false); setUploadPhase("idle"); setUploadProgress(null); } } async function uploadExternalFilesToTarget(fileList: FileList | File[], target: FileActionTarget) { await handleFilesUpload(fileList, { target }); } async function openConnectorSyncDialog(target: FileActionTarget | null = toolbarTarget()) { if (!canUpload || !target) return; openDialog("connector-sync", target); if (connectorProfiles.length === 0) { await loadConnectorProfilesForDialog(); return; } const profileId = connectorProfileId || connectorProfiles[0]?.id || ""; if (profileId && connectorItems.length === 0) await browseConnector(profileId, "", null); } async function openConnectorSpaceDialog() { if (!canOrganize) return; const defaultOwnerSpace = activeSpace && !activeSpaceIsConnector ? activeSpace : spaces.find((space) => !isConnectorSpace(space)) ?? null; setConnectorSpaceOwnerSpaceId(defaultOwnerSpace?.id || ""); setConnectorSpaceLabel(""); openDialog("connector-space", null); if (connectorProfiles.length === 0) { await loadConnectorProfilesForDialog(); return; } const profileId = connectorProfileId || connectorProfiles[0]?.id || ""; if (profileId && connectorItems.length === 0) await browseConnector(profileId, "", null); } async function loadConnectorProfilesForDialog(preferredProfileId = connectorProfileId) { setConnectorLoading(true); setConnectorError(""); try { const response = await listFileConnectorProfiles(settings); const enabledProfiles = response.profiles.filter((profile) => profile.enabled); setConnectorProfiles(enabledProfiles); const nextProfileId = enabledProfiles.some((profile) => profile.id === preferredProfileId) ? preferredProfileId : enabledProfiles[0]?.id || ""; setConnectorProfileId(nextProfileId); setConnectorLibraryId(null); setConnectorPath(""); setConnectorItems([]); setConnectorSelectedItem(null); if (nextProfileId) { await browseConnector(nextProfileId, "", null); } else { setConnectorError("i18n:govoplan-files.no_connector_profiles_are_available.9be3be28"); } } catch (err) { setConnectorError(err instanceof Error ? err.message : String(err)); } finally { setConnectorLoading(false); } } async function browseConnector(profileId: string, path = "", libraryId: string | null = connectorLibraryId) { if (!profileId) { setConnectorPath(""); setConnectorLibraryId(null); setConnectorItems([]); setConnectorSelectedItem(null); return; } setConnectorLoading(true); setConnectorError(""); try { const response = await browseFileConnectorProfile(settings, profileId, { path: normalizeFolder(path), library_id: libraryId || undefined }); setConnectorPath(response.path || ""); setConnectorLibraryId(response.library_id ?? libraryId ?? null); setConnectorItems(response.items); setConnectorSelectedItem(null); } catch (err) { setConnectorError(err instanceof Error ? err.message : String(err)); setConnectorItems([]); setConnectorSelectedItem(null); } finally { setConnectorLoading(false); } } function handleConnectorProfileChange(profileId: string) { setConnectorProfileId(profileId); setConnectorLibraryId(null); setConnectorPath(""); setConnectorItems([]); setConnectorSelectedItem(null); if (profileId) void browseConnector(profileId, "", null); } function openConnectorItem(item: FileConnectorBrowseItem) { if (!connectorProfileId) return; if (item.kind === "file") { setConnectorSelectedItem(item); return; } if (item.kind === "library") { void browseConnector(connectorProfileId, "", item.external_id || item.path); return; } void browseConnector(connectorProfileId, item.path, connectorLibraryId); } function browseConnectorParent() { if (!connectorProfileId) return; if (connectorPath) { void browseConnector(connectorProfileId, parentFolderPath(connectorPath), connectorLibraryId); return; } if (connectorLibraryId) void browseConnector(connectorProfileId, "", null); } function connectorSpaceSuggestedLabel(): string { const location = connectorPath ? lastPathSegment(connectorPath) : connectorLibraryId || ""; return [activeConnectorProfile?.label, location].filter(Boolean).join(" / ") || "i18n:govoplan-files.linked_file_space.1a614c7d"; } async function createConnectorSpaceFromDialog() { const ownerSpace = findSpace(connectorSpaceOwnerSpaceId) ?? spaces.find((space) => !isConnectorSpace(space)) ?? null; if (!canOrganize || !ownerSpace || isConnectorSpace(ownerSpace) || !connectorProfileId) { setConnectorError("i18n:govoplan-files.choose_a_connector_profile_and_owner_space.4c386b00"); return; } const label = connectorSpaceLabel.trim() || connectorSpaceSuggestedLabel(); const remotePath = normalizeFolder(connectorPath); setBusy(true); setError(""); setMessage(""); setConnectorError(""); try { const created = await createFileConnectorSpace(settings, { owner_type: ownerSpace.owner_type, owner_id: ownerSpace.owner_id, label, connector_profile_id: connectorProfileId, library_id: connectorLibraryId || undefined, remote_path: remotePath, sync_mode: "manual", metadata: { created_from_ui: true, source_label: activeConnectorProfile?.label, source_path: remotePath, source_library_id: connectorLibraryId } }); setMessage(i18nMessage("i18n:govoplan-files.added_connector_space_value.a24725b8", { value0: created.label })); closeDialog(); resetTransientState(); await loadSpaces(); setActiveSpaceId(`connector:${created.id}`); setCurrentFolder(""); } catch (err) { const detail = err instanceof Error ? err.message : String(err); setError(detail); setConnectorError(detail); } finally { setBusy(false); } } async function removeConnectorSpace() { const target = connectorSpaceRemovalTarget; if (!canOrganize || !target?.connector_space_id || !isConnectorSpace(target)) return; setBusy(true); setError(""); setMessage(""); try { await deleteFileConnectorSpace(settings, target.connector_space_id); setConnectorSpaceRemovalTarget(null); setConnectorSpaceItemsBySpace((current) => { const next = { ...current }; delete next[target.id]; return next; }); setConnectorSpaceLibraryBySpace((current) => { const next = { ...current }; delete next[target.id]; return next; }); setConnectorSpaceSelectedItem(null); setMessage(`Removed connector space “${target.label}”. Remote content and managed GovOPlaN files were not changed.`); await loadSpaces(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function connectorMetadataString(item: FileConnectorBrowseItem, key: string): string | null { const value = item.metadata[key]; if (typeof value === "string" && value.trim()) return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return null; } function connectorLibraryForSync(item: FileConnectorBrowseItem): string { return connectorLibraryId || connectorMetadataString(item, "library_id") || connectorMetadataString(item, "share") || activeConnectorProfile?.id || "default"; } function connectorRevision(item: FileConnectorBrowseItem): string | null { return item.etag || item.modified_at || connectorMetadataString(item, "revision"); } async function syncConnectorSelection() { const target = currentActionTarget(); const targetSpace = target ? findSpace(target.spaceId) : null; const selected = connectorSelectedItem; if (!canUpload || !target || !targetSpace || !connectorProfileId || !selected || selected.kind !== "file") { setConnectorError("i18n:govoplan-files.choose_a_connector_file_and_destination_folder.3c6f3ffb"); return; } setBusy(true); setError(""); setMessage(""); setConnectorError(""); try { const response = await syncFileConnectorFile(settings, connectorProfileId, { library_id: connectorLibraryForSync(selected), path: selected.path, owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, target_folder: target.folderPath, conflict_strategy: "rename", source_revision: connectorRevision(selected), metadata: { ...selected.metadata, browse_name: selected.name, browse_path: selected.path, browse_modified_at: selected.modified_at, browse_etag: selected.etag, synced_from_ui: true } }); const actionLabel = response.action === "created" ? "i18n:govoplan-files.synced_new_file.d5e8243d" : response.action === "updated" ? "i18n:govoplan-files.synced_new_version.83784264" : "i18n:govoplan-files.connector_source_already_matched.7eaf5be1"; setMessage(i18nMessage("i18n:govoplan-files.value_value.36bc3a40", { value0: actionLabel, value1: response.file.display_path })); closeDialog(); resetTransientState(); await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId }); } catch (err) { const detail = err instanceof Error ? err.message : String(err); setError(detail); setConnectorError(detail); } finally { setBusy(false); } } async function handleCreateFolder() { const target = currentActionTarget(); const targetSpace = target ? findSpace(target.spaceId) : null; const trimmedName = newFolderName.trim(); if (!target || !targetSpace || isConnectorSpace(targetSpace) || !trimmedName) return; const folderPath = joinFolder(target.folderPath, trimmedName); if (folderExistsInTarget(target, folderPath)) { setNewFolderError(`Folder already exists: ${folderPath}.`); return; } if (pathExistsInTarget(target, folderPath, { includeFolders: false })) { setNewFolderError(`A file already exists at ${folderPath}.`); return; } setBusy(true); setError(""); setNewFolderError(""); setMessage(""); try { await createFolder(settings, { owner_type: targetSpace.owner_type, owner_id: targetSpace.owner_id, path: folderPath }); setMessage(i18nMessage("i18n:govoplan-files.created_folder_value.6c3002f9", { value0: folderPath })); closeDialog(); setNewFolderName(""); resetTransientState(); await loadSpaceContents(targetSpace, { silent: targetSpace.id !== activeSpaceId }); setExpandedTreeNodes((current) => { const next = new Set(current); folderAncestorPaths(target.folderPath, { includeSelf: true }).forEach((path) => next.add(treeNodeKey(target.spaceId, path))); return next; }); } catch (err) { setNewFolderError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function runPatternSearch(patternOverride = searchPattern, caseSensitiveOverride = searchCaseSensitive) { const trimmedPattern = patternOverride.trim(); if (!activeSpace || activeSpaceIsConnector || !trimmedPattern) { await clearSearch(); return; } const caseSensitive = caseSensitiveOverride; setBusy(true); setError(""); setMessage(""); try { const response = await resolveFilePatterns(settings, { patterns: [trimmedPattern], owner_type: activeSpace.owner_type, owner_id: activeSpace.owner_id, path_prefix: currentFolder, include_unmatched: true, case_sensitive: caseSensitive }); setSearchResults(response.patterns.flatMap((pattern) => pattern.matches)); setSearchActive(true); setSearchPattern(trimmedPattern); setSearchCaseSensitive(caseSensitive); setUnmatchedCount(response.unmatched.length); setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function clearSearch() { setSearchPattern(""); setSearchActive(false); setSearchResults(null); setUnmatchedCount(null); setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); } async function runPropertyFilters( campaignUsage: "" | FileCampaignUsageFilter, auditRelevant: AuditRelevantFilter ) { if (!activeSpace || activeSpaceIsConnector) return; if (!campaignUsage && !auditRelevant) { clearPropertyFilters(); return; } setBusy(true); setError(""); setMessage(""); try { const response = await listFilesByProperties(settings, { owner_type: activeSpace.owner_type, owner_id: activeSpace.owner_id, path_prefix: currentFolder, campaign_usage: campaignUsage || undefined, audit_relevant: auditRelevant ? auditRelevant === "true" : undefined }); setCampaignUsageFilter(campaignUsage); setAuditRelevantFilter(auditRelevant); setPropertyFilterResults(response.files); setPropertyFilterTotal(response.total); setPropertyFiltersActive(true); setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function clearPropertyFilters() { setCampaignUsageFilter(""); setAuditRelevantFilter(""); setPropertyFiltersActive(false); setPropertyFilterResults(null); setPropertyFilterTotal(null); setSelectedFileIds(new Set()); setSelectedFolderPaths(new Set()); } function deleteSelected( fileIds: Set = selectedFileIds, folderPaths: Set = selectedFolderPaths, space: FileSpace | null = activeSpace) { const fileCount = fileIds.size; const folderCount = folderPaths.size; if (!space || isConnectorSpace(space) || fileCount === 0 && folderCount === 0) return; const folderText = folderCount ? `${folderCount} folder(s) and their contents` : ""; const fileText = fileCount ? `${fileCount} file(s)` : ""; const what = [folderText, fileText].filter(Boolean).join(" plus "); setDeleteDialog({ fileIds: new Set(fileIds), folderPaths: new Set(folderPaths), spaceId: space.id, title: "i18n:govoplan-files.delete_selected_item_s.4b6a0023", message: i18nMessage("i18n:govoplan-files.delete_value_audit_relevant_blobs_remain_retaine.290af88f", { value0: what }) }); } async function performConfirmedDelete() { const pending = deleteDialog; if (!pending) return; const space = findSpace(pending.spaceId); if (!space) { setDeleteDialog(null); return; } setBusy(true); setError(""); setMessage(""); try { if (pending.fileIds.size > 0) { await bulkDeleteFiles(settings, Array.from(pending.fileIds)); } if (pending.folderPaths.size > 0) { for (const folderPath of pending.folderPaths) { await deleteFolder(settings, { owner_type: space.owner_type, owner_id: space.owner_id, path: folderPath, recursive: true }); } } setDeleteDialog(null); resetTransientState(); await loadSpaceContents(space, { silent: space.id !== activeSpaceId }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function downloadLabelForSets(sets: SelectionSets, spaceId = activeSpaceId): string { const count = fileIdsForSelection(filesBySpace[spaceId] ?? EMPTY_FILES, sets.fileIds, sets.folderPaths).length; return count > 1 ? "i18n:govoplan-files.download_zip.7bd0e4b0" : "i18n:govoplan-files.download.a479c9c3"; } async function downloadSelection( fileIds: Set = selectedFileIds, folderPaths: Set = selectedFolderPaths, sourceFiles: ManagedFile[] = files) { if (!canDownload) return; const idsToDownload = fileIdsForSelection(sourceFiles, fileIds, folderPaths); if (idsToDownload.length === 0) return; setBusy(true); setError(""); try { if (idsToDownload.length === 1) { const file = sourceFiles.find((item) => item.id === idsToDownload[0]); if (file) await downloadFile(settings, file); } else { await downloadFilesAsZip(settings, idsToDownload, "multi-seal-mail-files.zip"); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function openTransferDialog(mode: TransferMode, sourceSpaceId = activeSpaceId, sets: SelectionSets = currentSelectionSets(), defaultTarget?: FileActionTarget) { if (!canOrganize) return; if (!sourceSpaceId || sets.fileIds.size === 0 && sets.folderPaths.size === 0) return; setTransferMode(mode); setTransferDialogState({ fileIds: new Set(sets.fileIds), folderPaths: new Set(sets.folderPaths), sourceSpaceId, targetSpaceId: defaultTarget?.spaceId ?? sourceSpaceId, targetFolder: defaultTarget?.folderPath ?? currentFolder }); setDialog("transfer"); } function invalidDropReason(sourceSpaceId: string, folderPaths: Iterable, target: FileActionTarget): string | null { if (sourceSpaceId !== target.spaceId) return null; const targetFolder = normalizeFolder(target.folderPath); for (const folderPathRaw of folderPaths) { const folderPath = normalizeFolder(folderPathRaw); if (!folderPath) continue; if (targetFolder === folderPath || targetFolder.startsWith(`${folderPath}/`)) { return "i18n:govoplan-files.cannot_move_or_copy_a_folder_into_itself_or_one_.4adbd5ea"; } } return null; } async function runTransfer(mode: TransferMode, sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget, options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;} = {}) { const sourceSpace = findSpace(sourceSpaceId); const targetSpace = findSpace(target.spaceId); if (!sourceSpace || !targetSpace || isConnectorSpace(sourceSpace) || isConnectorSpace(targetSpace)) return; const reason = invalidDropReason(sourceSpaceId, sets.folderPaths, target); if (reason) { setError(reason); return; } if (sets.fileIds.size === 0 && sets.folderPaths.size === 0) return; if (mode === "move" && transferWouldBeNoop(sourceSpaceId, sets, target)) { setInternalDrag(null); setDropTargetKey(""); return; } if (!options.bypassConflictDialog) { const conflicts = transferConflicts(mode, sourceSpaceId, sets, target); if (conflicts.length > 0) { closeDialog(); setConflictDialog({ operation: "transfer", title: i18nMessage("i18n:govoplan-files.value_conflict_s.b3872a48", { value0: conflicts.length }), message: i18nMessage("i18n:govoplan-files.value_this_selection_would_create_duplicate_visi.26ae34d4", { value0: mode === "copy" ? "i18n:govoplan-files.copying.43b7de98" : "i18n:govoplan-files.moving.65cd4dd8" }), items: conflicts, pendingTransfer: { mode, sourceSpaceId, sets: { fileIds: new Set(sets.fileIds), folderPaths: new Set(sets.folderPaths) }, target }, review: false }); setInternalDrag(null); setDropTargetKey(""); return; } } setBusy(true); setError(""); setMessage(""); try { const response = await transferFiles(settings, { operation: mode, file_ids: Array.from(sets.fileIds), folder_paths: Array.from(sets.folderPaths), source_owner_type: sourceSpace.owner_type, source_owner_id: sourceSpace.owner_id, target_owner_type: targetSpace.owner_type, target_owner_id: targetSpace.owner_id, target_folder: target.folderPath, conflict_strategy: options.conflictStrategy ?? "reject", conflict_resolutions: options.conflictResolutions }); setMessage(i18nMessage("i18n:govoplan-files.value_value_file_s_and_value_folder_s.c160e725", { value0: mode === "copy" ? "i18n:govoplan-files.copied.8e3df45a" : "i18n:govoplan-files.moved.0fc28db0", value1: response.files, value2: response.folders })); setConflictDialog(null); closeDialog(); resetTransientState(); const reloadIds = new Set([sourceSpace.id, targetSpace.id]); await Promise.all(Array.from(reloadIds).map((spaceId) => { const space = findSpace(spaceId); return space ? loadSpaceContents(space, { silent: space.id !== activeSpaceId }) : Promise.resolve(); })); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); setInternalDrag(null); setDropTargetKey(""); } } async function handleTransferDialogSubmit() { if (!transferDialogState) return; const state = transferDialogState; const mode = transferMode; closeDialog(); await runTransfer(mode, state.sourceSpaceId, state, { spaceId: state.targetSpaceId, folderPath: state.targetFolder }); } async function runRenamePreview(dryRun: boolean) { if (!activeSpace || activeSpaceIsConnector || selectedEntryCount === 0) return; setBusy(true); setError(""); try { const response = await bulkRenameFiles(settings, { file_ids: Array.from(selectedFileIds), folder_paths: Array.from(selectedFolderPaths), owner_type: activeSpace.owner_type, owner_id: activeSpace.owner_id, mode: renameMode, find: renameFind, replacement: renameReplacement, prefix: renamePrefix, suffix: renameSuffix, recursive: renameRecursive, dry_run: dryRun }); setRenamePreview(response); setRenamePreviewVisibleCount(20); if (dryRun) { setMessage(""); } else { setMessage(i18nMessage("i18n:govoplan-files.renamed_value_item_s.3c4a40fe", { value0: response.items.length })); } if (!dryRun) { closeDialog(); resetTransientState(); await loadSpaceContents(activeSpace); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function openRenameDialog() { if (!canOrganize || activeSpaceIsConnector || selectedEntryCount === 0) return; setRenamePreview(null); if (selectedEntryCount === 1) { const selectedFile = selectedFiles[0]; const selectedFolderPath = Array.from(selectedFolderPaths)[0]; setSingleRenameName(selectedFile ? selectedFile.filename : lastPathSegment(selectedFolderPath)); openDialog("single-rename"); return; } openDialog("rename"); } async function runSingleRename() { if (!canOrganize || !activeSpace || activeSpaceIsConnector || selectedEntryCount !== 1 || !singleRenameName.trim()) return; setBusy(true); setError(""); try { const response = await bulkRenameFiles(settings, { file_ids: Array.from(selectedFileIds), folder_paths: Array.from(selectedFolderPaths), owner_type: activeSpace.owner_type, owner_id: activeSpace.owner_id, mode: "direct", new_name: singleRenameName, dry_run: false }); setMessage(i18nMessage("i18n:govoplan-files.renamed_value_item_s.3c4a40fe", { value0: response.items.length })); closeDialog(); resetTransientState(); await loadSpaceContents(activeSpace); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function openFolder(spaceId: string, path: string) { const space = findSpace(spaceId); if (spaceId !== activeSpaceId) setActiveSpaceId(spaceId); resetTransientState(); if (space && isConnectorSpace(space)) { void loadConnectorSpaceContents(space, { folderPath: normalizeFolder(path) }); return; } setCurrentFolder(normalizeFolder(path)); } function handleEntryKeyDown(entry: ExplorerEntry, event: ReactKeyboardEvent) { if (event.key === " " || event.key === "Spacebar") { event.preventDefault(); handleEntrySelection(entry, event as unknown as ReactMouseEvent); } if (event.key === "Enter") { event.preventDefault(); if (entry.kind === "folder") activeSpace && openFolder(activeSpace.id, entry.path);else if (canDownload) void downloadFile(settings, entry.file); } } function dragSetsForEntry(entry: ExplorerEntry): SelectionSets { const key = entrySelectionKey(entry); return currentSelectionKeys().has(key) ? currentSelectionSets() : setsForEntry(entry); } function handleInternalDragStart(entry: ExplorerEntry, event: ReactDragEvent) { if (!canOrganize || !activeSpace || activeSpaceIsConnector) {event.preventDefault();return;} const sets = dragSetsForEntry(entry); if (!currentSelectionKeys().has(entrySelectionKey(entry))) { applySelectionKeys(new Set([entrySelectionKey(entry)])); setSelectionAnchor(entrySelectionKey(entry)); } const state = { sourceSpaceId: activeSpace.id, fileIds: Array.from(sets.fileIds), folderPaths: Array.from(sets.folderPaths) }; setInternalDrag(state); event.dataTransfer.effectAllowed = "i18n:govoplan-files.copymove.d0fa5904"; event.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(state)); event.dataTransfer.setData("text/plain", `${state.fileIds.length + state.folderPaths.length} item(s)`); } function handleTreeFolderDragStart(spaceId: string, path: string, event: ReactDragEvent) { if (!canOrganize || isConnectorSpace(findSpace(spaceId))) {event.preventDefault();return;} const folderPath = normalizeFolder(path); if (!folderPath) return; const state = { sourceSpaceId: spaceId, fileIds: [], folderPaths: [folderPath] }; setInternalDrag(state); event.dataTransfer.effectAllowed = "i18n:govoplan-files.copymove.d0fa5904"; event.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(state)); event.dataTransfer.setData("text/plain", folderPath); } function isInternalDrag(event: ReactDragEvent): boolean { return event.dataTransfer.types.includes(INTERNAL_DRAG_TYPE) || Boolean(internalDrag); } function dropTargetId(target: FileActionTarget): string { return `${target.spaceId}:${normalizeFolder(target.folderPath)}`; } function handleDropTargetDragOver(event: ReactDragEvent, target: FileActionTarget) { event.preventDefault(); event.stopPropagation(); if (isConnectorSpace(findSpace(target.spaceId))) { event.dataTransfer.dropEffect = "none"; setDropTargetKey(""); return; } if (isInternalDrag(event)) { if (!canOrganize) {event.dataTransfer.dropEffect = "none";setDropTargetKey("");return;} const state = internalDrag; const mode = event.ctrlKey ? "copy" : "move"; const sets = state ? { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) } : null; const reason = state ? invalidDropReason(state.sourceSpaceId, state.folderPaths, target) : null; const noop = state && sets && mode === "move" ? transferWouldBeNoop(state.sourceSpaceId, sets, target) : false; event.dataTransfer.dropEffect = reason || noop ? "none" : mode; setDropTargetKey(reason || noop ? "" : dropTargetId(target)); return; } event.dataTransfer.dropEffect = canUpload && !busy && !uploadActive ? "copy" : "none"; setDropTargetKey(canUpload && !busy && !uploadActive ? dropTargetId(target) : ""); } function handleCurrentFolderDragOver(event: ReactDragEvent) { const target = toolbarTarget(); if (!target) { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = "none"; setDropTargetKey(""); return; } handleDropTargetDragOver(event, target); } async function handleDropOnTarget(event: ReactDragEvent, target: FileActionTarget) { event.preventDefault(); event.stopPropagation(); setDragActive(false); setDropTargetKey(""); const hasDroppedFiles = event.dataTransfer.files.length > 0; if (isConnectorSpace(findSpace(target.spaceId))) { if (hasDroppedFiles) setError(uploadRejectedReason(target)); return; } if (isInternalDrag(event)) { if (!canOrganize) return; const state = internalDrag ?? parseDragState(event.dataTransfer.getData(INTERNAL_DRAG_TYPE)); if (!state) return; const reason = invalidDropReason(state.sourceSpaceId, state.folderPaths, target); if (reason) { setError(reason); return; } await runTransfer(event.ctrlKey ? "copy" : "move", state.sourceSpaceId, { fileIds: new Set(state.fileIds), folderPaths: new Set(state.folderPaths) }, target); return; } if (hasDroppedFiles) { if (!canUpload || busy || uploadActive) { setError(uploadRejectedReason(target)); return; } await uploadExternalFilesToTarget(event.dataTransfer.files, target); return; } setError("Drop one or more files from your computer, or drag files and folders from this file space."); } async function handleDropOnCurrentFolder(event: ReactDragEvent) { const target = toolbarTarget(); if (!target) { event.preventDefault(); event.stopPropagation(); setDropTargetKey(""); setError(uploadRejectedReason(null)); return; } await handleDropOnTarget(event, target); } function clearDropState() { clearDragDropState(); cancelTreeDragExpand(); } function spaceForContext(menu: ContextMenuState | null): FileSpace | null { const spaceId = menu?.spaceId ?? activeSpaceId; return findSpace(spaceId) ?? activeSpace; } function filesForContext(menu: ContextMenuState | null): ManagedFile[] { const spaceId = menu?.spaceId ?? activeSpaceId; return filesBySpace[spaceId] ?? files; } function folderEntryForPath(spaceId: string, path: string): FolderEntry { const sourceFiles = filesBySpace[spaceId] ?? EMPTY_FILES; const sourceFolders = foldersBySpace[spaceId] ?? EMPTY_FOLDERS; return buildFolderEntryFromSources(sourceFiles, sourceFolders, path); } function selectedSetsForContext(menu: ContextMenuState | null): SelectionSets { if (!menu) return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; if (menu.source === "tree") { if (menu.entry?.kind === "folder") return { fileIds: new Set(), folderPaths: new Set([menu.entry.path]) }; if (menu.folderPath !== undefined) { const sourceFiles = filesForContext(menu); const fileIds = new Set(sourceFiles.filter((file) => isFileInFolder(file, menu.folderPath ?? "")).map((file) => file.id)); return { fileIds, folderPaths: new Set() }; } } if (menu.target === "empty" || !menu.entry) { return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; } const key = entrySelectionKey(menu.entry); if (currentSelectionKeys().has(key)) { return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) }; } return setsForEntry(menu.entry); } function openContextMenu(event: ReactMouseEvent, target: "empty" | "folder" | "file", entry?: ExplorerEntry) { event.preventDefault(); event.stopPropagation(); if (entry) { const key = entrySelectionKey(entry); const currentKeys = currentSelectionKeys(); if (!currentKeys.has(key)) { applySelectionKeys(new Set([key])); setSelectionAnchor(key); } } setContextMenu({ x: event.clientX, y: event.clientY, target, entry, source: "list", spaceId: activeSpaceId, folderPath: entry?.kind === "folder" ? entry.path : currentFolder }); } function openTreeContextMenu(event: ReactMouseEvent, spaceId: string, path: string) { event.preventDefault(); event.stopPropagation(); const normalizedPath = normalizeFolder(path); const entry = normalizedPath ? folderEntryForPath(spaceId, normalizedPath) : undefined; setContextMenu({ x: event.clientX, y: event.clientY, target: entry ? "folder" : "empty", entry, source: "tree", spaceId, folderPath: normalizedPath }); } function openTreeEmptyContextMenu(event: ReactMouseEvent) { event.preventDefault(); const target = toolbarTarget(); if (!target) return; setContextMenu({ x: event.clientX, y: event.clientY, target: "empty", source: "tree", spaceId: target.spaceId, folderPath: target.folderPath }); } function contextActionTarget(menu: ContextMenuState | null): FileActionTarget | null { const space = spaceForContext(menu); if (!space) return null; const folderPath = menu?.entry?.kind === "folder" ? menu.entry.path : menu?.folderPath ?? currentFolder; return { spaceId: space.id, folderPath: normalizeFolder(folderPath) }; } function openUploadDialogForContext(menu: ContextMenuState | null) { const target = contextActionTarget(menu); setContextMenu(null); openDialog("upload", target); } function openCreateFolderDialogForContext(menu: ContextMenuState | null) { const target = contextActionTarget(menu); setContextMenu(null); openDialog("create-folder", target); } async function downloadContextSelection(menu: ContextMenuState | null) { const { fileIds, folderPaths } = selectedSetsForContext(menu); const sourceFiles = filesForContext(menu); setContextMenu(null); await downloadSelection(fileIds, folderPaths, sourceFiles); } async function deleteContextSelection(menu: ContextMenuState | null) { const { fileIds, folderPaths } = selectedSetsForContext(menu); const space = spaceForContext(menu); setContextMenu(null); await deleteSelected(fileIds, folderPaths, space); } function accessTargetForSelection(fileIds: Set, folderPaths: Set, spaceId: string): FileAccessExplanationTarget | null { if (fileIds.size === 1 && folderPaths.size === 0) { const fileId = Array.from(fileIds)[0]; const file = filesInSpace(spaceId).find((item) => item.id === fileId); return file ? { resourceType: "file", resourceId: file.id, label: file.display_path || file.filename, action: "files:file:read" } : null; } if (fileIds.size === 0 && folderPaths.size === 1) { const folderPath = normalizeFolder(Array.from(folderPaths)[0]); const folder = foldersInSpace(spaceId).find((item) => normalizeFolder(item.path) === folderPath); if (folder) { return { resourceType: "folder", resourceId: folder.id, label: folder.path, action: "files:file:read" }; } const space = findSpace(spaceId); const tenantId = (auth.active_tenant ?? auth.tenant)?.id; if (!space || !tenantId || !folderPath) return null; return { resourceType: "folder", resourceId: virtualFolderResourceId({ tenantId, ownerType: space.owner_type, ownerId: space.owner_id, path: folderPath }), label: folderPath, action: "files:file:read" }; } return null; } function accessTargetForContext(menu: ContextMenuState | null): FileAccessExplanationTarget | null { const { fileIds, folderPaths } = selectedSetsForContext(menu); return accessTargetForSelection(fileIds, folderPaths, menu?.spaceId ?? activeSpaceId); } function openAccessExplanationForContext(menu: ContextMenuState | null) { const target = accessTargetForContext(menu); setContextMenu(null); if (target) void openAccessExplanation(target); } async function openAccessExplanation(target: FileAccessExplanationTarget): Promise { if (!auth.user?.id) return; setAccessExplanationTarget(target); setResourceAccessExplanation(null); setResourceAccessLoading(true); setError(""); try { setResourceAccessExplanation(await fetchResourceAccessExplanation(settings, { userId: auth.user.id, resourceType: target.resourceType, resourceId: target.resourceId, action: target.action, tenantId: (auth.active_tenant ?? auth.tenant)?.id })); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setAccessExplanationTarget(null); } finally { setResourceAccessLoading(false); } } function openTransferDialogForContext(menu: ContextMenuState | null, mode: TransferMode) { const sets = selectedSetsForContext(menu); const space = spaceForContext(menu); const target = contextActionTarget(menu) ?? (activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null); setContextMenu(null); if (space) openTransferDialog(mode, space.id, sets, target ?? undefined); } function toolbarTarget(): FileActionTarget | null { return activeSpace ? { spaceId: activeSpace.id, folderPath: currentFolder } : null; } function parentFolder(path: string): string { const parts = normalizeFilePath(path).split("/").filter(Boolean); return normalizeFolder(parts.slice(0, -1).join("/")); } function filesInSpace(spaceId: string): ManagedFile[] { return filesBySpace[spaceId] ?? EMPTY_FILES; } function foldersInSpace(spaceId: string): FileFolder[] { return foldersBySpace[spaceId] ?? EMPTY_FOLDERS; } function pathExistsInTarget(target: FileActionTarget, path: string, options: {excludeFileId?: string;includeFolders?: boolean;} = {}): boolean { const normalized = normalizeFilePath(path); const targetFiles = filesInSpace(target.spaceId); if (targetFiles.some((file) => file.id !== options.excludeFileId && normalizeFilePath(file.display_path) === normalized)) return true; if (options.includeFolders && folderExistsInTarget(target, normalized)) return true; return false; } function folderExistsInTarget(target: FileActionTarget, path: string): boolean { const normalized = normalizeFolder(path); return foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === normalized); } function uploadConflicts(uploadedFiles: File[], target: FileActionTarget): FileConflictItem[] { const conflicts: FileConflictItem[] = []; const reserved = new Set(); uploadedFiles.forEach((file, index) => { const targetPath = normalizeFilePath(joinFolder(target.folderPath, file.name)); if (pathExistsInTarget(target, targetPath, { includeFolders: true }) || reserved.has(targetPath)) { conflicts.push({ id: `upload:${index}:${targetPath}`, kind: "file", label: file.name, targetPath, action: "rename", newPath: nextClientAvailablePath(target, targetPath, reserved) }); } reserved.add(targetPath); }); return conflicts; } function nextClientAvailablePath(target: FileActionTarget, desiredPath: string, reserved = new Set()): string { const desired = normalizeFilePath(desiredPath); if (!reserved.has(desired) && !pathExistsInTarget(target, desired, { includeFolders: true })) return desired; for (let counter = 1; counter < 10000; counter += 1) { const candidate = candidateRenamedPath(desired, counter); if (!reserved.has(candidate) && !pathExistsInTarget(target, candidate, { includeFolders: true })) return candidate; } return desired; } function transferWouldBeNoop(sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget): boolean { if (sourceSpaceId !== target.spaceId) return false; const targetFolder = normalizeFolder(target.folderPath); const sourceFiles = filesInSpace(sourceSpaceId); const selectedItems = sets.fileIds.size + sets.folderPaths.size; if (selectedItems === 0) return false; for (const fileId of sets.fileIds) { const file = sourceFiles.find((item) => item.id === fileId); if (!file || parentFolder(file.display_path) !== targetFolder) return false; } for (const folderPath of sets.folderPaths) { if (parentFolder(folderPath) !== targetFolder) return false; } return true; } function transferConflicts(mode: TransferMode, sourceSpaceId: string, sets: SelectionSets, target: FileActionTarget): FileConflictItem[] { const conflicts: FileConflictItem[] = []; const sourceFiles = filesInSpace(sourceSpaceId); const reserved = new Set(); const maybeAddConflict = (id: string, kind: "file" | "folder", label: string, targetPath: string, excludeFileId?: string) => { const normalized = kind === "folder" ? normalizeFolder(targetPath) : normalizeFilePath(targetPath); const exists = kind === "folder" ? foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === normalized) : pathExistsInTarget(target, normalized, { excludeFileId: mode === "move" && sourceSpaceId === target.spaceId ? excludeFileId : undefined }); if (exists || reserved.has(normalized)) { conflicts.push({ id, kind, label, targetPath: normalized, action: "rename", newPath: kind === "folder" ? nextClientAvailableFolderPath(target, normalized, reserved) : nextClientAvailablePath(target, normalized, reserved) }); } reserved.add(normalized); }; for (const fileId of sets.fileIds) { const file = sourceFiles.find((item) => item.id === fileId); if (!file) continue; const targetPath = normalizeFilePath(joinFolder(target.folderPath, file.filename)); if (mode === "move" && sourceSpaceId === target.spaceId && normalizeFilePath(file.display_path) === targetPath) continue; maybeAddConflict(`file:${file.id}`, "file", file.filename, targetPath, file.id); } for (const folderPathRaw of sets.folderPaths) { const folderPath = normalizeFolder(folderPathRaw); const folderName = lastPathSegment(folderPath); const targetFolderPath = normalizeFolder(joinFolder(target.folderPath, folderName)); if (mode === "move" && sourceSpaceId === target.spaceId && folderPath === targetFolderPath) continue; maybeAddConflict(`folder:${folderPath}`, "folder", folderName, targetFolderPath); for (const file of sourceFiles.filter((item) => isFileInFolder(item, folderPath))) { const relative = normalizeFilePath(file.display_path).slice(folderPath.length + 1); const targetPath = normalizeFilePath(joinFolder(targetFolderPath, relative)); if (mode === "move" && sourceSpaceId === target.spaceId && normalizeFilePath(file.display_path) === targetPath) continue; maybeAddConflict(`folder-file:${folderPath}:${file.id}`, "file", relative, targetPath, file.id); } } return conflicts; } function nextClientAvailableFolderPath(target: FileActionTarget, desiredPath: string, reserved = new Set()): string { const desired = normalizeFolder(desiredPath); if (!reserved.has(desired) && !foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === desired)) return desired; for (let counter = 1; counter < 10000; counter += 1) { const candidate = candidateRenamedPath(desired, counter); if (!reserved.has(candidate) && !foldersInSpace(target.spaceId).some((folder) => normalizeFolder(folder.path) === candidate)) return candidate; } return desired; } function updateConflictItem(id: string, patch: Partial) { setConflictDialog((current) => current ? { ...current, items: current.items.map((item) => item.id === id ? { ...item, ...patch } : item) } : current); } async function applyConflictDecision(strategy: ConflictStrategy | "review") { const currentConflict = conflictDialog; if (!currentConflict) return; if (strategy === "review" && !currentConflict.review) { setConflictDialog({ ...currentConflict, review: true }); return; } const reviewed = currentConflict.review || strategy === "review"; const conflictStrategy: ConflictStrategy = reviewed ? "reject" : strategy as ConflictStrategy; const conflictResolutions: ConflictResolution[] | undefined = reviewed ? currentConflict.items.map((item) => ({ target_path: item.targetPath, action: item.action, new_path: item.action === "rename" ? item.newPath : undefined })) : undefined; setConflictDialog(null); if (currentConflict.operation === "upload" && currentConflict.pendingUpload) { const pending = currentConflict.pendingUpload; let filesToUpload = pending.files; let uploadResolutions = conflictResolutions; if (reviewed) { const skippedPaths = new Set(currentConflict.items.filter((item) => item.action === "skip").map((item) => item.targetPath)); filesToUpload = pending.files.filter((file) => !skippedPaths.has(normalizeFilePath(joinFolder(pending.target.folderPath, file.name)))); uploadResolutions = conflictResolutions?.filter((item) => item.action !== "skip"); } if (filesToUpload.length === 0) { closeDialog(); setMessage("i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped.1166f3b5"); return; } await handleFilesUpload(filesToUpload, { target: pending.target, conflictStrategy, conflictResolutions: uploadResolutions, bypassConflictDialog: true }); return; } if (currentConflict.operation === "transfer" && currentConflict.pendingTransfer) { const pending = currentConflict.pendingTransfer; await runTransfer(pending.mode, pending.sourceSpaceId, pending.sets, pending.target, { conflictStrategy, conflictResolutions, bypassConflictDialog: true }); } } function toggleSort(column: SortColumn) { if (sortColumn === column) { setSortDirection((current) => current === "asc" ? "desc" : "asc"); return; } setSortColumn(column); setSortDirection("asc"); } function sortLabel(column: SortColumn, label: string): string { if (sortColumn !== column) return label; return `${label} ${sortDirection === "asc" ? "↑" : "↓"}`; } const noticeTone = message.startsWith("i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped") ? "warning" : message.startsWith("i18n:govoplan-files.uploading_value_file_s") || message === "i18n:govoplan-files.unpacking_zip_upload.35019691" || message === "i18n:govoplan-files.finalizing_upload.bcce936d" ? "info" : "success"; const managedSpaceBlocker = !activeSpace ? "Select a file space first." : activeSpaceIsConnector ? "This action writes managed storage and is unavailable in a read-only connector space." : ""; const workingBlocker = busy ? "Wait for the current file operation to finish." : ""; const uploadBlocker = workingBlocker || managedSpaceBlocker || (!canUpload ? "File upload permission is required." : ""); const organizeBlocker = workingBlocker || managedSpaceBlocker || (!canOrganize ? "File organization permission is required." : ""); const selectionBlocker = !hasSelection ? "Select at least one file or folder first." : ""; const downloadBlocker = workingBlocker || managedSpaceBlocker || (!canDownload ? "File download permission is required." : "") || (selectedDownloadFileIds.length === 0 ? "Select at least one downloadable file first." : ""); const shareBlocker = workingBlocker || managedSpaceBlocker || (!canShare ? "File sharing permission is required." : "") || (!shareManageableFile ? "Select one manageable file first." : ""); const accessExplanationBlocker = workingBlocker || (activeSpaceIsConnector ? "Access explanations apply to managed files and folders." : "") || (!canExplainResourceAccess ? "Permission to inspect resource access is required." : "") || (!accessExplainableTarget ? "Select one file or folder first." : ""); const deleteBlocker = workingBlocker || managedSpaceBlocker || (!canDelete ? "File deletion permission is required." : "") || selectionBlocker; const syncBlocker = workingBlocker || (!activeSpace ? "Select a file space first." : "") || (!canUpload ? "File upload permission is required to import synchronized content." : "") || (activeSpaceIsConnector && connectorSpaceSelectedItem?.kind !== "file" ? "Select one remote file to synchronize." : ""); const toolbar = {hasSelection && } {activeSpaceIsConnector && } {activeSpaceIsConnector && } ; const uploadBusyLabel = uploadPhase === "unpacking" ? "i18n:govoplan-files.unpacking_zip_archive.698095f4" : "i18n:govoplan-files.uploading_files.6536791d"; const uploadProgressLabel = uploadPhase === "unpacking" ? "i18n:govoplan-files.extracting_files_on_the_server.845a3c1a" : uploadProgress !== null && uploadProgress >= 100 ? "i18n:govoplan-files.finalizing_upload.bcce936d" : undefined; const visibleUploadProgress = uploadPhase === "unpacking" ? null : uploadProgress; const connectorLocationLabel = activeConnectorProfile ? [activeConnectorProfile.label, connectorLibraryId, connectorPath || (connectorLibraryId ? "i18n:govoplan-files.root.e96857c5" : "")].filter(Boolean).join(" / ") : "i18n:govoplan-files.connector.ba358306"; const connectorParentDisabled = !connectorProfileId || connectorLoading || busy || !connectorPath && !connectorLibraryId; const activeConnectorSpaceLibrary = activeSpace && activeSpaceIsConnector ? connectorSpaceLibrary(activeSpace) : null; const connectorSpaceLocationLabel = activeSpace && activeSpaceIsConnector ? [activeSpace.label, activeConnectorSpaceLibrary, currentFolder || (activeConnectorSpaceLibrary ? "i18n:govoplan-files.root.e96857c5" : "")].filter(Boolean).join(" / ") : ""; const connectorSpaceParentDisabled = !activeSpace || !activeSpaceIsConnector || connectorSpaceLoading || busy || !currentFolder && !activeConnectorSpaceLibrary; function renderExplorerEntry(entry: ExplorerEntry, rowIndex: number) { const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null; const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false; return entry.kind === "folder" ?
handleInternalDragStart(entry, event)} onDragEnd={() => {setInternalDrag(null);clearDropState();}} onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)} onDragLeave={clearDropState} onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)} onMouseDown={preventTextSelectionOnShift} onClick={(event) => handleEntrySelection(entry, event)} onDoubleClick={() => {if (!busy && activeSpace) openFolder(activeSpace.id, entry.path);}} onContextMenu={(event) => openContextMenu(event, "folder", entry)} onKeyDown={(event) => handleEntryKeyDown(entry, event)}>
{formatBytes(entry.totalSize)} {formatDate(entry.updatedAt)}
:
handleInternalDragStart(entry, event)} onDragEnd={() => {setInternalDrag(null);clearDropState();}} onMouseDown={preventTextSelectionOnShift} onClick={(event) => handleEntrySelection(entry, event)} onDoubleClick={() => {if (!busy) void downloadFile(settings, entry.file);}} onContextMenu={(event) => openContextMenu(event, "file", entry)} onKeyDown={(event) => handleEntryKeyDown(entry, event)}>
{formatBytes(entry.file.size_bytes)} {formatDate(entry.file.updated_at)}
; } function renderConnectorSpaceContent() { if (!activeSpace || !activeSpaceIsConnector) return null; return (
{connectorSpaceLocationLabel} {connectorSpaceError &&

{connectorSpaceError}

}
{connectorSpaceLoading &&
{i18nMessage("i18n:govoplan-files.loading_connector_space.356d83c6")}
} {!connectorSpaceLoading && activeConnectorSpaceItems.length === 0 &&
i18n:govoplan-files.no_connector_items.d634e023
} {!connectorSpaceLoading && activeConnectorSpaceItems.map((item) => { const isSelected = connectorSpaceSelectedItem?.kind === "file" && item.kind === "file" && connectorSpaceSelectedItem.path === item.path; const itemSize = item.size_bytes === null || item.size_bytes === undefined ? "" : formatBytes(item.size_bytes); const itemModified = item.modified_at ? formatDate(item.modified_at) : ""; const meta = item.kind === "file" ? [itemSize, itemModified].filter(Boolean).join(" · ") : item.kind === "library" ? "i18n:govoplan-files.library.b8100f5b" : "i18n:govoplan-files.folder.30baa249"; return ( ); })}
{connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.no_file_selected.f76f1c1c"} {connectorSpaceSelectedItem?.kind === "file" ? i18nMessage("i18n:govoplan-files.value_value.598aab59", { value0: connectorSpaceSelectedItem.path, value1: parentFolderPath(connectorSpaceRelativePath(activeSpace, connectorSpaceSelectedItem.path)) || "i18n:govoplan-files.root.e96857c5" }) : "i18n:govoplan-files.select_a_remote_file_to_sync_it_into_this_space_.8e58a5ae"}
); } const currentFolderDropTarget = toolbarTarget(); const currentFolderDropActive = currentFolderDropTarget ? dropTargetKey === dropTargetId(currentFolderDropTarget) : false; return (
{error && {error} } {message && !error && {message} }
{toolbar} void runPatternSearch(patternValue, caseSensitiveValue)} onClear={() => void clearSearch()} /> void runPropertyFilters(campaignUsage, auditRelevant)} onClear={clearPropertyFilters} />
{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary} {activeSpaceIsConnector ? i18nMessage("i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c", { value0: activeConnectorSpaceItems.filter((item) => item.kind === "folder" || item.kind === "library").length, value1: activeConnectorSpaceItems.filter((item) => item.kind === "file").length }) : i18nMessage("i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c", { value0: explorerEntries.filter((entry) => entry.kind === "folder").length, value1: explorerEntries.filter((entry) => entry.kind === "file").length })} {activeSpaceIsConnector && {[activeSpace?.provider?.toUpperCase() || "i18n:govoplan-files.connector.ba358306", activeSpace?.read_only ? "i18n:govoplan-files.read_only.9b19a5a2" : "i18n:govoplan-files.writable.dd35487a", `${activeSpace?.sync_mode || "manual"} sync`].join(" · ")} } {busy && i18n:govoplan-files.working.13b7bfca} {!activeSpaceIsConnector && unmatchedCount !== null && {unmatchedCount} i18n:govoplan-files.visible_file_s_were_not_matched.dc497e90} {!activeSpaceIsConnector && selectedFiles.some((file) => file.audit_relevant) && i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543}
{!activeSpaceIsConnector &&
}
{activeSpaceIsConnector ? renderConnectorSpaceContent() :
setFileListScrollTop(event.currentTarget.scrollTop)} onDragOver={handleCurrentFolderDragOver} onDragLeave={clearDropState} onDrop={(event) => void handleDropOnCurrentFolder(event)} onContextMenu={(event) => openContextMenu(event, "empty")} onClick={(event) => { if (event.target === event.currentTarget) applySelectionKeys(new Set()); }}>
{(() => { const normalizedCurrentFolder = normalizeFolder(currentFolder); const canOpenParent = Boolean(normalizedCurrentFolder && activeSpace); const targetParentFolder = parentFolderPath(normalizedCurrentFolder); return (
{if (canOpenParent) applySelectionKeys(new Set());}} onDoubleClick={() => { if (canOpenParent && activeSpace && !busy) openFolder(activeSpace.id, targetParentFolder); }} onKeyDown={(event) => { if (event.key === "Enter" && canOpenParent && activeSpace && !busy) { event.preventDefault(); openFolder(activeSpace.id, targetParentFolder); } }}>
); })()} {explorerEntries.length === 0 &&
i18n:govoplan-files.this_folder_is_empty_use_upload_or_create_folder.5977d784
} {virtualExplorerRows.topSpacerHeight > 0 && }
{busy &&
{i18nMessage("i18n:govoplan-files.loading_files.bfd27a7e")}
}
{conflictDialog && setConflictDialog(null)} onCancel={() => setConflictDialog(null)} onOverwrite={() => void applyConflictDecision("overwrite")} onRename={() => void applyConflictDecision("rename")} onReview={() => void applyConflictDecision("review")} onApplyReview={() => void applyConflictDecision("review")} onUpdateItem={updateConflictItem} /> } void performConfirmedDelete()} onCancel={() => setDeleteDialog(null)} /> void removeConnectorSpace()} onCancel={() => setConnectorSpaceRemovalTarget(null)} /> {contextMenu && 0 || selectedSetsForContext(contextMenu).folderPaths.size > 0} canCreateFolder={canOrganize} canUpload={canUpload} canDownload={canDownload} canOrganize={canOrganize} canDelete={canDelete} canExplainAccess={canExplainResourceAccess && Boolean(accessTargetForContext(contextMenu))} downloadLabel={downloadLabelForSets(selectedSetsForContext(contextMenu), contextMenu.spaceId ?? activeSpaceId)} onCreateFolder={() => openCreateFolderDialogForContext(contextMenu)} onUpload={() => openUploadDialogForContext(contextMenu)} onDownload={() => void downloadContextSelection(contextMenu)} onMove={() => openTransferDialogForContext(contextMenu, "move")} onCopy={() => openTransferDialogForContext(contextMenu, "copy")} onExplainAccess={() => openAccessExplanationForContext(contextMenu)} onDelete={() => void deleteContextSelection(contextMenu)} /> } {accessExplanationTarget && {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}}}>
} {shareDialogFile && setShareDialogFile(null)} onChanged={() => { if (activeSpace) void loadSpaceContents(activeSpace, { silent: true }); }} /> } {dialog === "upload" && {!archivePreview && <> {unpackZip &&

Supports ZIP, TAR, TAR.GZ, TAR.BZ2, and TAR.XZ. Nothing is written until you review and confirm the archive contents.

}
i18n:govoplan-files.destination_folder.f8ccb63b activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
setError(reason === "disabled" ? uploadRejectedReason(activeDialogTarget) : "The browser did not provide readable file data for this drop. Use the file picker, or drag local files from a file manager that exposes file contents to the browser.")} onFiles={(files) => handleFilesUpload(files, { target: activeDialogTarget || undefined })} />
} {archivePreview && archiveFile &&
{archiveFile.name} {archivePreview.archive_format.toUpperCase()} · {formatBytes(archivePreview.compressed_size_bytes)} compressed
{archivePreview.file_count} files {formatBytes(archivePreview.expanded_size_bytes)} expanded · {archivePreview.directory_count} folders
Destination {activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}
{archivePreview.requires_password && } {selectedArchivePaths.size} of {archivePreview.file_count} selected
{archivePreview.entries.map((entry) => { const selectableFiles = archiveFilesForEntry(entry); const depth = Math.max(0, entry.path.split("/").length - 1); return ( ); })}

Preview expires {formatDate(archivePreview.expires_at)}. The original archive is uploaded again only when you confirm.

{archivePreview.requires_password && }
}
} {dialog === "connector-sync" &&
i18n:govoplan-files.destination_folder.f8ccb63b activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
{connectorLocationLabel} {connectorError &&

{connectorError}

}
{connectorLoading &&
{i18nMessage("i18n:govoplan-files.loading_connector_files.02c876e0")}
} {!connectorLoading && connectorItems.length === 0 &&
i18n:govoplan-files.no_connector_items.d634e023
} {!connectorLoading && connectorItems.map((item) => { const isSelected = connectorSelectedItem?.kind === "file" && item.kind === "file" && connectorSelectedItem.path === item.path; const itemSize = item.size_bytes === null || item.size_bytes === undefined ? "" : formatBytes(item.size_bytes); const itemModified = item.modified_at ? formatDate(item.modified_at) : ""; const meta = item.kind === "file" ? [itemSize, itemModified].filter(Boolean).join(" · ") : item.kind === "library" ? "i18n:govoplan-files.library.b8100f5b" : "i18n:govoplan-files.folder.30baa249"; return ( ); })}
{connectorSelectedItem?.kind === "file" ? connectorSelectedItem.name : "i18n:govoplan-files.no_file_selected.f76f1c1c"} {connectorSelectedItem?.kind === "file" ? i18nMessage("i18n:govoplan-files.value_value.598aab59", { value0: connectorSelectedItem.path, value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" }) : "i18n:govoplan-files.select_a_remote_file_to_sync_into_the_destinatio.80477a47"}
} {dialog === "connector-space" &&
setConnectorSpaceLabel(event.target.value)} disabled={busy} placeholder={connectorSpaceSuggestedLabel()} />
{connectorLocationLabel} {connectorError &&

{connectorError}

}
{connectorLoading &&
{i18nMessage("i18n:govoplan-files.loading_connector_folders.dde26f3e")}
} {!connectorLoading && connectorItems.length === 0 &&
i18n:govoplan-files.no_connector_items.d634e023
} {!connectorLoading && connectorItems.map((item) => { const itemSize = item.size_bytes === null || item.size_bytes === undefined ? "" : formatBytes(item.size_bytes); const itemModified = item.modified_at ? formatDate(item.modified_at) : ""; const meta = item.kind === "file" ? [itemSize, itemModified].filter(Boolean).join(" · ") : item.kind === "library" ? "i18n:govoplan-files.library.b8100f5b" : "i18n:govoplan-files.folder.30baa249"; return ( ); })}
{connectorSpaceLabel.trim() || connectorSpaceSuggestedLabel()} {[activeConnectorProfile?.label, connectorLibraryId, connectorPath || (connectorLibraryId ? "i18n:govoplan-files.root.e96857c5" : "")].filter(Boolean).join(" / ") || "i18n:govoplan-files.connector_root.9027235c"}
} {dialog === "create-folder" && {setNewFolderName(event.target.value);setNewFolderError("");}} onKeyDown={(event) => {if (event.key === "Enter") void handleCreateFolder();}} /> i18n:govoplan-files.created_inside.3426a815 {activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}. {newFolderError && {newFolderError}}
i18n:govoplan-files.parent_folder.d8ed4c2a activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
} {dialog === "transfer" && transferDialogState &&

i18n:govoplan-files.choose_a_destination_space_and_folder_folders_ar.45158643

i18n:govoplan-files.destination_folder.f8ccb63b setTransferDialogState((current) => current ? { ...current, targetFolder: folderPath } : current)} />
} {dialog === "single-rename" && setSingleRenameName(event.target.value)} onKeyDown={(event) => {if (event.key === "Enter") void runSingleRename();}} />
} {dialog === "rename" &&

i18n:govoplan-files.bulk_rename_changes_managed_display_paths_only_i.8cf34a6b

{renameMode === "prefix" && setRenamePrefix(event.target.value)} />} {renameMode === "suffix" && setRenameSuffix(event.target.value)} />} {renameMode === "replace" && <> setRenameFind(event.target.value)} /> setRenameReplacement(event.target.value)} />} {selectedFolderPaths.size > 0 && }
{renamePreview && setRenamePreviewVisibleCount((current) => current + 20)} onShowFewer={() => setRenamePreviewVisibleCount(20)} /> }
}
); } function FilePropertyFilterRow({ campaignUsage, auditRelevant, busy, active, disabled, total, onApply, onClear }: { campaignUsage: "" | FileCampaignUsageFilter; auditRelevant: AuditRelevantFilter; busy: boolean; active: boolean; disabled: boolean; total: number | null; onApply: (campaignUsage: "" | FileCampaignUsageFilter, auditRelevant: AuditRelevantFilter) => void; onClear: () => void; }) { const [campaignUsageDraft, setCampaignUsageDraft] = useState(campaignUsage); const [auditRelevantDraft, setAuditRelevantDraft] = useState(auditRelevant); useEffect(() => { setCampaignUsageDraft(campaignUsage); }, [campaignUsage]); useEffect(() => { setAuditRelevantDraft(auditRelevant); }, [auditRelevant]); return (
); } function FileSearchRow({ value, caseSensitive, busy, searchActive, disabled, onSearch, onClear }: {value: string;caseSensitive: boolean;busy: boolean;searchActive: boolean;disabled: boolean;onSearch: (value: string, caseSensitive: boolean) => void;onClear: () => void;}) { const [draft, setDraft] = useState(value); const [caseSensitiveDraft, setCaseSensitiveDraft] = useState(caseSensitive); useEffect(() => { setDraft(value); }, [value]); useEffect(() => { setCaseSensitiveDraft(caseSensitive); }, [caseSensitive]); function clear() { setDraft(""); onClear(); } return (
); } function activeCampaignShares(file: ManagedFile) { return (file.shares ?? []).filter((share) => share.target_type === "campaign" && fileShareIsActive(share) ); } function fileShareIsActive(share: NonNullable[number]): boolean { if (typeof share.active === "boolean") return share.active; return !share.revoked_at && (!share.expires_at || new Date(share.expires_at).getTime() > Date.now()); } function activeCampaignShareCount(file: ManagedFile): number { return activeCampaignShares(file).length; } function campaignShareLabel(file: ManagedFile): string { const count = activeCampaignShareCount(file); if (count === 0) return "i18n:govoplan-files.no_campaign_links.4203c2be"; if (count === 1) return "i18n:govoplan-files.linked_to_1_campaign.5349694f"; return i18nMessage("i18n:govoplan-files.linked_to_value_campaigns.1ad7be94", { value0: count }); } function campaignShareTitle(file: ManagedFile): string | undefined { const shares = activeCampaignShares(file); if (shares.length === 0) return undefined; return shares.map((share) => share.target_id).join(", "); }