From ee5b06b1e7d60f458ceb9ed943369c55201698ce Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 2 Jul 2026 14:59:52 +0200 Subject: [PATCH] Release v0.1.4 --- package.json | 4 +- pyproject.toml | 4 +- src/govoplan_files/backend/manifest.py | 2 +- src/govoplan_files/backend/router.py | 90 +- .../backend/storage/archives.py | 8 +- .../backend/storage/campaign_attachments.py | 13 +- .../backend/storage/campaign_usage.py | 10 +- src/govoplan_files/backend/storage/files.py | 31 + .../backend/storage/services.py | 2 + .../backend/storage/transfers.py | 68 +- webui/package.json | 4 +- webui/src/api/files.ts | 10 +- webui/src/features/files/FilesPage.tsx | 215 ++-- .../files/components/ManagedFileChooser.tsx | 1047 +++++++++++++++++ .../features/files/utils/fileManagerUtils.ts | 101 +- webui/src/index.ts | 2 + webui/src/module.ts | 12 +- webui/src/styles/file-manager.css | 469 ++++++++ 18 files changed, 1953 insertions(+), 139 deletions(-) create mode 100644 webui/src/features/files/components/ManagedFileChooser.tsx diff --git a/package.json b/package.json index 2def4fa..f277ff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/files-webui", - "version": "0.1.1", + "version": "0.1.4", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -19,7 +19,7 @@ "LICENSE" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.1", + "@govoplan/core-webui": "^0.1.4", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pyproject.toml b/pyproject.toml index af26d06..fff2c4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-files" -version = "0.1.3" +version = "0.1.4" description = "GovOPlaN files module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.3", + "govoplan-core>=0.1.4", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index c054aaf..083ceae 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -66,7 +66,7 @@ def _files_router(context: ModuleContext): manifest = ModuleManifest( id="files", name="Files", - version="0.1.2", + version="0.1.4", dependencies=("access",), optional_dependencies=(), permissions=PERMISSIONS, diff --git a/src/govoplan_files/backend/router.py b/src/govoplan_files/backend/router.py index 7079d7e..ac54771 100644 --- a/src/govoplan_files/backend/router.py +++ b/src/govoplan_files/backend/router.py @@ -44,7 +44,7 @@ from govoplan_files.backend.schemas import ( _conflict_resolutions, ) from govoplan_core.db.models import Group, UserGroupMembership -from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare +from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileFolder, FileShare, FileVersion from govoplan_core.db.session import get_session from govoplan_files.backend.runtime import settings from govoplan_files.backend.storage.paths import UnsafeFilePathError, filename_from_path, normalize_logical_path @@ -195,6 +195,92 @@ def _asset_response(session: Session, asset: FileAsset, *, include_shares: bool ) +def _asset_list_response(session: Session, assets: list[FileAsset], *, include_shares: bool = False) -> list[FileAssetResponse]: + if not assets: + return [] + + asset_ids = [asset.id for asset in assets] + version_ids = [asset.current_version_id for asset in assets if asset.current_version_id] + if len(version_ids) != len(assets): + raise FileStorageError("File has no current version") + + version_blob_rows: list[tuple[FileVersion, FileBlob]] = [] + for chunk in _chunks(version_ids): + version_blob_rows.extend( + session.query(FileVersion, FileBlob) + .join(FileBlob, FileBlob.id == FileVersion.blob_id) + .filter(FileVersion.id.in_(chunk)) + .all() + ) + versions_by_id = {version.id: version for version, _blob in version_blob_rows} + blobs_by_version_id = {version.id: blob for version, blob in version_blob_rows} + + shares_by_asset_id: dict[str, list[FileShareResponse]] = {asset_id: [] for asset_id in asset_ids} + if include_shares: + for chunk in _chunks(asset_ids): + share_rows = ( + session.query(FileShare) + .filter(FileShare.file_asset_id.in_(chunk)) + .order_by(FileShare.created_at.desc()) + .all() + ) + for row in share_rows: + shares_by_asset_id.setdefault(row.file_asset_id, []).append( + FileShareResponse( + id=row.id, + target_type=row.target_type, + target_id=row.target_id, + permission=row.permission, + created_at=row.created_at.isoformat(), + revoked_at=row.revoked_at.isoformat() if row.revoked_at else None, + ) + ) + + sent_asset_ids: set[str] = set() + for chunk in _chunks(asset_ids): + sent_rows = ( + session.query(CampaignAttachmentUse.file_asset_id) + .filter(CampaignAttachmentUse.file_asset_id.in_(chunk), CampaignAttachmentUse.use_stage == "sent") + .distinct() + .all() + ) + sent_asset_ids.update(row[0] for row in sent_rows) + + responses: list[FileAssetResponse] = [] + for asset in assets: + version = versions_by_id.get(asset.current_version_id or "") + blob = blobs_by_version_id.get(asset.current_version_id or "") + if not version or not blob: + raise FileStorageError("File version not found") + responses.append( + FileAssetResponse( + id=asset.id, + tenant_id=asset.tenant_id, + owner_type=asset.owner_type, + owner_id=_owner_id(asset), + display_path=asset.display_path, + filename=asset.filename, + description=asset.description, + size_bytes=blob.size_bytes, + content_type=blob.content_type, + checksum_sha256=blob.checksum_sha256, + version_id=version.id, + created_at=asset.created_at.isoformat(), + updated_at=asset.updated_at.isoformat(), + deleted_at=asset.deleted_at.isoformat() if asset.deleted_at else None, + audit_relevant=asset.id in sent_asset_ids, + metadata=asset.metadata_ or {}, + shares=shares_by_asset_id.get(asset.id, []), + ) + ) + return responses + + +def _chunks(values: list[str], size: int = 900): + for index in range(0, len(values), size): + yield values[index:index + size] + + def _folder_owner_id(folder: FileFolder) -> str: return folder.owner_user_id if folder.owner_type == "user" else folder.owner_group_id # type: ignore[return-value] @@ -349,7 +435,7 @@ def list_files( path_prefix=path_prefix, is_admin=_is_admin(principal), ) - return FileListResponse(files=[_asset_response(session, asset, include_shares=True) for asset in assets]) + return FileListResponse(files=_asset_list_response(session, assets, include_shares=True)) @router.post("/upload", response_model=FileUploadResponse) diff --git a/src/govoplan_files/backend/storage/archives.py b/src/govoplan_files/backend/storage/archives.py index a6116df..64ab773 100644 --- a/src/govoplan_files/backend/storage/archives.py +++ b/src/govoplan_files/backend/storage/archives.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from govoplan_files.backend.db.models import FileAsset from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend -from govoplan_files.backend.storage.files import create_file_asset, current_version_and_blob +from govoplan_files.backend.storage.files import create_file_asset, current_versions_and_blobs from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path @@ -45,9 +45,11 @@ def _read_zip_member( def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None: backend = get_storage_backend() + asset_list = list(assets) + version_blobs = current_versions_and_blobs(session, asset_list) with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: - for asset in assets: - _version, blob = current_version_and_blob(session, asset) + for asset in asset_list: + _version, blob = version_blobs[asset.id] info = zipfile.ZipInfo(asset.display_path) info.compress_type = zipfile.ZIP_DEFLATED with archive.open(info, "w") as member: diff --git a/src/govoplan_files/backend/storage/campaign_attachments.py b/src/govoplan_files/backend/storage/campaign_attachments.py index 7e8adbe..9897c2a 100644 --- a/src/govoplan_files/backend/storage/campaign_attachments.py +++ b/src/govoplan_files/backend/storage/campaign_attachments.py @@ -13,7 +13,9 @@ from typing import Any, Iterator from sqlalchemy.orm import Session from govoplan_files.backend.db.models import FileAsset -from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user, read_asset_bytes +from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend +from govoplan_files.backend.storage.common import FileStorageError +from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component @@ -172,6 +174,8 @@ def prepare_campaign_snapshot( owner_id = _asset_owner_id(asset) if owner_id: assets_by_owner[(asset.owner_type, owner_id)].append(asset) + version_blobs = current_versions_and_blobs(session, shared_assets) + backend = get_storage_backend() if include_bytes else None manifest: dict[str, ManagedAttachmentFile] = {} prepared_by_id: dict[str, tuple[str, str]] = {} @@ -206,11 +210,14 @@ def prepare_campaign_snapshot( continue target = _safe_local_target(local_root, relative_path) target.parent.mkdir(parents=True, exist_ok=True) + version, blob = version_blobs[asset.id] if include_bytes: - data, version, blob = read_asset_bytes(session, asset) + try: + data = backend.get_bytes(blob.storage_key) if backend else b"" + except StorageBackendError as exc: + raise FileStorageError(str(exc)) from exc target.write_bytes(data) else: - version, blob = current_version_and_blob(session, asset) target.touch() local_key = str(target.resolve()) manifest[local_key] = ManagedAttachmentFile( diff --git a/src/govoplan_files/backend/storage/campaign_usage.py b/src/govoplan_files/backend/storage/campaign_usage.py index e235a0e..f8b28b6 100644 --- a/src/govoplan_files/backend/storage/campaign_usage.py +++ b/src/govoplan_files/backend/storage/campaign_usage.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion from govoplan_files.backend.storage.common import utcnow -from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user +from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user if TYPE_CHECKING: from govoplan_campaign.backend.db.models import CampaignJob @@ -204,6 +204,7 @@ def record_campaign_attachment_uses_for_jobs( ) assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {} + version_blobs_by_campaign: dict[tuple[str, str], dict[str, tuple[FileVersion, FileBlob]]] = {} for job_id, attachments in fallback_attachments_by_job.items(): job = job_by_id[job_id] campaign_key = (job.tenant_id, job.campaign_id) @@ -221,6 +222,8 @@ def record_campaign_attachment_uses_for_jobs( by_key[asset.display_path.strip("/")] = asset by_key.setdefault(asset.filename, asset) assets_by_campaign[campaign_key] = by_key + version_blobs_by_campaign[campaign_key] = current_versions_and_blobs(session, assets) + version_blobs = version_blobs_by_campaign[campaign_key] for attachment in attachments: matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else [] @@ -230,7 +233,10 @@ def record_campaign_attachment_uses_for_jobs( asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None) if not asset: continue - version, blob = current_version_and_blob(session, asset) + version_blob = version_blobs.get(asset.id) + if not version_blob: + continue + version, blob = version_blob _add_use( session, job, diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index ba7e807..fc69f12 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -265,6 +265,32 @@ def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVe return version, blob +def current_versions_and_blobs(session: Session, assets: Iterable[FileAsset]) -> dict[str, tuple[FileVersion, FileBlob]]: + asset_list = list(assets) + if not asset_list: + return {} + version_ids = [asset.current_version_id for asset in asset_list if asset.current_version_id] + if len(version_ids) != len(asset_list): + raise FileStorageError("File has no current version") + + rows: list[tuple[FileVersion, FileBlob]] = [] + for chunk in _chunks(version_ids): + rows.extend( + session.query(FileVersion, FileBlob) + .join(FileBlob, FileBlob.id == FileVersion.blob_id) + .filter(FileVersion.id.in_(chunk)) + .all() + ) + by_version_id = {version.id: (version, blob) for version, blob in rows} + result: dict[str, tuple[FileVersion, FileBlob]] = {} + for asset in asset_list: + version_blob = by_version_id.get(asset.current_version_id or "") + if not version_blob: + raise FileStorageError("File version not found") + result[asset.id] = version_blob + return result + + def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]: version, blob = current_version_and_blob(session, asset) backend = get_storage_backend() @@ -437,6 +463,11 @@ def _asset_owner_id(asset: FileAsset) -> str: raise FileStorageError("File has no valid owner") +def _chunks(values: list[str], size: int = 900): + for index in range(0, len(values), size): + yield values[index:index + size] + + def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool: return _active_asset_at_path( session, diff --git a/src/govoplan_files/backend/storage/services.py b/src/govoplan_files/backend/storage/services.py index 1fdbe9e..62efb23 100644 --- a/src/govoplan_files/backend/storage/services.py +++ b/src/govoplan_files/backend/storage/services.py @@ -33,6 +33,7 @@ from govoplan_files.backend.storage.files import ( asset_is_audit_relevant, create_file_asset, current_version_and_blob, + current_versions_and_blobs, get_asset_for_user, list_assets_for_user, read_asset_bytes, @@ -64,6 +65,7 @@ __all__ = [ "create_folder", "create_zip_file", "current_version_and_blob", + "current_versions_and_blobs", "ensure_group_access", "ensure_owner_access", "extract_zip_upload", diff --git a/src/govoplan_files/backend/storage/transfers.py b/src/govoplan_files/backend/storage/transfers.py index dad2d62..81d258c 100644 --- a/src/govoplan_files/backend/storage/transfers.py +++ b/src/govoplan_files/backend/storage/transfers.py @@ -69,17 +69,13 @@ def transfer_selection( if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"): raise FileStorageError("Cannot move a folder into itself or one of its child folders") - assets_by_id: dict[str, FileAsset] = {} - for file_id in file_ids: - asset = get_asset_for_user( - session, - tenant_id=tenant_id, - user_id=user_id, - asset_id=file_id, - require_write=operation == "move", - is_admin=is_admin, - ) - assets_by_id[asset.id] = asset + assets_by_id = _selected_assets_for_owner( + session, + tenant_id=tenant_id, + owner_type=source_owner_type, + owner_id=source_owner_id, + file_ids=file_ids, + ) folder_asset_targets: dict[str, str] = {} folder_target_paths: dict[str, str] = {} @@ -297,6 +293,33 @@ def _collapse_folder_roots(folder_paths: list[str]) -> list[str]: return collapsed +def _selected_assets_for_owner( + session: Session, + *, + tenant_id: str, + owner_type: str, + owner_id: str, + file_ids: list[str], +) -> dict[str, FileAsset]: + selected_file_ids = list(dict.fromkeys(file_ids)) + if not selected_file_ids: + return {} + assets = { + asset.id: asset + for asset in _asset_query_for_owner( + session, + tenant_id=tenant_id, + owner_type=owner_type, + owner_id=owner_id, + ) + .filter(FileAsset.id.in_(selected_file_ids), FileAsset.deleted_at.is_(None)) + .all() + } + if len(assets) != len(selected_file_ids): + raise FileStorageError("File not found") + return assets + + def _path_under_root(path: str, root: str) -> bool: normalized = normalize_logical_path(path) return normalized == root or normalized.startswith(f"{root}/") @@ -349,13 +372,26 @@ def rename_selection( if mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1: raise FileStorageError("Direct rename requires exactly one selected item") - assets: dict[str, FileAsset] = {} - for file_id in selected_file_ids: - asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin) - assets[asset.id] = asset - owner_type_norm = owner_type.lower().strip() if owner_type else None owner_id_norm = owner_id + assets: dict[str, FileAsset] = {} + if selected_file_ids: + if owner_type_norm and owner_id_norm: + ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, user_id=user_id, is_admin=is_admin) + assets.update( + _selected_assets_for_owner( + session, + tenant_id=tenant_id, + owner_type=owner_type_norm, + owner_id=owner_id_norm, + file_ids=selected_file_ids, + ) + ) + else: + for file_id in selected_file_ids: + asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin) + assets[asset.id] = asset + folder_rows_by_path: dict[str, FileFolder] = {} affected_folder_paths: set[str] = set() if selected_folder_roots and owner_type_norm and owner_id_norm: diff --git a/webui/package.json b/webui/package.json index 5477d66..593d79e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/files-webui", - "version": "0.1.2", + "version": "0.1.4", "private": true, "type": "module", "main": "src/index.ts", @@ -21,7 +21,7 @@ "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", "lucide-react": "^0.555.0", - "@govoplan/core-webui": "^0.1.2" + "@govoplan/core-webui": "^0.1.4" }, "peerDependenciesMeta": { "@govoplan/core-webui": { diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts index d671caa..5bcba56 100644 --- a/webui/src/api/files.ts +++ b/webui/src/api/files.ts @@ -1,4 +1,4 @@ -import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui"; +import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type FilesManagedFileLinkTarget } from "@govoplan/core-webui"; export type FileSpace = { id: string; @@ -181,13 +181,17 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number }; -export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise { +export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise { return apiFetch(settings, "/api/v1/files/bulk-shares", { method: "POST", - body: JSON.stringify({ file_ids: fileIds, target_type: "campaign", target_id: campaignId, permission: "read" }) + body: JSON.stringify({ file_ids: fileIds, target_type: target.type, target_id: target.id, permission: target.permission ?? "read" }) }); } +export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise { + return shareFilesWithTarget(settings, fileIds, { type: "campaign", id: campaignId, label: "campaign" }); +} + export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise { const response = await shareFilesWithCampaign(settings, [fileId], campaignId); const share = response.shares[0]; diff --git a/webui/src/features/files/FilesPage.tsx b/webui/src/features/files/FilesPage.tsx index d1109d9..24399a8 100644 --- a/webui/src/features/files/FilesPage.tsx +++ b/webui/src/features/files/FilesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; import { ChevronRight, Copy, Download, File, Folder, Home, Link2, MoveRight, Plus, Search, Trash2, UploadCloud } from "lucide-react"; import { Button, @@ -78,6 +78,9 @@ import { useFileDragDropState } from "./hooks/useFileDragDropState"; type UploadPhase = "idle" | "uploading" | "unpacking" | "finalizing"; +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"); @@ -141,6 +144,28 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a const entries = buildExplorerEntries(visibleFiles, folders, currentFolder, searchActive); return sortExplorerEntries(entries, sortColumn, sortDirection); }, [visibleFiles, folders, currentFolder, searchActive, sortColumn, sortDirection]); + const fileListViewportRef = useRef(null); + const fileListMeasureRowRef = useRef(null); + 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, @@ -172,6 +197,47 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a const activeDialogSpace = activeDialogTarget ? spaces.find((space) => space.id === activeDialogTarget.spaceId) ?? null : null; const downloadLabel = selectedDownloadFileIds.length > 1 ? "Download ZIP" : "Download"; + 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(""); @@ -1161,6 +1227,75 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a : undefined; const visibleUploadProgress = uploadPhase === "unpacking" ? null : uploadProgress; + function renderExplorerEntry(entry: ExplorerEntry, rowIndex: number) { + const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null; + const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false; + + return entry.kind === "folder" ? ( +
handleInternalDragStart(entry, event)} + onDragEnd={() => { setInternalDrag(null); clearDropState(); }} + onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)} + onDragLeave={clearDropState} + onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)} + onMouseDown={preventTextSelectionOnShift} + onClick={(event) => handleEntrySelection(entry, event)} + onDoubleClick={() => { if (!busy && activeSpace) openFolder(activeSpace.id, entry.path); }} + onContextMenu={(event) => openContextMenu(event, "folder", entry)} + onKeyDown={(event) => handleEntryKeyDown(entry, event)} + > +
+
+
+
+ {formatBytes(entry.totalSize)} + {formatDate(entry.updatedAt)} +
+ ) : ( +
handleInternalDragStart(entry, event)} + onDragEnd={() => { setInternalDrag(null); clearDropState(); }} + onMouseDown={preventTextSelectionOnShift} + onClick={(event) => handleEntrySelection(entry, event)} + onDoubleClick={() => { if (!busy) void downloadFile(settings, entry.file); }} + onContextMenu={(event) => openContextMenu(event, "file", entry)} + onKeyDown={(event) => handleEntryKeyDown(entry, event)} + > +
+
+
+
+ {formatBytes(entry.file.size_bytes)} + {formatDate(entry.file.updated_at)} +
+ ); + } + return (
{error && ( @@ -1258,20 +1393,24 @@ export default function FilesPage({ settings, auth }: { settings: ApiSettings; a
setFileListScrollTop(event.currentTarget.scrollTop)} onContextMenu={(event) => openContextMenu(event, "empty")} onClick={(event) => { if (event.target === event.currentTarget) applySelectionKeys(new Set()); }} > -
+
{(() => { const normalizedCurrentFolder = normalizeFolder(currentFolder); const canOpenParent = Boolean(normalizedCurrentFolder && activeSpace); const targetParentFolder = parentFolderPath(normalizedCurrentFolder); return (
This folder is empty. Use Upload or Create Folder to add content.
)} - {explorerEntries.map((entry) => { - const entryDropTarget = entry.kind === "folder" && activeSpace ? { spaceId: activeSpace.id, folderPath: entry.path } : null; - const isDropTarget = entryDropTarget ? dropTargetKey === dropTargetId(entryDropTarget) : false; - return entry.kind === "folder" ? ( -
handleInternalDragStart(entry, event)} - onDragEnd={() => { setInternalDrag(null); clearDropState(); }} - onDragOver={(event) => entryDropTarget && handleDropTargetDragOver(event, entryDropTarget)} - onDragLeave={clearDropState} - onDrop={(event) => entryDropTarget && void handleDropOnTarget(event, entryDropTarget)} - onMouseDown={preventTextSelectionOnShift} - onClick={(event) => handleEntrySelection(entry, event)} - onDoubleClick={() => { if (!busy && activeSpace) openFolder(activeSpace.id, entry.path); }} - onContextMenu={(event) => openContextMenu(event, "folder", entry)} - onKeyDown={(event) => handleEntryKeyDown(entry, event)} - > -
-
-
-
- {formatBytes(entry.totalSize)} - {formatDate(entry.updatedAt)} -
- ) : ( -
handleInternalDragStart(entry, event)} - onDragEnd={() => { setInternalDrag(null); clearDropState(); }} - onMouseDown={preventTextSelectionOnShift} - onClick={(event) => handleEntrySelection(entry, event)} - onDoubleClick={() => { if (!busy) void downloadFile(settings, entry.file); }} - onContextMenu={(event) => openContextMenu(event, "file", entry)} - onKeyDown={(event) => handleEntryKeyDown(entry, event)} - > -
-
-
-
- {formatBytes(entry.file.size_bytes)} - {formatDate(entry.file.updated_at)} -
- ); - })} + {virtualExplorerRows.topSpacerHeight > 0 && ( + diff --git a/webui/src/features/files/components/ManagedFileChooser.tsx b/webui/src/features/files/components/ManagedFileChooser.tsx new file mode 100644 index 0000000..bcc88f5 --- /dev/null +++ b/webui/src/features/files/components/ManagedFileChooser.tsx @@ -0,0 +1,1047 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react"; +import { + listFileSpaces, + listFiles, + listFolders, + resolveFilePatterns, + shareFilesWithTarget, + type FileFolder, + type FileSpace, + type ManagedFile +} from "../../../api/files"; +import { + Button, + ConfirmDialog, + Dialog, + DismissibleAlert, + type FilesManagedAttachmentSelection, + type FilesManagedFileChooserProps, + type FilesManagedFileLinkTarget, + type FilesManagedFolderSelection +} from "@govoplan/core-webui"; +import { useFileTreeState } from "../hooks/useFileTreeState"; +import type { FolderNode, SortColumn, SortDirection } from "../types"; +import { + buildExplorerEntries, + buildFolderTree, + formatBytes, + formatDate, + normalizeFilePath, + normalizeFolder, + parentFolderPath, + sortExplorerEntries, + treeNodeKey +} from "../utils/fileManagerUtils"; + +type ManagedFileChooserMode = FilesManagedFileChooserProps["mode"]; + +const DEFAULT_CHOOSER_FILE_ROW_HEIGHT = 52; +const DEFAULT_PATTERN_RESULT_ROW_HEIGHT = 58; +const DEFAULT_CHOOSER_TREE_ROW_HEIGHT = 32; +const CHOOSER_FILE_ROW_GAP = 4; +const CHOOSER_FILE_OVERSCAN_ROWS = 8; + +type RememberedChooserState = { + spaceId?: string; + folder?: string; + pattern?: string; +}; + +type ManagedFileSource = { + ownerType: "user" | "group"; + ownerId: string; +}; + +const MANAGED_FILE_SOURCE_PREFIX = "managed:"; + +type ChooserExplorerEntry = ReturnType[number]; +type VirtualFolderTreeRow = { node: FolderNode; depth: number }; + +export type ManagedFolderSelection = FilesManagedFolderSelection; +export type ManagedAttachmentSelection = FilesManagedAttachmentSelection; + +export default function ManagedFileChooser({ + open, + settings, + mode, + storageScope, + source, + basePath = ".", + initialPattern = "", + rememberKey = mode, + previewContext, + linkTarget, + renderPatternPreview, + onClose, + onSelectFolder, + onSelectAttachment +}: FilesManagedFileChooserProps) { + const parsedSource = useMemo(() => parseManagedFileSource(source), [source]); + const normalizedBasePath = normalizeManagedBasePath(basePath); + const storageKey = useMemo( + () => `govoplan.files.managedFileChooser.${storageScope || linkTarget?.id || "global"}.${rememberKey}`, + [linkTarget?.id, rememberKey, storageScope] + ); + const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]); + const [spaces, setSpaces] = useState([]); + const [selectedSpaceId, setSelectedSpaceId] = useState(""); + const [files, setFiles] = useState([]); + const [folders, setFolders] = useState([]); + const [currentFolder, setCurrentFolder] = useState(""); + const [pattern, setPattern] = useState(""); + const patternRef = useRef(""); + const rememberDraftTimerRef = useRef(null); + const [patternHasText, setPatternHasText] = useState(false); + const [exactSelectionPattern, setExactSelectionPattern] = useState(""); + const [patternMatches, setPatternMatches] = useState(null); + const [pendingExactFile, setPendingExactFile] = useState(null); + const [loading, setLoading] = useState(false); + const [resolving, setResolving] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null; + const sourceLocked = mode === "attachment" && Boolean(parsedSource); + const sourceMatchesSpace = selectedSpace + ? mode === "folder" || !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId) + : false; + const effectiveRoot = mode === "attachment" ? normalizedBasePath : ""; + const entries = useMemo( + () => buildExplorerEntries(files, folders, currentFolder, false), + [currentFolder, files, folders] + ); + const [sortColumn, setSortColumn] = useState("name"); + const [sortDirection, setSortDirection] = useState("asc"); + const sortedEntries = useMemo( + () => sortExplorerEntries(entries, sortColumn, sortDirection), + [entries, sortColumn, sortDirection] + ); + const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot); + const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]); + const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]); + const openFolder = useCallback((path: string) => { + const normalized = normalizeFolder(path); + if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return; + setCurrentFolder(normalized); + setPatternMatches(null); + }, [effectiveRoot, mode]); + const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({ + activeSpaceId: selectedSpaceId, + currentFolder, + onOpenFolder: (_spaceId, path) => openFolder(path) + }); + + const toggleSort = useCallback((column: SortColumn) => { + if (sortColumn === column) { + setSortDirection((current) => current === "asc" ? "desc" : "asc"); + return; + } + setSortColumn(column); + setSortDirection(column === "name" ? "asc" : "desc"); + }, [sortColumn]); + + const applyExactFile = useCallback((file: ManagedFile) => { + const exactPattern = relativePath(file.display_path, effectiveRoot); + patternRef.current = exactPattern; + setPattern(exactPattern); + setPatternHasText(true); + setExactSelectionPattern(exactPattern); + setPatternMatches([file]); + setPendingExactFile(null); + }, [effectiveRoot]); + + const scheduleRememberedState = useCallback((patternValue = patternRef.current) => { + if (!open || !selectedSpaceId) return; + if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current); + rememberDraftTimerRef.current = window.setTimeout(() => { + writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern: patternValue }); + rememberDraftTimerRef.current = null; + }, 350); + }, [currentFolder, open, selectedSpaceId, storageKey]); + + useEffect(() => () => { + if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current); + }, []); + + const requestExactFile = useCallback((file: ManagedFile) => { + const exactPattern = relativePath(file.display_path, effectiveRoot); + const currentPattern = patternRef.current.trim(); + if (currentPattern && currentPattern !== exactPattern) { + setPendingExactFile(file); + return; + } + applyExactFile(file); + }, [applyExactFile, effectiveRoot]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + setError(""); + const initialPatternValue = remembered.pattern ?? initialPattern.trim(); + patternRef.current = initialPatternValue; + setPattern(initialPatternValue); + setPatternHasText(Boolean(initialPatternValue.trim())); + setExactSelectionPattern(""); + setPatternMatches(null); + setPendingExactFile(null); + void listFileSpaces(settings) + .then((response) => { + if (cancelled) return; + setSpaces(response.spaces); + const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource)); + const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId); + const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null; + setSelectedSpaceId(firstSpace?.id ?? ""); + }) + .catch((reason: unknown) => { + if (!cancelled) setError(errorMessage(reason)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]); + + useEffect(() => { + if (!open || !selectedSpace) return; + let cancelled = false; + setLoading(true); + setError(""); + setPatternMatches(null); + const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : ""; + const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot; + setCurrentFolder(initialFolder || effectiveRoot); + void Promise.all([ + listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id, path_prefix: mode === "attachment" ? effectiveRoot || undefined : undefined }), + listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }) + ]) + .then(([fileResponse, folderResponse]) => { + if (cancelled) return; + setFiles(fileResponse.files); + setFolders(folderResponse.folders); + }) + .catch((reason: unknown) => { + if (!cancelled) setError(errorMessage(reason)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [effectiveRoot, mode, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]); + + useEffect(() => { + scheduleRememberedState(); + }, [currentFolder, pattern, scheduleRememberedState]); + + function updatePatternInput(value: string) { + patternRef.current = value; + setPatternHasText((current) => { + const next = Boolean(value.trim()); + return current === next ? current : next; + }); + setExactSelectionPattern(""); + if (patternMatches !== null) setPatternMatches(null); + scheduleRememberedState(value); + } + + async function previewPattern(): Promise { + if (!selectedSpace) return []; + const trimmed = patternRef.current.trim(); + if (!trimmed) { + setPatternMatches([]); + setPattern(""); + setPatternHasText(false); + return []; + } + const previewPatternValue = renderChooserPattern(trimmed, previewContext, renderPatternPreview); + if (!previewPatternValue) { + setPatternMatches([]); + setPattern(trimmed); + return []; + } + setPattern(trimmed); + setPatternHasText(true); + setResolving(true); + setError(""); + try { + const response = await resolveFilePatterns(settings, { + patterns: [previewPatternValue], + owner_type: selectedSpace.owner_type, + owner_id: selectedSpace.owner_id, + path_prefix: effectiveRoot || undefined, + include_unmatched: false + }); + const matches = response.patterns[0]?.matches ?? []; + setPatternMatches(matches); + return matches; + } catch (reason) { + setError(errorMessage(reason)); + return []; + } finally { + setResolving(false); + } + } + + async function shareMatches(matches: ManagedFile[]) { + if (!linkTarget) return; + const fileIds = matches + .filter((file) => !isLinkedToTarget(file, linkTarget)) + .map((file) => file.id); + const uniqueIds = [...new Set(fileIds)]; + if (uniqueIds.length > 0) await shareFilesWithTarget(settings, uniqueIds, linkTarget); + } + + async function confirmSelection() { + if (!selectedSpace || !sourceMatchesSpace) return; + setSubmitting(true); + setError(""); + try { + if (mode === "folder") { + onSelectFolder?.({ + space: selectedSpace, + folderPath: currentFolder, + source: encodeManagedFileSource(selectedSpace) + }); + return; + } + + const matches = patternMatches ?? await previewPattern(); + await shareMatches(matches); + const trimmedPattern = patternRef.current.trim(); + onSelectAttachment?.({ + space: selectedSpace, + folderPath: effectiveRoot, + source: encodeManagedFileSource(selectedSpace), + fileFilter: trimmedPattern, + matchCount: matches.length, + fileIds: matches.map((file) => file.id), + selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern" + }); + } catch (reason) { + setError(errorMessage(reason)); + } finally { + setSubmitting(false); + } + } + + if (!open || typeof document === "undefined") return null; + + const dialog = ( + <> + + + + + )} + > + {error && {error}} + {mode === "attachment" && !parsedSource && ( + + This attachment base path is not connected to a managed file space. Choose its folder under Attachments → Attachment sources first. + + )} + {mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && ( + The configured managed file space is no longer available to this user. + )} + +
+ + +
+
+
+ + {breadcrumbs.map((crumb) => ( + + + + + ))} +
+
+ + {mode === "attachment" && ( + void previewPattern()} + onBackToFolder={() => setPatternMatches(null)} + /> + )} + + {patternMatches !== null && mode === "attachment" ? ( + + ) : ( + + )} + + {mode === "folder" && ( +

Choose the folder that should act as this campaign attachment source.

+ )} + {mode === "attachment" && ( +

Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.

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