2598 lines
121 KiB
TypeScript
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(", ");
|
|
}
|