Files
govoplan-files/webui/src/features/files/FilesPage.tsx
2026-07-14 13:22:11 +02:00

2598 lines
121 KiB
TypeScript

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, MoveRight, Plus, RefreshCw, Search, Trash2, UploadCloud } from "lucide-react";
import {
Button,
ConfirmDialog,
DismissibleAlert,
FieldLabel,
FileDropZone,
FormField,
LoadingIndicator,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo, i18nMessage } from
"@govoplan/core-webui";
import {
bulkDeleteFiles,
bulkRenameFiles,
browseFileConnectorProfile,
createFileConnectorSpace,
createFolder,
deleteFolder,
downloadFile,
downloadFilesAsZip,
fetchResourceAccessExplanation,
listFilesDelta,
listFileConnectorProfiles,
listFileSpaces,
listManagedFileSnapshot,
resolveFilePatterns,
syncFileConnectorFile,
transferFiles,
uploadFiles,
virtualFolderResourceId,
type ConflictResolution,
type ConflictStrategy,
type FileConnectorBrowseItem,
type FileConnectorProfile,
type FileDeltaResponse,
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 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 FileAccessExplanationTarget = {
resourceType: "file" | "folder";
resourceId: string;
label: string;
action: string;
};
const DEFAULT_FILE_LIST_ROW_HEIGHT = 58;
const FILE_LIST_OVERSCAN_ROWS = 8;
export default function FilesPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
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 [spaces, setSpaces] = useState<FileSpace[]>(EMPTY_SPACES);
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<Record<string, ManagedFile[]>>({});
const [foldersBySpace, setFoldersBySpace] = useState<Record<string, FileFolder[]>>({});
const [fileDeltaWatermarksBySpace, setFileDeltaWatermarksBySpace] = useState<Record<string, string>>({});
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<ManagedFile[] | null>(null);
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [unmatchedCount, setUnmatchedCount] = useState<number | null>(null);
const [unpackZip, setUnpackZip] = useState(false);
const [busy, setBusy] = useState(false);
const [uploadActive, setUploadActive] = useState(false);
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [connectorProfiles, setConnectorProfiles] = useState<FileConnectorProfile[]>([]);
const [connectorProfileId, setConnectorProfileId] = useState("");
const [connectorLibraryId, setConnectorLibraryId] = useState<string | null>(null);
const [connectorPath, setConnectorPath] = useState("");
const [connectorItems, setConnectorItems] = useState<FileConnectorBrowseItem[]>([]);
const [connectorSelectedItem, setConnectorSelectedItem] = useState<FileConnectorBrowseItem | null>(null);
const [connectorLoading, setConnectorLoading] = useState(false);
const [connectorError, setConnectorError] = useState("");
const [connectorSpaceLabel, setConnectorSpaceLabel] = useState("");
const [connectorSpaceOwnerSpaceId, setConnectorSpaceOwnerSpaceId] = useState("");
const [connectorSpaceItemsBySpace, setConnectorSpaceItemsBySpace] = useState<Record<string, FileConnectorBrowseItem[]>>({});
const [connectorSpaceLibraryBySpace, setConnectorSpaceLibraryBySpace] = useState<Record<string, string | null>>({});
const [connectorSpaceSelectedItem, setConnectorSpaceSelectedItem] = useState<FileConnectorBrowseItem | null>(null);
const [connectorSpaceLoading, setConnectorSpaceLoading] = useState(false);
const [connectorSpaceError, setConnectorSpaceError] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [accessExplanationTarget, setAccessExplanationTarget] = useState<FileAccessExplanationTarget | null>(null);
const [resourceAccessExplanation, setResourceAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
const [resourceAccessLoading, setResourceAccessLoading] = useState(false);
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<RenameMode>("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<RenameResponse | null>(null);
const visibleFiles = searchActive && searchResults ? searchResults : files;
const explorerEntries = useMemo(() => {
const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive);
return sortExplorerEntries(entries, sortColumn, sortDirection);
}, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]);
const fileListViewportRef = useRef<HTMLDivElement | null>(null);
const fileListMeasureRowRef = useRef<HTMLDivElement | null>(null);
const managedSpaceLoadsInFlightRef = useRef<Set<string>>(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 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, searchActive, searchResults, sortColumn, sortDirection]);
async function loadSpaces() {
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 {
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);
}
} 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(() => {
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);
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);
}
setUnmatchedCount(null);
setRenamePreview(null);
}
function currentActionTarget(): FileActionTarget | null {
return activeDialogTarget;
}
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("");
}
openRawDialog(kind, target);
}
function closeDialog() {
closeRawDialog();
setNewFolderError("");
setUploadActive(false);
setUploadPhase("idle");
setUploadProgress(null);
setConnectorError("");
setConnectorSelectedItem(null);
setConnectorSpaceLabel("");
}
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 handleFilesUpload(fileList: FileList | File[], options: {conflictStrategy?: ConflictStrategy;conflictResolutions?: ConflictResolution[];bypassConflictDialog?: boolean;target?: FileActionTarget;} = {}) {
if (!canUpload) return;
const target = options.target ?? currentActionTarget();
const targetSpace = target ? findSpace(target.spaceId) : null;
if (!target || !targetSpace || isConnectorSpace(targetSpace)) return;
const selected = Array.from(fileList);
if (selected.length === 0) 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;
}
}
const uploadsZipArchive = unpackZip && selected.some((file) => file.name.toLowerCase().endsWith(".zip"));
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,
unpack_zip: unpackZip,
conflict_strategy: options.conflictStrategy ?? "reject",
conflict_resolutions: options.conflictResolutions,
onProgress: ({ percentage }) => {
setUploadProgress(percentage);
if (percentage !== null && percentage >= 100) {
setUploadPhase(uploadsZipArchive ? "unpacking" : "finalizing");
setMessage(uploadsZipArchive ? "i18n:govoplan-files.unpacking_zip_upload.35019691" : "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) {
const previousTarget = dialogTarget;
setDialogTarget(target);
try {
await handleFilesUpload(fileList);
} finally {
setDialogTarget(previousTarget);
}
}
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);
}
}
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());
}
function deleteSelected(
fileIds: Set<string> = selectedFileIds,
folderPaths: Set<string> = 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<string> = selectedFileIds,
folderPaths: Set<string> = 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<string>, 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<HTMLElement>) {
if (event.key === " " || event.key === "Spacebar") {
event.preventDefault();
handleEntrySelection(entry, event as unknown as ReactMouseEvent<HTMLElement>);
}
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<HTMLElement>) {
if (!canOrganize || !activeSpace || activeSpaceIsConnector) {event.preventDefault();return;}
const sets = dragSetsForEntry(entry);
if (!currentSelectionKeys().has(entrySelectionKey(entry))) {
applySelectionKeys(new Set<EntrySelectionKey>([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<HTMLElement>) {
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<HTMLElement>): 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<HTMLElement>, 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 ? "copy" : "none";
setDropTargetKey(canUpload ? dropTargetId(target) : "");
}
async function handleDropOnTarget(event: ReactDragEvent<HTMLElement>, target: FileActionTarget) {
event.preventDefault();
event.stopPropagation();
setDragActive(false);
setDropTargetKey("");
if (isConnectorSpace(findSpace(target.spaceId))) 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 (canUpload && event.dataTransfer.files.length > 0) {
await uploadExternalFilesToTarget(event.dataTransfer.files, 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<HTMLElement>, 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<EntrySelectionKey>([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<HTMLElement>, 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<HTMLElement>) {
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<string>, folderPaths: Set<string>, 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<void> {
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<string>();
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>()): 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<string>();
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>()): 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<FileConflictItem>) {
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 toolbar =
<div className="file-manager-toolbar" aria-label="i18n:govoplan-files.file_actions.9e1b94c5">
<Button variant="primary" onClick={() => openDialog("upload", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canUpload}><UploadCloud size={16} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</Button>
<Button
onClick={() => activeSpaceIsConnector ? void syncConnectorSpaceSelection() : void openConnectorSyncDialog(toolbarTarget())}
disabled={busy || !activeSpace || !canUpload || activeSpaceIsConnector && connectorSpaceSelectedItem?.kind !== "file"}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.sync.905f6309
</Button>
<Button onClick={() => void openConnectorSpaceDialog()} disabled={busy || !canOrganize}>
<Link2 size={16} aria-hidden="true" /> i18n:govoplan-files.add_space.e4d674d4
</Button>
<Button onClick={() => void downloadSelection()} disabled={busy || activeSpaceIsConnector || !canDownload || selectedDownloadFileIds.length === 0}><Download size={16} aria-hidden="true" /> {downloadLabel}</Button>
<Button onClick={() => openDialog("create-folder", toolbarTarget())} disabled={busy || !activeSpace || activeSpaceIsConnector || !canOrganize}><Plus size={16} aria-hidden="true" /> i18n:govoplan-files.create_folder.97bafaba</Button>
<Button onClick={() => openTransferDialog("move")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><MoveRight size={16} aria-hidden="true" /> i18n:govoplan-files.move.76cdb950</Button>
<Button onClick={() => openTransferDialog("copy")} disabled={busy || activeSpaceIsConnector || !canOrganize || !hasSelection}><Copy size={16} aria-hidden="true" /> i18n:govoplan-files.copy.af74f7c5</Button>
{hasSelection && <Button onClick={openRenameDialog} disabled={busy || activeSpaceIsConnector || !canOrganize}>{selectedEntryCount === 1 ? "i18n:govoplan-files.rename.d3f4cb89" : "i18n:govoplan-files.bulk_rename.7dcaa624"}</Button>}
<Button onClick={() => accessExplainableTarget && void openAccessExplanation(accessExplainableTarget)} disabled={busy || activeSpaceIsConnector || !canExplainResourceAccess || !accessExplainableTarget}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</Button>
<Button variant="danger" onClick={() => void deleteSelected()} disabled={busy || activeSpaceIsConnector || !canDelete || !hasSelection}><Trash2 size={16} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</Button>
{activeSpaceIsConnector &&
<Button onClick={() => activeSpace && void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading || !activeSpace}>
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc
</Button>
}
</div>;
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" ?
<div
key={entry.id}
className={`file-list-row folder-row ${isEntrySelected(entry) ? "is-selected" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
role="row"
aria-rowindex={rowIndex}
tabIndex={0}
draggable
onDragStart={(event) => 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)}>
<div className="file-list-name-cell">
<div className="file-list-name">
<Folder className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{entry.name}</strong><small>{folderContentLabel(entry)}</small></span>
</div>
</div>
<span>{formatBytes(entry.totalSize)}</span>
<span className="file-row-tail"><span>{formatDate(entry.updatedAt)}</span></span>
</div> :
<div
key={entry.id}
className={`file-list-row file-row ${isEntrySelected(entry) ? "is-selected" : ""}`}
role="row"
aria-rowindex={rowIndex}
tabIndex={0}
draggable
onDragStart={(event) => 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)}>
<div className="file-list-name-cell">
<div className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span>
<strong>{searchActive ? entry.file.display_path : entry.file.filename}</strong>
<small
className={`file-linkage-status ${activeCampaignShareCount(entry.file) > 0 ? "is-linked" : ""}`}
title={campaignShareTitle(entry.file)}>
{activeCampaignShareCount(entry.file) > 0 && <Link2 size={12} aria-hidden="true" />}
{campaignShareLabel(entry.file)}
</small>
</span>
</div>
</div>
<span>{formatBytes(entry.file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(entry.file.updated_at)}</span></span>
</div>;
}
function renderConnectorSpaceContent() {
if (!activeSpace || !activeSpaceIsConnector) return null;
return (
<div className="connector-space-panel">
<div className="connector-browser connector-space-browser">
<div className="connector-browser-toolbar">
<Button onClick={browseConnectorSpaceParent} disabled={connectorSpaceParentDisabled}><ArrowUp size={15} aria-hidden="true" /> i18n:govoplan-files.up.2038bdec</Button>
<span className="connector-browser-path" title={connectorSpaceLocationLabel}>{connectorSpaceLocationLabel}</span>
<Button onClick={() => void loadConnectorSpaceContents(activeSpace)} disabled={busy || connectorSpaceLoading}><RefreshCw size={15} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc</Button>
</div>
{connectorSpaceError && <p className="field-error connector-browser-error">{connectorSpaceError}</p>}
<div className="connector-browser-list" role="list" aria-label="i18n:govoplan-files.connector_space_files.dbb0ab24">
{connectorSpaceLoading &&
<div className="connector-browser-empty"><LoadingIndicator label="i18n:govoplan-files.loading_connector_space.c176e6b8" /> <span>{i18nMessage("i18n:govoplan-files.loading_connector_space.356d83c6")}</span></div>
}
{!connectorSpaceLoading && activeConnectorSpaceItems.length === 0 &&
<div className="connector-browser-empty">i18n:govoplan-files.no_connector_items.d634e023</div>
}
{!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 (
<button
type="button"
key={`${item.kind}:${item.external_id || item.path}`}
className={`connector-browser-row ${isSelected ? "is-selected" : ""}`}
onClick={() => openConnectorSpaceItem(item)}
onDoubleClick={() => {
if (item.kind === "file") void syncConnectorSpaceSelection(item);
}}
disabled={busy}>
{item.kind === "file" ? <File size={18} aria-hidden="true" /> : <Folder size={18} aria-hidden="true" />}
<span>
<strong>{item.name}</strong>
<small>{meta}</small>
</span>
</button>);
})}
</div>
</div>
<div className="connector-sync-selection">
<strong>{connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.no_file_selected.f76f1c1c"}</strong>
<small>
{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"}
</small>
</div>
</div>);
}
return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen">
{error &&
<DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>
}
{message && !error &&
<DismissibleAlert tone={noticeTone} resetKey={message} floating>{message}</DismissibleAlert>
}
<div className={`file-manager-shell ${busy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="i18n:govoplan-files.file_spaces_and_folders.443d2056">
<div className="file-tree-heading">i18n:govoplan-files.spaces.9b584b52</div>
<div className="file-tree-list" onContextMenu={openTreeEmptyContextMenu}>
{spaces.length === 0 && <p className="muted small-text">i18n:govoplan-files.no_file_spaces_available.a81fa3d0</p>}
{spaces.map((space) => {
const isActiveRoot = activeSpaceId === space.id && currentFolder === "";
const spaceIsConnector = isConnectorSpace(space);
const nodes = spaceIsConnector ? [] : buildFolderTree(filesBySpace[space.id] ?? EMPTY_FILES, foldersBySpace[space.id] ?? EMPTY_FOLDERS);
const rootTarget = { spaceId: space.id, folderPath: "" };
const rootDropActive = dropTargetKey === dropTargetId(rootTarget);
return (
<div className="file-tree-space" key={space.id}>
<button
type="button"
className={`file-tree-node file-tree-root ${isActiveRoot ? "is-active" : ""} ${rootDropActive && !spaceIsConnector ? "is-drop-target" : ""}`}
onClick={() => openFolder(space.id, "")}
onContextMenu={(event) => {if (!spaceIsConnector) openTreeContextMenu(event, space.id, "");}}
onDragOver={(event) => {if (!spaceIsConnector) handleDropTargetDragOver(event, rootTarget);}}
onDragLeave={clearDropState}
onDrop={(event) => {if (!spaceIsConnector) void handleDropOnTarget(event, rootTarget);}}
disabled={busy}>
{spaceIsConnector ? <Link2 size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
<span>{space.label}</span>
</button>
{!spaceIsConnector &&
<FolderTree
nodes={nodes}
activeSpaceId={activeSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
dropTargetKey={dropTargetKey}
onOpen={openFolder}
expandedKeys={expandedTreeNodes}
onToggle={toggleTreeFolder}
onContextMenu={openTreeContextMenu}
onDragOverTarget={handleDropTargetDragOver}
onDropOnTarget={handleDropOnTarget}
onClearDropState={clearDropState}
onRequestDragExpand={scheduleTreeDragExpand}
onDragStartFolder={handleTreeFolderDragStart}
onDragEndFolder={() => {setInternalDrag(null);clearDropState();}}
disabled={busy} />
}
</div>);
})}
</div>
</aside>
<section className="file-list-panel" aria-label="i18n:govoplan-files.current_folder_contents.f9a24fa8">
<div className="file-list-sticky">
{toolbar}
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-files.current_folder.5aeab2f0">
<button type="button" className="file-breadcrumb" onClick={() => activeSpace && openFolder(activeSpace.id, "")} disabled={busy || !activeSpace}>
<Home size={15} aria-hidden="true" /> {activeSpace?.label || "i18n:govoplan-files.files.6ce6c512"}
</button>
{folderCrumbs.map((crumb) =>
<span className="file-breadcrumb-segment" key={crumb.path}>
<ChevronRight size={14} aria-hidden="true" />
<button type="button" className="file-breadcrumb" onClick={() => activeSpace && openFolder(activeSpace.id, crumb.path)} disabled={busy}>{crumb.name}</button>
</span>
)}
</nav>
<FileSearchRow
value={searchPattern}
caseSensitive={searchCaseSensitive}
busy={busy}
searchActive={searchActive}
disabled={!activeSpace || activeSpaceIsConnector}
onSearch={(patternValue, caseSensitiveValue) => void runPatternSearch(patternValue, caseSensitiveValue)}
onClear={() => void clearSearch()} />
<div className="file-list-meta">
<span>{activeSpaceIsConnector ? connectorSpaceSelectedItem?.kind === "file" ? connectorSpaceSelectedItem.name : "i18n:govoplan-files.remote_connector_space.d8956863" : selectedSummary}</span>
<span>
{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 })}
</span>
{activeSpaceIsConnector &&
<span>{[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(" · ")}</span>
}
{busy && <span>i18n:govoplan-files.working.13b7bfca</span>}
{!activeSpaceIsConnector && unmatchedCount !== null && <span>{unmatchedCount} i18n:govoplan-files.visible_file_s_were_not_matched.dc497e90</span>}
{!activeSpaceIsConnector && selectedFiles.some((file) => file.audit_relevant) && <span>i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543</span>}
</div>
{!activeSpaceIsConnector &&
<div className="file-list-table-head" role="row">
<button type="button" onClick={() => toggleSort("name")}>{sortLabel("name", "i18n:govoplan-files.name.709a2322")}</button>
<button type="button" onClick={() => toggleSort("size")}>{sortLabel("size", "i18n:govoplan-files.size.b7152342")}</button>
<button type="button" onClick={() => toggleSort("modified")}>{sortLabel("modified", "i18n:govoplan-files.modified.19a532c8")}</button>
</div>
}
</div>
{activeSpaceIsConnector ?
renderConnectorSpaceContent() :
<div
className="file-list-drop-target"
ref={fileListViewportRef}
onScroll={(event) => setFileListScrollTop(event.currentTarget.scrollTop)}
onContextMenu={(event) => openContextMenu(event, "empty")}
onClick={(event) => {
if (event.target === event.currentTarget) applySelectionKeys(new Set<EntrySelectionKey>());
}}>
<div className="file-list-table" role="table" aria-label="i18n:govoplan-files.managed_files.4dd6ad11" aria-rowcount={explorerEntries.length + 1}>
{(() => {
const normalizedCurrentFolder = normalizeFolder(currentFolder);
const canOpenParent = Boolean(normalizedCurrentFolder && activeSpace);
const targetParentFolder = parentFolderPath(normalizedCurrentFolder);
return (
<div
ref={fileListMeasureRowRef}
className={`file-list-row folder-row file-parent-row ${canOpenParent ? "" : "is-disabled"}`}
role="row"
aria-rowindex={1}
tabIndex={canOpenParent ? 0 : -1}
aria-disabled={!canOpenParent}
aria-label={canOpenParent ? i18nMessage("i18n:govoplan-files.open_parent_folder_value.8030a7c7", { value0: targetParentFolder || activeSpace?.label || "root" }) : "i18n:govoplan-files.already_at_the_root_folder.1238f110"}
onMouseDown={preventTextSelectionOnShift}
onClick={() => {if (canOpenParent) applySelectionKeys(new Set<EntrySelectionKey>());}}
onDoubleClick={() => {
if (canOpenParent && activeSpace && !busy) openFolder(activeSpace.id, targetParentFolder);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && canOpenParent && activeSpace && !busy) {
event.preventDefault();
openFolder(activeSpace.id, targetParentFolder);
}
}}>
<div className="file-list-name-cell">
<div className="file-list-name">
<Folder className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>..</strong><small>i18n:govoplan-files.parent_folder.d8ed4c2a</small></span>
</div>
</div>
<span aria-hidden="true">-</span>
<span className="file-row-tail" aria-hidden="true"><span>-</span></span>
</div>);
})()}
{explorerEntries.length === 0 &&
<div className="file-list-empty">i18n:govoplan-files.this_folder_is_empty_use_upload_or_create_folder.5977d784</div>
}
{virtualExplorerRows.topSpacerHeight > 0 &&
<div className="file-list-virtual-spacer" style={{ height: virtualExplorerRows.topSpacerHeight }} aria-hidden="true" />
}
{virtualExplorerRows.entries.map((entry, index) => renderExplorerEntry(entry, virtualExplorerRows.startIndex + index + 2))}
{virtualExplorerRows.bottomSpacerHeight > 0 &&
<div className="file-list-virtual-spacer" style={{ height: virtualExplorerRows.bottomSpacerHeight }} aria-hidden="true" />
}
</div>
</div>
}
</section>
{busy &&
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
<div className="loading-frame-panel"><LoadingIndicator label="i18n:govoplan-files.loading_files.3b142382" /> <span>{i18nMessage("i18n:govoplan-files.loading_files.bfd27a7e")}</span></div>
</div>
}
</div>
{conflictDialog &&
<FileConflictDialog
state={conflictDialog}
busy={busy}
onClose={() => setConflictDialog(null)}
onCancel={() => setConflictDialog(null)}
onOverwrite={() => void applyConflictDecision("overwrite")}
onRename={() => void applyConflictDecision("rename")}
onReview={() => void applyConflictDecision("review")}
onApplyReview={() => void applyConflictDecision("review")}
onUpdateItem={updateConflictItem} />
}
<ConfirmDialog
open={Boolean(deleteDialog)}
title={deleteDialog?.title || "i18n:govoplan-files.delete_selected_item_s.4b6a0023"}
message={deleteDialog?.message || "i18n:govoplan-files.delete_selected_item_s.4b6a0023"}
confirmLabel="i18n:govoplan-files.delete.f6fdbe48"
cancelLabel="i18n:govoplan-files.cancel.77dfd213"
tone="danger"
busy={busy}
onConfirm={() => void performConfirmedDelete()}
onCancel={() => setDeleteDialog(null)} />
{contextMenu &&
<FileContextMenu
menu={contextMenu}
hasSelection={selectedSetsForContext(contextMenu).fileIds.size > 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 &&
<FileDialog title="i18n:govoplan-files.access_explanation.75ee7f62" onClose={() => {if (!resourceAccessLoading) {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}}}>
<ResourceAccessExplanationContent
loading={resourceAccessLoading}
explanation={resourceAccessExplanation}
fallbackResourceLabel={accessExplanationTarget.label} />
<div className="button-row compact-actions align-end">
<Button onClick={() => {setAccessExplanationTarget(null);setResourceAccessExplanation(null);}} disabled={resourceAccessLoading}>i18n:govoplan-files.close.bbfa773e</Button>
</div>
</FileDialog>
}
{dialog === "upload" &&
<FileDialog title={i18nMessage("i18n:govoplan-files.upload_to_value_value.b83a34b4", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
<FileDropZone
disabled={busy || !activeDialogSpace || !canUpload}
busy={uploadActive}
progress={visibleUploadProgress}
busyLabel={uploadBusyLabel}
progressLabel={uploadProgressLabel}
note={`Files are uploaded into ${activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.`}
onFiles={(files) => handleFilesUpload(files)} />
<ToggleSwitch label="i18n:govoplan-files.unpack_zip_uploads.256fead4" checked={unpackZip} onChange={setUnpackZip} disabled={busy} />
<div className="field-block">
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
<TransferFolderSelector
space={activeDialogSpace}
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
selectedFolder={activeDialogTarget?.folderPath || ""}
disabled={busy || !activeDialogSpace}
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
</div>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
</div>
</FileDialog>
}
{dialog === "connector-sync" &&
<FileDialog title={i18nMessage("i18n:govoplan-files.sync_to_value_value.29c362ea", { value0: activeDialogSpace?.label || "i18n:govoplan-files.files.6ce6c512", value1: activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5" })} onClose={closeDialog}>
<div className="connector-sync-grid">
<FormField label="i18n:govoplan-files.connector_profile.9e9cd317">
<select value={connectorProfileId} onChange={(event) => handleConnectorProfileChange(event.target.value)} disabled={busy || connectorLoading}>
{connectorProfiles.length === 0 && <option value="">i18n:govoplan-files.no_connector_profiles.03c730ee</option>}
{connectorProfiles.map((profile) =>
<option key={profile.id} value={profile.id}>{profile.label}</option>
)}
</select>
</FormField>
<div className="field-block">
<FieldLabel className="form-label">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
<TransferFolderSelector
space={activeDialogSpace}
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
selectedFolder={activeDialogTarget?.folderPath || ""}
disabled={busy || !activeDialogSpace}
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
</div>
</div>
<div className="connector-browser">
<div className="connector-browser-toolbar">
<Button onClick={browseConnectorParent} disabled={connectorParentDisabled}><ArrowUp size={15} aria-hidden="true" /> i18n:govoplan-files.up.2038bdec</Button>
<span className="connector-browser-path" title={connectorLocationLabel}>{connectorLocationLabel}</span>
<Button onClick={() => connectorProfileId && void browseConnector(connectorProfileId, connectorPath, connectorLibraryId)} disabled={busy || connectorLoading || !connectorProfileId}><RefreshCw size={15} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc</Button>
</div>
{connectorError && <p className="field-error connector-browser-error">{connectorError}</p>}
<div className="connector-browser-list" role="list" aria-label="i18n:govoplan-files.connector_files.8f351f5d">
{connectorLoading &&
<div className="connector-browser-empty"><LoadingIndicator label="i18n:govoplan-files.loading_connector_files.e5b0871d" /> <span>{i18nMessage("i18n:govoplan-files.loading_connector_files.02c876e0")}</span></div>
}
{!connectorLoading && connectorItems.length === 0 &&
<div className="connector-browser-empty">i18n:govoplan-files.no_connector_items.d634e023</div>
}
{!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 (
<button
type="button"
key={`${item.kind}:${item.external_id || item.path}`}
className={`connector-browser-row ${isSelected ? "is-selected" : ""}`}
onClick={() => openConnectorItem(item)}
disabled={busy}>
{item.kind === "file" ? <File size={18} aria-hidden="true" /> : <Folder size={18} aria-hidden="true" />}
<span>
<strong>{item.name}</strong>
<small>{meta}</small>
</span>
</button>);
})}
</div>
</div>
<div className="connector-sync-selection">
<strong>{connectorSelectedItem?.kind === "file" ? connectorSelectedItem.name : "i18n:govoplan-files.no_file_selected.f76f1c1c"}</strong>
<small>
{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"}
</small>
</div>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void syncConnectorSelection()} disabled={busy || connectorLoading || !connectorSelectedItem || connectorSelectedItem.kind !== "file" || !activeDialogSpace}>i18n:govoplan-files.sync.905f6309</Button>
</div>
</FileDialog>
}
{dialog === "connector-space" &&
<FileDialog title="i18n:govoplan-files.add_connector_space.aa6bdbd6" onClose={closeDialog}>
<div className="connector-sync-grid">
<FormField label="i18n:govoplan-files.connector_profile.9e9cd317">
<select value={connectorProfileId} onChange={(event) => handleConnectorProfileChange(event.target.value)} disabled={busy || connectorLoading}>
{connectorProfiles.length === 0 && <option value="">i18n:govoplan-files.no_connector_profiles.03c730ee</option>}
{connectorProfiles.map((profile) =>
<option key={profile.id} value={profile.id}>{profile.label}</option>
)}
</select>
</FormField>
<FormField label="i18n:govoplan-files.owner_space.364c4b62">
<select value={connectorSpaceOwnerSpaceId} onChange={(event) => setConnectorSpaceOwnerSpaceId(event.target.value)} disabled={busy}>
{spaces.filter((space) => !isConnectorSpace(space)).map((space) =>
<option key={space.id} value={space.id}>{space.label}</option>
)}
</select>
</FormField>
<FormField label="i18n:govoplan-files.space_label.ec892726">
<input value={connectorSpaceLabel} onChange={(event) => setConnectorSpaceLabel(event.target.value)} disabled={busy} placeholder={connectorSpaceSuggestedLabel()} />
</FormField>
</div>
<div className="connector-browser">
<div className="connector-browser-toolbar">
<Button onClick={browseConnectorParent} disabled={connectorParentDisabled}><ArrowUp size={15} aria-hidden="true" /> i18n:govoplan-files.up.2038bdec</Button>
<span className="connector-browser-path" title={connectorLocationLabel}>{connectorLocationLabel}</span>
<Button onClick={() => connectorProfileId && void browseConnector(connectorProfileId, connectorPath, connectorLibraryId)} disabled={busy || connectorLoading || !connectorProfileId}><RefreshCw size={15} aria-hidden="true" /> i18n:govoplan-files.refresh.56e3badc</Button>
</div>
{connectorError && <p className="field-error connector-browser-error">{connectorError}</p>}
<div className="connector-browser-list" role="list" aria-label="i18n:govoplan-files.connector_folders.960cbb03">
{connectorLoading &&
<div className="connector-browser-empty"><LoadingIndicator label="i18n:govoplan-files.loading_connector_folders.426de3b6" /> <span>{i18nMessage("i18n:govoplan-files.loading_connector_folders.dde26f3e")}</span></div>
}
{!connectorLoading && connectorItems.length === 0 &&
<div className="connector-browser-empty">i18n:govoplan-files.no_connector_items.d634e023</div>
}
{!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 (
<button
type="button"
key={`${item.kind}:${item.external_id || item.path}`}
className="connector-browser-row"
onClick={() => openConnectorItem(item)}
disabled={busy}>
{item.kind === "file" ? <File size={18} aria-hidden="true" /> : <Folder size={18} aria-hidden="true" />}
<span>
<strong>{item.name}</strong>
<small>{meta}</small>
</span>
</button>);
})}
</div>
</div>
<div className="connector-sync-selection">
<strong>{connectorSpaceLabel.trim() || connectorSpaceSuggestedLabel()}</strong>
<small>{[activeConnectorProfile?.label, connectorLibraryId, connectorPath || (connectorLibraryId ? "i18n:govoplan-files.root.e96857c5" : "")].filter(Boolean).join(" / ") || "i18n:govoplan-files.connector_root.9027235c"}</small>
</div>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void createConnectorSpaceFromDialog()} disabled={busy || connectorLoading || !connectorProfileId || !connectorSpaceOwnerSpaceId}>i18n:govoplan-files.create_space.b50606b4</Button>
</div>
</FileDialog>
}
{dialog === "create-folder" &&
<FileDialog title="i18n:govoplan-files.create_folder.e59f63fa" onClose={closeDialog}>
<FormField label="i18n:govoplan-files.folder_name.b2ce023b" help="i18n:govoplan-files.create_a_folder_below_the_selected_destination.9041ee76">
<input autoFocus value={newFolderName} placeholder="i18n:govoplan-files.e_g_invoices.77a73bd1" onChange={(event) => {setNewFolderName(event.target.value);setNewFolderError("");}} onKeyDown={(event) => {if (event.key === "Enter") void handleCreateFolder();}} />
<small>i18n:govoplan-files.created_inside.3426a815 {activeDialogTarget?.folderPath || "i18n:govoplan-files.root.e96857c5"}.</small>
{newFolderError && <small className="field-error">{newFolderError}</small>}
</FormField>
<div className="field-block">
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_parent_folder_for_the_new_subfolder.2fcb6580">i18n:govoplan-files.parent_folder.d8ed4c2a</FieldLabel>
<TransferFolderSelector
space={activeDialogSpace}
nodes={activeDialogTarget ? buildFolderTree(filesBySpace[activeDialogTarget.spaceId] ?? EMPTY_FILES, foldersBySpace[activeDialogTarget.spaceId] ?? EMPTY_FOLDERS) : []}
selectedFolder={activeDialogTarget?.folderPath || ""}
disabled={busy || !activeDialogSpace}
onSelect={(folderPath) => activeDialogTarget && updateActiveDialogFolder(activeDialogTarget.spaceId, folderPath)} />
</div>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void handleCreateFolder()} disabled={busy || !newFolderName.trim()}>i18n:govoplan-files.create_folder.97bafaba</Button>
</div>
</FileDialog>
}
{dialog === "transfer" && transferDialogState &&
<FileDialog title={`${transferMode === "copy" ? "i18n:govoplan-files.copy.af74f7c5" : "i18n:govoplan-files.move.76cdb950"} selection`} onClose={closeDialog}>
<p className="muted">i18n:govoplan-files.choose_a_destination_space_and_folder_folders_ar.45158643</p>
<div className="form-grid two-column-form-grid">
<FormField label="i18n:govoplan-files.destination_space.92b63970" help="i18n:govoplan-files.select_the_space_that_will_receive_the_selected_.0a8bea8f">
<select value={transferDialogState.targetSpaceId} onChange={(event) => setTransferDialogState((current) => current ? { ...current, targetSpaceId: event.target.value, targetFolder: "" } : current)}>
{spaces.filter((space) => !isConnectorSpace(space)).map((space) => <option key={space.id} value={space.id}>{space.label}</option>)}
</select>
</FormField>
<div className="field-block">
<FieldLabel className="form-label" help="i18n:govoplan-files.choose_the_target_folder_folders_are_transferred.f7ab8e9e">i18n:govoplan-files.destination_folder.f8ccb63b</FieldLabel>
<TransferFolderSelector
space={findSpace(transferDialogState.targetSpaceId)}
nodes={buildFolderTree(filesBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FILES, foldersBySpace[transferDialogState.targetSpaceId] ?? EMPTY_FOLDERS)}
selectedFolder={transferDialogState.targetFolder}
disabled={busy}
onSelect={(folderPath) => setTransferDialogState((current) => current ? { ...current, targetFolder: folderPath } : current)} />
</div>
</div>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void handleTransferDialogSubmit()} disabled={busy}>{transferMode === "copy" ? "i18n:govoplan-files.copy.af74f7c5" : "i18n:govoplan-files.move.76cdb950"}</Button>
</div>
</FileDialog>
}
{dialog === "single-rename" &&
<FileDialog title="i18n:govoplan-files.rename_item.3d21d6ca" onClose={closeDialog}>
<FormField label="i18n:govoplan-files.new_name.9e627c7b" help="i18n:govoplan-files.only_the_visible_name_changes_immutable_blobs_st.01557e71">
<input autoFocus value={singleRenameName} onChange={(event) => setSingleRenameName(event.target.value)} onKeyDown={(event) => {if (event.key === "Enter") void runSingleRename();}} />
</FormField>
<div className="button-row compact-actions align-end">
<Button onClick={closeDialog} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void runSingleRename()} disabled={busy || !singleRenameName.trim()}>i18n:govoplan-files.rename.d3f4cb89</Button>
</div>
</FileDialog>
}
{dialog === "rename" &&
<FileDialog title="i18n:govoplan-files.bulk_rename_selected_items.5e79d694" onClose={closeDialog}>
<p className="muted">i18n:govoplan-files.bulk_rename_changes_managed_display_paths_only_i.8cf34a6b</p>
<div className="form-grid two-column-form-grid">
<FormField label="i18n:govoplan-files.mode.a7b93d21" help="i18n:govoplan-files.choose_how_the_selected_names_should_be_changed.0231854f">
<select value={renameMode} onChange={(event) => setRenameMode(event.target.value as RenameMode)}>
<option value="prefix">i18n:govoplan-files.add_prefix.672452bc</option>
<option value="suffix">i18n:govoplan-files.add_suffix_before_extension.784381fe</option>
<option value="replace">i18n:govoplan-files.find_and_replace.2c6b404b</option>
</select>
</FormField>
{renameMode === "prefix" && <FormField label="i18n:govoplan-files.prefix.90eceb01"><input value={renamePrefix} onChange={(event) => setRenamePrefix(event.target.value)} /></FormField>}
{renameMode === "suffix" && <FormField label="i18n:govoplan-files.suffix.885db975"><input value={renameSuffix} onChange={(event) => setRenameSuffix(event.target.value)} /></FormField>}
{renameMode === "replace" && <><FormField label="i18n:govoplan-files.find.df251b06"><input value={renameFind} onChange={(event) => setRenameFind(event.target.value)} /></FormField><FormField label="i18n:govoplan-files.replacement.c385e347"><input value={renameReplacement} onChange={(event) => setRenameReplacement(event.target.value)} /></FormField></>}
</div>
{selectedFolderPaths.size > 0 &&
<ToggleSwitch label="i18n:govoplan-files.apply_recursively_to_folder_contents.c7076984" checked={renameRecursive} onChange={setRenameRecursive} disabled={busy} />
}
<div className="button-row compact-actions">
<Button onClick={() => void runRenamePreview(true)} disabled={busy || selectedEntryCount === 0}>i18n:govoplan-files.preview_rename.bb890b24</Button>
<Button variant="primary" onClick={() => void runRenamePreview(false)} disabled={busy || selectedEntryCount === 0 || !renamePreview}>i18n:govoplan-files.apply_preview.8692d5eb</Button>
</div>
{renamePreview &&
<RenamePreviewList
response={renamePreview}
selectedFileIds={selectedFileIds}
selectedFolderPaths={selectedFolderPaths}
recursive={renameRecursive}
visibleCount={renamePreviewVisibleCount}
onShowMore={() => setRenamePreviewVisibleCount((current) => current + 20)}
onShowFewer={() => setRenamePreviewVisibleCount(20)} />
}
</FileDialog>
}
</div>);
}
function ResourceAccessExplanationContent({
loading,
explanation,
fallbackResourceLabel
}: {loading: boolean;explanation: ResourceAccessExplanationResponse | null;fallbackResourceLabel: string;}) {
if (loading) return <p className="muted small-note">i18n:govoplan-files.loading_access_explanation.04a7c934</p>;
if (!explanation) return null;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || fallbackResourceLabel || explanation.resource_id;
return (
<>
<div className="form-grid compact responsive-form-grid">
<div><span className="form-label">i18n:govoplan-files.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-files.resource.d1c626a9</span><p>{resourceLabel}</p></div>
<div><span className="form-label">i18n:govoplan-files.action.97c89a4d</span><p><code>{explanation.action}</code></p></div>
<div><span className="form-label">i18n:govoplan-files.evidence.8487d192</span><p>{explanation.provenance.length}</p></div>
</div>
{explanation.provenance.length === 0 ?
<p className="muted small-note">i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e</p> :
<div className="admin-assignment-grid">
{explanation.provenance.map((item, index) =>
<div key={`${item.kind}:${item.id ?? index}`}>
<strong>{provenanceKindLabel(item.kind)}</strong>
<div className="muted small-note">
{item.source || "i18n:govoplan-files.no_source.6dcf9723"}
{item.id && <> · <code>{item.id}</code></>}
</div>
{item.label && <p>{item.label}</p>}
{Object.keys(item.details ?? {}).length > 0 && <p className="muted small-note">{formatProvenanceDetails(item.details ?? {})}</p>}
</div>
)}
</div>
}
</>);
}
function provenanceKindLabel(kind: string): string {
switch (kind) {
case "resource":
return "i18n:govoplan-files.resource.d1c626a9";
case "owner":
return "i18n:govoplan-files.owner.89ff3122";
case "share":
return "i18n:govoplan-files.share.09ca55ca";
case "policy":
return "i18n:govoplan-files.policy.0b779a05";
case "role":
return "i18n:govoplan-files.role.b5b4a5a2";
case "right":
return "i18n:govoplan-files.permission.2f81a22d";
default:
return kind;
}
}
function formatProvenanceDetails(details: Record<string, unknown>): string {
return Object.entries(details).map(([key, value]) => `${key}: ${formatProvenanceValue(value)}`).join("; ");
}
function formatProvenanceValue(value: unknown): string {
if (value === null || value === undefined) return "i18n:govoplan-files.none.6eef6648";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
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 (
<div className="file-search-row">
<Search size={17} aria-hidden="true" />
<input
value={draft}
placeholder="i18n:govoplan-files.search_this_folder_pdf_pdf_or_2026_pdf.74dec36a"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {if (event.key === "Enter") onSearch(draft, caseSensitiveDraft);}} />
<ToggleSwitch label="i18n:govoplan-files.case_sensitive.c73af7bd" checked={caseSensitiveDraft} onChange={setCaseSensitiveDraft} disabled={busy || disabled} />
<Button onClick={() => onSearch(draft, caseSensitiveDraft)} disabled={busy || disabled}>i18n:govoplan-files.search.bce06414</Button>
{searchActive && <Button onClick={clear} disabled={busy}>i18n:govoplan-files.clear.719ea396</Button>}
</div>);
}
function activeCampaignShares(file: ManagedFile) {
return (file.shares ?? []).filter((share) => share.target_type === "campaign" && !share.revoked_at);
}
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(", ");
}