From a2290c97fb0efc0de1700eaa725aaeedb520b619 Mon Sep 17 00:00:00 2001 From: Yaniv Zalach Date: Tue, 18 Aug 2026 23:45:41 +0300 Subject: [PATCH 01/11] Base rewrite --- frontend/package.json | 1 + .../src/features/fileTree/FileTreePage.tsx | 169 +++ .../fileTree/components/FileTreeContent.tsx | 139 ++ .../fileTree/components/FileTreeDetailRow.tsx | 30 + .../fileTree/components/FileTreeFileRow.tsx | 108 ++ .../fileTree/components/FileTreeFolder.tsx | 203 +++ .../fileTree/components/FileTreeInspector.tsx | 124 ++ .../fileTree/components/FileTreePartition.tsx | 138 ++ .../components/FileTreeStatistics.tsx | 64 + .../fileTree/components/FileTreeToolbar.tsx | 140 +++ .../components/FileTreeViewSettings.tsx | 205 +++ .../features/fileTree/fileTreeModel.test.ts | 93 ++ .../src/features/fileTree/fileTreeModel.ts | 330 +++++ .../src/features/fileTree/fileTreeSchemas.ts | 64 + .../src/features/fileTree/fileTreeTypes.ts | 64 + .../features/fileTree/useFileTreePageState.ts | 151 +++ frontend/src/pages/FileTreePage.jsx | 1120 ----------------- frontend/src/pages/FileTreePage.tsx | 1 + frontend/tsconfig.json | 1 + 19 files changed, 2025 insertions(+), 1120 deletions(-) create mode 100644 frontend/src/features/fileTree/FileTreePage.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeContent.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeDetailRow.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeFileRow.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeFolder.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeInspector.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreePartition.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeStatistics.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeToolbar.tsx create mode 100644 frontend/src/features/fileTree/components/FileTreeViewSettings.tsx create mode 100644 frontend/src/features/fileTree/fileTreeModel.test.ts create mode 100644 frontend/src/features/fileTree/fileTreeModel.ts create mode 100644 frontend/src/features/fileTree/fileTreeSchemas.ts create mode 100644 frontend/src/features/fileTree/fileTreeTypes.ts create mode 100644 frontend/src/features/fileTree/useFileTreePageState.ts delete mode 100644 frontend/src/pages/FileTreePage.jsx create mode 100644 frontend/src/pages/FileTreePage.tsx diff --git a/frontend/package.json b/frontend/package.json index e8d865a..bd8c9dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "build": "vite build", "preview": "vite preview", "lint": "eslint .", + "test:filetree": "node --test --experimental-strip-types --experimental-specifier-resolution=node src/features/fileTree/fileTreeModel.test.ts", "typecheck": "tsc --noEmit", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/frontend/src/features/fileTree/FileTreePage.tsx b/frontend/src/features/fileTree/FileTreePage.tsx new file mode 100644 index 0000000..0e8e967 --- /dev/null +++ b/frontend/src/features/fileTree/FileTreePage.tsx @@ -0,0 +1,169 @@ +import type { MouseEvent } from "react"; +import { useOutletContext } from "react-router-dom"; +import PanelIssueNotice from "../../components/PanelIssueNotice"; +import { useViewInGraph } from "../../hooks/useViewInGraph"; +import FileTreeContent from "./components/FileTreeContent"; +import FileTreeInspector from "./components/FileTreeInspector"; +import FileTreeToolbar from "./components/FileTreeToolbar"; +import { + buildFileTree, + buildFileTreeGraphIndex, + getAllFolderIds, + getBranches, + getCurrentSnapshot, + getDisplayedSnapshots, + getSnapshotFileErrors, + getSnapshotFiles, + groupFilesByPartition, +} from "./fileTreeModel"; +import { fileTreeContextSchema } from "./fileTreeSchemas"; +import { useFileTreePageState } from "./useFileTreePageState"; + +const FileTreePage = () => { + const rawContext: unknown = useOutletContext(); + const context = fileTreeContextSchema.parse(rawContext); + const { duplicatingNodeId, viewInGraph } = useViewInGraph(); + const activeDuplicatingNodeId = + typeof duplicatingNodeId === "string" ? duplicatingNodeId : null; + + const pageState = useFileTreePageState(); + + const graphIndex = buildFileTreeGraphIndex(context); + const branches = getBranches(context); + const selectedBranchName = branches.some( + (branch) => branch.name === pageState.requestedBranchName, + ) + ? pageState.requestedBranchName + : null; + const displayedSnapshots = getDisplayedSnapshots( + graphIndex, + branches, + selectedBranchName, + ); + const currentSnapshot = getCurrentSnapshot( + displayedSnapshots, + pageState.requestedSnapshotId, + ); + const currentSnapshotId = + currentSnapshot?.details.snapshot_id ?? currentSnapshot?.id ?? ""; + const snapshotFiles = getSnapshotFiles( + currentSnapshot, + graphIndex, + pageState.scope, + ); + const partitions = groupFilesByPartition(snapshotFiles, pageState.search); + const folders = buildFileTree(partitions); + const visibleFiles = partitions.flatMap((partition) => partition.files); + const snapshotErrors = getSnapshotFileErrors(currentSnapshot, graphIndex); + + const handleViewInGraph = ( + event: MouseEvent, + fileId: string, + ) => { + void viewInGraph(event, fileId); + }; + + if (currentSnapshot === undefined) { + return ( +
+

No snapshots available.

+
+ ); + } + + const inspectedFileId = + pageState.inspectedItem?.kind === "file" + ? pageState.inspectedItem.file.id + : null; + const inspectedFolderId = + pageState.inspectedItem?.kind === "folder" + ? pageState.inspectedItem.folder.id + : null; + const inspectedPartitionId = + pageState.inspectedItem?.kind === "partition" + ? pageState.inspectedItem.partition.id + : null; + + return ( +
+ { + pageState.selectFiles([]); + }} + onCollapseAll={pageState.collapseAll} + onExpandAll={() => { + pageState.expandItems( + pageState.viewMode === "tree" + ? [ + ...getAllFolderIds(folders), + ...partitions + .filter(({ name }) => name === "(unpartitioned)") + .map(({ id }) => id), + ] + : partitions.map(({ id }) => id), + ); + }} + onScopeChange={pageState.setScope} + onSearchChange={pageState.setSearch} + onSelectAll={() => { + pageState.selectFiles(visibleFiles); + }} + onSnapshotChange={pageState.setSnapshot} + onViewModeChange={pageState.setViewMode} + partitionCount={partitions.length} + scope={pageState.scope} + search={pageState.search} + selectedBranchName={selectedBranchName} + snapshots={displayedSnapshots} + viewMode={pageState.viewMode} + /> +
+
+ {snapshotErrors.length > 0 && ( +
+ + {snapshotErrors.join("\n")} + +
+ )} + +
+ {pageState.inspectedItem !== null && ( + + )} +
+
+ ); +}; + +export default FileTreePage; diff --git a/frontend/src/features/fileTree/components/FileTreeContent.tsx b/frontend/src/features/fileTree/components/FileTreeContent.tsx new file mode 100644 index 0000000..bb59998 --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeContent.tsx @@ -0,0 +1,139 @@ +import type { MouseEvent } from "react"; +import type { + DataFileNode, + FileTreeFolder, + FileTreeViewMode, + PartitionGroup, +} from "../fileTreeTypes"; +import FileTreeFolderComponent from "./FileTreeFolder"; +import FileTreePartition from "./FileTreePartition"; + +interface FileTreeContentProps { + checkedFileIds: Set; + duplicatingNodeId: string | null; + expandedItemIds: Set; + folders: FileTreeFolder[]; + inspectedFileId: string | null; + inspectedFolderId: string | null; + inspectedPartitionId: string | null; + onCollapseMany: (folderIds: string[]) => void; + onExpandMany: (folderIds: string[]) => void; + onInspectFile: (file: DataFileNode) => void; + onInspectFolder: (folder: FileTreeFolder) => void; + onInspectPartition: (partition: PartitionGroup) => void; + onToggleChecked: (fileId: string) => void; + onToggleExpanded: (itemId: string) => void; + onToggleFiles: (files: DataFileNode[]) => void; + onViewInGraph: (event: MouseEvent, fileId: string) => void; + partitions: PartitionGroup[]; + search: string; + viewMode: FileTreeViewMode; +} + +const FileTreeContent = ({ + checkedFileIds, + duplicatingNodeId, + expandedItemIds, + folders, + inspectedFileId, + inspectedFolderId, + inspectedPartitionId, + onCollapseMany, + onExpandMany, + onInspectFile, + onInspectFolder, + onInspectPartition, + onToggleChecked, + onToggleExpanded, + onToggleFiles, + onViewInGraph, + partitions, + search, + viewMode, +}: FileTreeContentProps) => { + if (partitions.length === 0) { + return ( +

+ {search === "" + ? "No data files found for this snapshot and scope." + : "No partitions match the search."} +

+ ); + } + + if (viewMode === "flat") { + return ( +
+ {partitions.map((partition) => ( + + ))} +
+ ); + } + + const unpartitioned = partitions.find( + (partition) => partition.name === "(unpartitioned)", + ); + return ( +
+ {unpartitioned !== undefined && ( + + )} +
+ {folders.map((folder) => ( + + ))} +
+
+ ); +}; + +export default FileTreeContent; diff --git a/frontend/src/features/fileTree/components/FileTreeDetailRow.tsx b/frontend/src/features/fileTree/components/FileTreeDetailRow.tsx new file mode 100644 index 0000000..6db1d8f --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeDetailRow.tsx @@ -0,0 +1,30 @@ +interface FileTreeDetailRowProps { + label: string; + value: unknown; +} + +const formatDetailValue = (value: unknown): string => { + if (value === null || value === undefined || value === "") return "-"; + if (typeof value === "string") return value; + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + return JSON.stringify(value, null, 2); +}; + +const FileTreeDetailRow = ({ label, value }: FileTreeDetailRowProps) => ( +
+ + {label} + + + {formatDetailValue(value)} + +
+); + +export default FileTreeDetailRow; diff --git a/frontend/src/features/fileTree/components/FileTreeFileRow.tsx b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx new file mode 100644 index 0000000..1ef1775 --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx @@ -0,0 +1,108 @@ +import type { MouseEvent } from "react"; +import { cn } from "../../../shared/lib/cn"; +import type { DataFileNode } from "../fileTreeTypes"; + +interface FileTreeFileRowProps { + checkedFileIds: Set; + duplicatingNodeId: string | null; + file: DataFileNode; + isInspected: boolean; + isTreeItem: boolean; + onInspect: (file: DataFileNode) => void; + onToggleChecked: (fileId: string) => void; + onViewInGraph: (event: MouseEvent, fileId: string) => void; +} + +const FILE_TYPE_LABELS = { + data: "Data", + equality_delete: "Equality delete", + position_delete: "Position delete", +} as const; + +const FileTreeFileRow = ({ + checkedFileIds, + duplicatingNodeId, + file, + isInspected, + isTreeItem, + onInspect, + onToggleChecked, + onViewInGraph, +}: FileTreeFileRowProps) => { + const isChecked = checkedFileIds.has(file.id); + const timestamp = file.details.earliest_appearing_snapshot_timestamp; + + return ( +
{ + onInspect(file); + }} + className={cn( + "group flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2 transition", + isInspected + ? "border-accent bg-accent-muted/60" + : isChecked + ? "border-accent/40 bg-accent-muted" + : "border-transparent bg-canvas hover:border-edge hover:bg-surface-deep", + )} + > + { + onToggleChecked(file.id); + }} + onClick={(event) => { + event.stopPropagation(); + }} + className="size-3.5 shrink-0 cursor-pointer rounded accent-accent" + /> +
+
+ {file.id} +
+
+ {FILE_TYPE_LABELS[file.type]} +
+
+ {timestamp != null && ( + + {timestamp} + + )} + +
+ ); +}; + +export default FileTreeFileRow; diff --git a/frontend/src/features/fileTree/components/FileTreeFolder.tsx b/frontend/src/features/fileTree/components/FileTreeFolder.tsx new file mode 100644 index 0000000..6cfaef0 --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeFolder.tsx @@ -0,0 +1,203 @@ +import type { MouseEvent } from "react"; +import { cn } from "../../../shared/lib/cn"; +import { getAllFolderIds, getLatestFileTimestamp } from "../fileTreeModel"; +import type { DataFileNode, FileTreeFolder as Folder } from "../fileTreeTypes"; +import FileTreeFileRow from "./FileTreeFileRow"; + +interface FileTreeFolderProps { + checkedFileIds: Set; + depth: number; + duplicatingNodeId: string | null; + expandedItemIds: Set; + inspectedFileId: string | null; + inspectedFolderId: string | null; + folder: Folder; + onCollapseMany: (folderIds: string[]) => void; + onExpandMany: (folderIds: string[]) => void; + onInspectFile: (file: DataFileNode) => void; + onInspectFolder: (folder: Folder) => void; + onToggleChecked: (fileId: string) => void; + onToggleExpanded: (itemId: string) => void; + onToggleFiles: (files: DataFileNode[]) => void; + onViewInGraph: (event: MouseEvent, fileId: string) => void; +} + +const FileTreeFolder = ({ + checkedFileIds, + depth, + duplicatingNodeId, + expandedItemIds, + inspectedFileId, + inspectedFolderId, + folder, + onCollapseMany, + onExpandMany, + onInspectFile, + onInspectFolder, + onToggleChecked, + onToggleExpanded, + onToggleFiles, + onViewInGraph, +}: FileTreeFolderProps) => { + const isExpanded = expandedItemIds.has(folder.id); + const isAllChecked = + folder.allFiles.length > 0 && + folder.allFiles.every((file) => checkedFileIds.has(file.id)); + const isSomeChecked = + !isAllChecked && + folder.allFiles.some((file) => checkedFileIds.has(file.id)); + const descendantFolderIds = getAllFolderIds(folder.children); + const latestTimestamp = getLatestFileTimestamp(folder.allFiles); + + return ( +
+
{ + onInspectFolder(folder); + }} + className="flex cursor-pointer items-center px-4 py-2.5 transition hover:bg-surface-hover" + > +
+ + + + {folder.label} + +
+
+ {latestTimestamp !== null && ( + + {latestTimestamp} + + )} + + {folder.allFiles.length} + + {folder.children.length > 0 && ( + <> + + + + )} + { + if (element !== null) element.indeterminate = isSomeChecked; + }} + onChange={() => { + onToggleFiles(folder.allFiles); + }} + onClick={(event) => { + event.stopPropagation(); + }} + className="size-3.5 cursor-pointer rounded accent-accent" + /> +
+
+ {isExpanded && ( +
+ {folder.children.map((child) => ( + + ))} + {folder.directFiles.map((file) => ( + + ))} +
+ )} +
+ ); +}; + +export default FileTreeFolder; diff --git a/frontend/src/features/fileTree/components/FileTreeInspector.tsx b/frontend/src/features/fileTree/components/FileTreeInspector.tsx new file mode 100644 index 0000000..da4a276 --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeInspector.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import type { MouseEvent } from "react"; +import { calculateFileStatistics } from "../fileTreeModel"; +import type { InspectedFileTreeItem } from "../fileTreeTypes"; +import FileTreeDetailRow from "./FileTreeDetailRow"; +import FileTreeStatistics from "./FileTreeStatistics"; + +interface FileTreeInspectorProps { + duplicatingNodeId: string | null; + inspectedItem: InspectedFileTreeItem; + onClose: () => void; + onViewInGraph: (event: MouseEvent, fileId: string) => void; +} + +const humanizeKey = (key: string): string => + key + .replaceAll("_", " ") + .replaceAll("-", " ") + .replace(/^./, (firstCharacter) => firstCharacter.toUpperCase()); + +const FileTreeInspector = ({ + duplicatingNodeId, + inspectedItem, + onClose, + onViewInGraph, +}: FileTreeInspectorProps) => { + const [isPathCopied, setIsPathCopied] = useState(false); + const title = + inspectedItem.kind === "file" + ? "Data file" + : inspectedItem.kind === "folder" + ? "Folder statistics" + : "Partition statistics"; + const subtitle = + inspectedItem.kind === "file" + ? inspectedItem.file.id + : inspectedItem.kind === "folder" + ? inspectedItem.folder.path + : inspectedItem.partition.name; + const statistics = + inspectedItem.kind === "file" + ? calculateFileStatistics([inspectedItem.file]) + : inspectedItem.kind === "folder" + ? inspectedItem.folder.statistics + : inspectedItem.partition.statistics; + + const handleCopyPath = async () => { + await navigator.clipboard.writeText(subtitle); + setIsPathCopied(true); + window.setTimeout(() => { + setIsPathCopied(false); + }, 2000); + }; + + return ( + + ); +}; + +export default FileTreeInspector; diff --git a/frontend/src/features/fileTree/components/FileTreePartition.tsx b/frontend/src/features/fileTree/components/FileTreePartition.tsx new file mode 100644 index 0000000..76e67bf --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreePartition.tsx @@ -0,0 +1,138 @@ +import type { MouseEvent } from "react"; +import { cn } from "../../../shared/lib/cn"; +import { getLatestFileTimestamp } from "../fileTreeModel"; +import type { DataFileNode, PartitionGroup } from "../fileTreeTypes"; +import FileTreeFileRow from "./FileTreeFileRow"; + +interface FileTreePartitionProps { + checkedFileIds: Set; + duplicatingNodeId: string | null; + expandedItemIds: Set; + inspectedFileId: string | null; + isInspected: boolean; + onInspectFile: (file: DataFileNode) => void; + onInspectPartition: (partition: PartitionGroup) => void; + onToggleChecked: (fileId: string) => void; + onToggleExpanded: (itemId: string) => void; + onToggleFiles: (files: DataFileNode[]) => void; + onViewInGraph: (event: MouseEvent, fileId: string) => void; + partition: PartitionGroup; +} + +const FileTreePartition = ({ + checkedFileIds, + duplicatingNodeId, + expandedItemIds, + inspectedFileId, + isInspected, + onInspectFile, + onInspectPartition, + onToggleChecked, + onToggleExpanded, + onToggleFiles, + onViewInGraph, + partition, +}: FileTreePartitionProps) => { + const isExpanded = expandedItemIds.has(partition.id); + const isAllChecked = + partition.files.length > 0 && + partition.files.every((file) => checkedFileIds.has(file.id)); + const isSomeChecked = + !isAllChecked && + partition.files.some((file) => checkedFileIds.has(file.id)); + const latestTimestamp = getLatestFileTimestamp(partition.files); + + return ( +
+
{ + onInspectPartition(partition); + }} + className="flex cursor-pointer items-center px-4 py-2.5 transition hover:bg-surface-hover" + > +
+ + + {partition.name} + +
+
+ {latestTimestamp !== null && ( + + {latestTimestamp} + + )} + + {partition.files.length} + + { + if (element !== null) element.indeterminate = isSomeChecked; + }} + onChange={() => { + onToggleFiles(partition.files); + }} + onClick={(event) => { + event.stopPropagation(); + }} + className="size-3.5 cursor-pointer rounded accent-accent" + /> +
+
+ {isExpanded && ( +
+ {partition.files.map((file) => ( + + ))} +
+ )} +
+ ); +}; + +export default FileTreePartition; diff --git a/frontend/src/features/fileTree/components/FileTreeStatistics.tsx b/frontend/src/features/fileTree/components/FileTreeStatistics.tsx new file mode 100644 index 0000000..96c834c --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeStatistics.tsx @@ -0,0 +1,64 @@ +import { formatByteSize } from "../fileTreeModel"; +import type { FileStatistics } from "../fileTreeTypes"; + +interface FileTreeStatisticsProps { + statistics: FileStatistics; +} + +const FileTreeStatistics = ({ statistics }: FileTreeStatisticsProps) => { + const rows = [ + ["Total size", formatByteSize(statistics.totalSizeBytes)], + ["Average size", formatByteSize(statistics.averageSizeBytes)], + ["Smallest file", formatByteSize(statistics.smallestSizeBytes)], + ["Largest file", formatByteSize(statistics.largestSizeBytes)], + ["Rows", statistics.totalRowCount.toLocaleString()], + ["Files", statistics.fileCount.toLocaleString()], + ]; + + return ( +
+
+ {rows.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+
+
+ Files by type +
+
+
+ Data + + {statistics.dataFileCount} + +
+
+ Position deletes + + {statistics.positionDeleteFileCount} + +
+
+ Equality deletes + + {statistics.equalityDeleteFileCount} + +
+
+
+
+ ); +}; + +export default FileTreeStatistics; diff --git a/frontend/src/features/fileTree/components/FileTreeToolbar.tsx b/frontend/src/features/fileTree/components/FileTreeToolbar.tsx new file mode 100644 index 0000000..20a2d5e --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeToolbar.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import type { + Branch, + FileTreeViewMode, + SnapshotFileScope, + SnapshotNode, +} from "../fileTreeTypes"; +import FileTreeViewSettings from "./FileTreeViewSettings"; + +interface FileTreeToolbarProps { + branches: Branch[]; + checkedFileIds: Set; + currentSnapshotId: string; + fileCount: number; + onBranchChange: (branchName: string | null) => void; + onClearSelection: () => void; + onCollapseAll: () => void; + onExpandAll: () => void; + onScopeChange: (scope: SnapshotFileScope) => void; + onSearchChange: (search: string) => void; + onSelectAll: () => void; + onSnapshotChange: (snapshotId: string) => void; + onViewModeChange: (viewMode: FileTreeViewMode) => void; + partitionCount: number; + scope: SnapshotFileScope; + search: string; + selectedBranchName: string | null; + snapshots: SnapshotNode[]; + viewMode: FileTreeViewMode; +} + +const ACTION_CLASS = + "cursor-pointer rounded-lg border border-edge px-3 py-1.5 text-sm text-slate-400 transition hover:border-edge-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-30"; + +const FileTreeToolbar = ({ + branches, + checkedFileIds, + currentSnapshotId, + fileCount, + onBranchChange, + onClearSelection, + onCollapseAll, + onExpandAll, + onScopeChange, + onSearchChange, + onSelectAll, + onSnapshotChange, + onViewModeChange, + partitionCount, + scope, + search, + selectedBranchName, + snapshots, + viewMode, +}: FileTreeToolbarProps) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopyPaths = async () => { + await navigator.clipboard.writeText([...checkedFileIds].join("\n")); + setIsCopied(true); + window.setTimeout(() => { + setIsCopied(false); + }, 2000); + }; + + return ( +
+ +
+ { + onSearchChange(event.target.value); + }} + className="min-w-44 flex-1 rounded-lg border border-edge bg-surface px-3 py-1.5 text-sm text-ink placeholder:text-slate-500 focus:border-accent focus:outline-none sm:max-w-xs" + /> +
+ + + + + + + {partitionCount} partitions / {fileCount} files + +
+
+ ); +}; + +export default FileTreeToolbar; diff --git a/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx new file mode 100644 index 0000000..6c8c92a --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState } from "react"; +import { cn } from "../../../shared/lib/cn"; +import type { + Branch, + FileTreeViewMode, + SnapshotFileScope, + SnapshotNode, +} from "../fileTreeTypes"; + +interface FileTreeViewSettingsProps { + branches: Branch[]; + currentSnapshotId: string; + onBranchChange: (branchName: string | null) => void; + onScopeChange: (scope: SnapshotFileScope) => void; + onSnapshotChange: (snapshotId: string) => void; + onViewModeChange: (viewMode: FileTreeViewMode) => void; + scope: SnapshotFileScope; + selectedBranchName: string | null; + snapshots: SnapshotNode[]; + viewMode: FileTreeViewMode; +} + +const CONTROL_CLASS = + "w-full rounded-lg border border-edge bg-canvas px-3 py-2 text-sm text-ink focus:border-accent focus:outline-none"; + +const FileTreeViewSettings = ({ + branches, + currentSnapshotId, + onBranchChange, + onScopeChange, + onSnapshotChange, + onViewModeChange, + scope, + selectedBranchName, + snapshots, + viewMode, +}: FileTreeViewSettingsProps) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + useEffect(() => { + if (!isOpen) return; + const handleOutsideClick = (event: globalThis.MouseEvent) => { + if ( + event.target instanceof Node && + !containerRef.current?.contains(event.target) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + }; + }, [isOpen]); + + return ( +
+ + {isOpen && ( +
+
+

View settings

+
+
+ {branches.length > 0 && ( + + )} + +
+ + File scope + +
+ + +
+
+
+ + Grouping + +
+ {(["flat", "tree"] as const).map((mode) => ( + + ))} +
+
+
+
+ )} +
+ ); +}; + +export default FileTreeViewSettings; diff --git a/frontend/src/features/fileTree/fileTreeModel.test.ts b/frontend/src/features/fileTree/fileTreeModel.test.ts new file mode 100644 index 0000000..fd05dc0 --- /dev/null +++ b/frontend/src/features/fileTree/fileTreeModel.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildFileTree, + buildFileTreeGraphIndex, + calculateFileStatistics, + getSnapshotFileErrors, + getSnapshotFiles, + groupFilesByPartition, +} from "./fileTreeModel.ts"; +import { fileTreeContextSchema } from "./fileTreeSchemas.ts"; +import type { DataFileNode, DataFileType } from "./fileTreeTypes.ts"; + +const createDataFile = ( + id: string, + type: DataFileType, + sizeGb: number, + rowCount: number, + partition: string, + snapshotId: string, +): DataFileNode => ({ + details: { + earliest_appearing_snapshot_id: snapshotId, + partition, + row_count: rowCount, + size_gb: sizeGb, + }, + id, + type, +}); + +void test("calculates partition statistics across every file type", () => { + const files = [ + createDataFile("data", "data", 1, 10, "region=eu", "2"), + createDataFile("position", "position_delete", 0.5, 2, "region=eu", "2"), + createDataFile("equality", "equality_delete", 0.25, 1, "region=eu", "2"), + ]; + const statistics = calculateFileStatistics(files); + + assert.equal(statistics.fileCount, 3); + assert.equal(statistics.totalRowCount, 13); + assert.equal(statistics.dataFileCount, 1); + assert.equal(statistics.positionDeleteFileCount, 1); + assert.equal(statistics.equalityDeleteFileCount, 1); + assert.equal(statistics.totalSizeBytes, 1.75 * 1024 ** 3); +}); + +void test("commit scope includes only files first appearing in the snapshot", () => { + const context = fileTreeContextSchema.parse({ + edges: [ + { from: "snapshot-path", to: "manifest-path" }, + { from: "manifest-path", to: "old-file" }, + { from: "manifest-path", to: "new-file" }, + ], + metadata: null, + nodes: [ + { + details: { error: null, snapshot_id: "2" }, + id: "snapshot-path", + type: "snapshot", + }, + { details: {}, id: "manifest-path", type: "manifest" }, + createDataFile("old-file", "data", 1, 10, "region=eu", "1"), + createDataFile("new-file", "data", 1, 10, "region=eu", "2"), + ], + }); + const graphIndex = buildFileTreeGraphIndex(context); + const snapshot = graphIndex.snapshots[0]; + + assert.deepEqual( + getSnapshotFiles(snapshot, graphIndex, "snapshot").map(({ id }) => id), + ["old-file", "new-file"], + ); + assert.deepEqual( + getSnapshotFiles(snapshot, graphIndex, "commit").map(({ id }) => id), + ["new-file"], + ); + assert.deepEqual(getSnapshotFileErrors(snapshot, graphIndex), []); +}); + +void test("tree folders roll statistics up from descendant partitions", () => { + const files = [ + createDataFile("one", "data", 1, 10, "region=eu, day=1", "2"), + createDataFile("two", "data", 2, 20, "region=eu, day=2", "2"), + ]; + const tree = buildFileTree(groupFilesByPartition(files, "")); + const regionFolder = tree[0]; + + assert.ok(regionFolder); + assert.equal(regionFolder.statistics.fileCount, 2); + assert.equal(regionFolder.statistics.totalRowCount, 30); + assert.equal(regionFolder.children.length, 2); +}); diff --git a/frontend/src/features/fileTree/fileTreeModel.ts b/frontend/src/features/fileTree/fileTreeModel.ts new file mode 100644 index 0000000..91cbde5 --- /dev/null +++ b/frontend/src/features/fileTree/fileTreeModel.ts @@ -0,0 +1,330 @@ +import type { FileTreeContext, GraphNode } from "./fileTreeSchemas"; +import type { + Branch, + DataFileNode, + DataFileType, + FileStatistics, + FileTreeFolder, + FileTreeGraphIndex, + PartitionGroup, + SnapshotFileScope, + SnapshotNode, +} from "./fileTreeTypes"; + +const BYTES_PER_GIBIBYTE = 1024 ** 3; +const DATA_FILE_TYPES = new Set([ + "data", + "position_delete", + "equality_delete", +]); + +interface MutableFileTreeFolder { + children: Map; + directFiles: DataFileNode[]; + label: string; + path: string; +} + +const isDataFileNode = (node: GraphNode): node is DataFileNode => + node.type === "data" || + node.type === "position_delete" || + node.type === "equality_delete"; + +const isSnapshotNode = (node: GraphNode): node is SnapshotNode => + node.type === "snapshot"; + +const getTimestampSortValue = (snapshot: SnapshotNode): number => { + const timestamp = snapshot.details.timestamp; + if (typeof timestamp === "number") return timestamp; + if (typeof timestamp !== "string") return 0; + const parsedTimestamp = Date.parse(timestamp); + return Number.isNaN(parsedTimestamp) ? 0 : parsedTimestamp; +}; + +export const buildFileTreeGraphIndex = ( + context: FileTreeContext, +): FileTreeGraphIndex => { + const nodesById: Record = {}; + for (const node of context.nodes) nodesById[node.id] = node; + + const snapshots = context.nodes + .filter(isSnapshotNode) + .sort( + (first, second) => + getTimestampSortValue(first) - getTimestampSortValue(second), + ); + const snapshotsBySnapshotId: Record = {}; + for (const snapshot of snapshots) { + const snapshotId = snapshot.details.snapshot_id; + if (snapshotId != null) snapshotsBySnapshotId[snapshotId] = snapshot; + } + + const adjacencyByNodeId: FileTreeGraphIndex["adjacencyByNodeId"] = {}; + for (const edge of context.edges) { + const connections = adjacencyByNodeId[edge.from] ?? []; + connections.push({ isDeleted: edge.is_deleted === true, to: edge.to }); + adjacencyByNodeId[edge.from] = connections; + } + + return { + adjacencyByNodeId, + nodesById, + snapshots, + snapshotsBySnapshotId, + }; +}; + +export const getBranches = (context: FileTreeContext): Branch[] => + Object.entries(context.metadata?.refs ?? {}) + .filter(([, reference]) => reference.type === "branch") + .map(([name, reference]) => ({ + headSnapshotId: reference["snapshot-id"], + name, + })) + .sort((first, second) => first.name.localeCompare(second.name)); + +export const getDisplayedSnapshots = ( + graphIndex: FileTreeGraphIndex, + branches: Branch[], + selectedBranchName: string | null, +): SnapshotNode[] => { + if (selectedBranchName === null) return graphIndex.snapshots; + const branch = branches.find(({ name }) => name === selectedBranchName); + if (branch === undefined) return graphIndex.snapshots; + + const branchSnapshots: SnapshotNode[] = []; + const visitedSnapshotIds = new Set(); + let currentSnapshotId: string | null | undefined = branch.headSnapshotId; + while ( + currentSnapshotId != null && + !visitedSnapshotIds.has(currentSnapshotId) + ) { + visitedSnapshotIds.add(currentSnapshotId); + const snapshot: SnapshotNode | undefined = + graphIndex.snapshotsBySnapshotId[currentSnapshotId]; + if (snapshot === undefined) break; + branchSnapshots.push(snapshot); + currentSnapshotId = snapshot.details.parent_id; + } + return branchSnapshots.reverse(); +}; + +export const getCurrentSnapshot = ( + snapshots: SnapshotNode[], + requestedSnapshotId: string | null, +): SnapshotNode | undefined => { + if (requestedSnapshotId !== null) { + const requestedSnapshot = snapshots.find( + (snapshot) => snapshot.details.snapshot_id === requestedSnapshotId, + ); + if (requestedSnapshot !== undefined) return requestedSnapshot; + } + return snapshots.at(-1); +}; + +export const getSnapshotFiles = ( + snapshot: SnapshotNode | undefined, + graphIndex: FileTreeGraphIndex, + scope: SnapshotFileScope, +): DataFileNode[] => { + if (snapshot === undefined) return []; + + const filesById = new Map(); + const visitedNodeIds = new Set(); + const queuedNodeIds = [snapshot.id]; + while (queuedNodeIds.length > 0) { + const currentNodeId = queuedNodeIds.shift(); + if (currentNodeId === undefined || visitedNodeIds.has(currentNodeId)) { + continue; + } + visitedNodeIds.add(currentNodeId); + + for (const connection of graphIndex.adjacencyByNodeId[currentNodeId] ?? + []) { + const child = graphIndex.nodesById[connection.to]; + if (child === undefined) continue; + if (isDataFileNode(child)) { + if (!connection.isDeleted && DATA_FILE_TYPES.has(child.type)) { + filesById.set(child.id, child); + } + } else if (child.type === "manifest") { + queuedNodeIds.push(child.id); + } + } + } + + const snapshotFiles = [...filesById.values()]; + if (scope === "snapshot") return snapshotFiles; + const snapshotId = snapshot.details.snapshot_id; + return snapshotFiles.filter( + (file) => file.details.earliest_appearing_snapshot_id === snapshotId, + ); +}; + +export const calculateFileStatistics = ( + files: DataFileNode[], +): FileStatistics => { + const fileSizes = files.map( + (file) => (file.details.size_gb ?? 0) * BYTES_PER_GIBIBYTE, + ); + const totalSizeBytes = fileSizes.reduce((total, size) => total + size, 0); + return { + averageSizeBytes: files.length === 0 ? 0 : totalSizeBytes / files.length, + dataFileCount: files.filter((file) => file.type === "data").length, + equalityDeleteFileCount: files.filter( + (file) => file.type === "equality_delete", + ).length, + fileCount: files.length, + largestSizeBytes: fileSizes.length === 0 ? 0 : Math.max(...fileSizes), + positionDeleteFileCount: files.filter( + (file) => file.type === "position_delete", + ).length, + smallestSizeBytes: fileSizes.length === 0 ? 0 : Math.min(...fileSizes), + totalRowCount: files.reduce( + (total, file) => total + (file.details.row_count ?? 0), + 0, + ), + totalSizeBytes, + }; +}; + +export const getLatestFileTimestamp = (files: DataFileNode[]): string | null => + files + .map((file) => file.details.earliest_appearing_snapshot_timestamp) + .filter((timestamp): timestamp is string => timestamp != null) + .sort((first, second) => first.localeCompare(second)) + .at(-1) ?? null; + +export const groupFilesByPartition = ( + files: DataFileNode[], + search: string, +): PartitionGroup[] => { + const filesByPartition = new Map(); + for (const file of files) { + const partition = file.details.partition ?? "(unpartitioned)"; + const partitionFiles = filesByPartition.get(partition) ?? []; + partitionFiles.push(file); + filesByPartition.set(partition, partitionFiles); + } + + const normalizedSearch = search.trim().toLowerCase(); + return [...filesByPartition.entries()] + .filter( + ([partition]) => + normalizedSearch === "" || + partition.toLowerCase().includes(normalizedSearch), + ) + .map(([name, partitionFiles]) => ({ + files: partitionFiles, + id: `partition:${name}`, + name, + statistics: calculateFileStatistics(partitionFiles), + })) + .sort((first, second) => second.name.localeCompare(first.name)); +}; + +const convertMutableFolder = ( + mutableFolder: MutableFileTreeFolder, +): FileTreeFolder => { + const children = [...mutableFolder.children.values()] + .map(convertMutableFolder) + .sort((first, second) => second.label.localeCompare(first.label)); + const allFiles = [ + ...mutableFolder.directFiles, + ...children.flatMap((child) => child.allFiles), + ]; + return { + allFiles, + children, + directFiles: mutableFolder.directFiles, + id: `folder:${mutableFolder.path}`, + label: mutableFolder.label, + path: mutableFolder.path, + statistics: calculateFileStatistics(allFiles), + }; +}; + +export const buildFileTree = ( + partitions: PartitionGroup[], +): FileTreeFolder[] => { + const root: MutableFileTreeFolder = { + children: new Map(), + directFiles: [], + label: "", + path: "", + }; + + for (const partition of partitions) { + if (partition.name === "(unpartitioned)") continue; + let currentFolder = root; + for (const segment of partition.name.split(", ")) { + const path = + currentFolder.path === "" + ? segment + : `${currentFolder.path}/${segment}`; + const existingFolder = currentFolder.children.get(segment); + if (existingFolder !== undefined) { + currentFolder = existingFolder; + continue; + } + const newFolder: MutableFileTreeFolder = { + children: new Map(), + directFiles: [], + label: segment, + path, + }; + currentFolder.children.set(segment, newFolder); + currentFolder = newFolder; + } + currentFolder.directFiles.push(...partition.files); + } + + return [...root.children.values()] + .map(convertMutableFolder) + .sort((first, second) => second.label.localeCompare(first.label)); +}; + +export const getAllFolderIds = (folders: FileTreeFolder[]): string[] => + folders.flatMap((folder) => [folder.id, ...getAllFolderIds(folder.children)]); + +export const getSnapshotFileErrors = ( + snapshot: SnapshotNode | undefined, + graphIndex: FileTreeGraphIndex, +): string[] => { + if (snapshot === undefined) return []; + const errors: string[] = []; + const visitedNodeIds = new Set(); + const queuedNodeIds = [snapshot.id]; + while (queuedNodeIds.length > 0) { + const currentNodeId = queuedNodeIds.shift(); + if (currentNodeId === undefined || visitedNodeIds.has(currentNodeId)) { + continue; + } + visitedNodeIds.add(currentNodeId); + const node = graphIndex.nodesById[currentNodeId]; + if (node === undefined) continue; + if (node.details.error) { + errors.push( + `${node.type} (${node.label ?? node.id}): ${node.details.error}`, + ); + } + for (const connection of graphIndex.adjacencyByNodeId[currentNodeId] ?? + []) { + queuedNodeIds.push(connection.to); + } + } + return errors; +}; + +export const formatByteSize = (sizeBytes: number): string => { + if (sizeBytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const unitIndex = Math.min( + Math.floor(Math.log(sizeBytes) / Math.log(1024)), + units.length - 1, + ); + const unit = units[unitIndex]; + if (unit === undefined) return `${String(Math.round(sizeBytes))} B`; + const value = sizeBytes / 1024 ** unitIndex; + return `${value.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${unit}`; +}; diff --git a/frontend/src/features/fileTree/fileTreeSchemas.ts b/frontend/src/features/fileTree/fileTreeSchemas.ts new file mode 100644 index 0000000..cbd5456 --- /dev/null +++ b/frontend/src/features/fileTree/fileTreeSchemas.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +const identifierSchema = z + .union([z.string(), z.number(), z.bigint()]) + .transform((value) => String(value)); + +const nullableIdentifierSchema = identifierSchema.nullable().optional(); + +const numericValueSchema = z + .union([z.number(), z.string()]) + .transform((value) => Number(value)) + .pipe(z.number()); + +export const fileDetailsSchema = z + .object({ + error: z.string().nullish(), + timestamp: z.union([z.string(), z.number()]).optional(), + snapshot_id: nullableIdentifierSchema, + parent_id: nullableIdentifierSchema, + partition: z.string().optional(), + earliest_appearing_snapshot_id: nullableIdentifierSchema, + earliest_appearing_snapshot_timestamp: z.string().nullish(), + format: z.string().optional(), + size_gb: numericValueSchema.optional(), + row_count: numericValueSchema.optional(), + }) + .catchall(z.unknown()); + +export const graphNodeSchema = z.object({ + id: identifierSchema, + label: z.string().optional(), + type: z.string(), + details: fileDetailsSchema.default({}), +}); + +export const graphEdgeSchema = z.object({ + from: identifierSchema, + to: identifierSchema, + is_deleted: z.boolean().optional(), +}); + +const metadataReferenceSchema = z + .object({ + type: z.string(), + "snapshot-id": identifierSchema, + }) + .catchall(z.unknown()); + +const metadataSchema = z + .object({ + refs: z.record(z.string(), metadataReferenceSchema).optional(), + }) + .catchall(z.unknown()); + +export const fileTreeContextSchema = z.object({ + nodes: z.array(graphNodeSchema).default([]), + edges: z.array(graphEdgeSchema).default([]), + metadata: metadataSchema.nullable().optional(), +}); + +export type FileDetails = z.infer; +export type GraphEdge = z.infer; +export type GraphNode = z.infer; +export type FileTreeContext = z.infer; diff --git a/frontend/src/features/fileTree/fileTreeTypes.ts b/frontend/src/features/fileTree/fileTreeTypes.ts new file mode 100644 index 0000000..967b821 --- /dev/null +++ b/frontend/src/features/fileTree/fileTreeTypes.ts @@ -0,0 +1,64 @@ +import type { GraphNode } from "./fileTreeSchemas"; + +export type DataFileType = "data" | "position_delete" | "equality_delete"; +export type FileTreeViewMode = "flat" | "tree"; +export type SnapshotFileScope = "commit" | "snapshot"; + +export interface DataFileNode extends GraphNode { + type: DataFileType; +} + +export interface SnapshotNode extends GraphNode { + type: "snapshot"; +} + +export interface FileStatistics { + averageSizeBytes: number; + dataFileCount: number; + equalityDeleteFileCount: number; + fileCount: number; + largestSizeBytes: number; + positionDeleteFileCount: number; + smallestSizeBytes: number; + totalRowCount: number; + totalSizeBytes: number; +} + +export interface FileTreeFolder { + allFiles: DataFileNode[]; + children: FileTreeFolder[]; + directFiles: DataFileNode[]; + id: string; + label: string; + path: string; + statistics: FileStatistics; +} + +export interface PartitionGroup { + files: DataFileNode[]; + id: string; + name: string; + statistics: FileStatistics; +} + +export interface Branch { + headSnapshotId: string; + name: string; +} + +export interface GraphConnection { + isDeleted: boolean; + to: string; +} + +export interface FileTreeGraphIndex { + adjacencyByNodeId: Record; + nodesById: Record; + snapshots: SnapshotNode[]; + snapshotsBySnapshotId: Record; +} + +export type InspectedFileTreeItem = + | { file: DataFileNode; kind: "file" } + | { folder: FileTreeFolder; kind: "folder" } + | { kind: "partition"; partition: PartitionGroup }; diff --git a/frontend/src/features/fileTree/useFileTreePageState.ts b/frontend/src/features/fileTree/useFileTreePageState.ts new file mode 100644 index 0000000..9b2e9e7 --- /dev/null +++ b/frontend/src/features/fileTree/useFileTreePageState.ts @@ -0,0 +1,151 @@ +import { useState } from "react"; +import type { + DataFileNode, + FileTreeFolder, + FileTreeViewMode, + InspectedFileTreeItem, + PartitionGroup, + SnapshotFileScope, +} from "./fileTreeTypes"; + +interface FileTreePageState { + checkedFileIds: Set; + closeInspector: () => void; + collapseAll: () => void; + collapseMany: (itemIds: string[]) => void; + expandItems: (itemIds: string[]) => void; + expandedItemIds: Set; + inspectFile: (file: DataFileNode) => void; + inspectedItem: InspectedFileTreeItem | null; + inspectFolder: (folder: FileTreeFolder) => void; + inspectPartition: (partition: PartitionGroup) => void; + requestedBranchName: string | null; + requestedSnapshotId: string | null; + scope: SnapshotFileScope; + search: string; + selectFiles: (files: DataFileNode[]) => void; + setBranch: (branchName: string | null) => void; + setScope: (scope: SnapshotFileScope) => void; + setSearch: (search: string) => void; + setSnapshot: (snapshotId: string) => void; + setViewMode: (viewMode: FileTreeViewMode) => void; + toggleChecked: (fileId: string) => void; + toggleExpanded: (itemId: string) => void; + toggleFiles: (files: DataFileNode[]) => void; + viewMode: FileTreeViewMode; +} + +export const useFileTreePageState = (): FileTreePageState => { + const [search, setSearchState] = useState(""); + const [requestedBranchName, setRequestedBranchName] = useState( + "main", + ); + const [requestedSnapshotId, setRequestedSnapshotId] = useState( + null, + ); + const [scope, setScopeState] = useState("snapshot"); + const [viewMode, setViewModeState] = useState("tree"); + const [expandedItemIds, setExpandedItemIds] = useState(new Set()); + const [checkedFileIds, setCheckedFileIds] = useState(new Set()); + const [inspectedItem, setInspectedItem] = + useState(null); + + const clearTransientState = () => { + setExpandedItemIds(new Set()); + setCheckedFileIds(new Set()); + setInspectedItem(null); + }; + const setBranch = (branchName: string | null) => { + setRequestedBranchName(branchName); + setRequestedSnapshotId(null); + clearTransientState(); + }; + const setSnapshot = (snapshotId: string) => { + setRequestedSnapshotId(snapshotId); + clearTransientState(); + }; + const setScope = (nextScope: SnapshotFileScope) => { + setScopeState(nextScope); + clearTransientState(); + }; + const setViewMode = (nextViewMode: FileTreeViewMode) => { + setViewModeState(nextViewMode); + setExpandedItemIds(new Set()); + }; + const toggleExpanded = (itemId: string) => { + setExpandedItemIds((currentIds) => { + const nextIds = new Set(currentIds); + if (nextIds.has(itemId)) nextIds.delete(itemId); + else nextIds.add(itemId); + return nextIds; + }); + }; + const expandItems = (itemIds: string[]) => { + setExpandedItemIds((currentIds) => new Set([...currentIds, ...itemIds])); + }; + const collapseMany = (itemIds: string[]) => { + setExpandedItemIds((currentIds) => { + const nextIds = new Set(currentIds); + for (const itemId of itemIds) nextIds.delete(itemId); + return nextIds; + }); + }; + const toggleChecked = (fileId: string) => { + setCheckedFileIds((currentIds) => { + const nextIds = new Set(currentIds); + if (nextIds.has(fileId)) nextIds.delete(fileId); + else nextIds.add(fileId); + return nextIds; + }); + }; + const toggleFiles = (files: DataFileNode[]) => { + const shouldClear = files.every((file) => checkedFileIds.has(file.id)); + setCheckedFileIds((currentIds) => { + const nextIds = new Set(currentIds); + for (const file of files) { + if (shouldClear) nextIds.delete(file.id); + else nextIds.add(file.id); + } + return nextIds; + }); + }; + + return { + checkedFileIds, + closeInspector: () => { + setInspectedItem(null); + }, + collapseAll: () => { + setExpandedItemIds(new Set()); + }, + collapseMany, + expandItems, + expandedItemIds, + inspectFile: (file) => { + setInspectedItem({ file, kind: "file" }); + }, + inspectedItem, + inspectFolder: (folder) => { + setInspectedItem({ folder, kind: "folder" }); + }, + inspectPartition: (partition) => { + setInspectedItem({ kind: "partition", partition }); + }, + requestedBranchName, + requestedSnapshotId, + scope, + search, + selectFiles: (files) => { + setCheckedFileIds(new Set(files.map(({ id }) => id))); + }, + setBranch, + setScope, + setSearch: setSearchState, + setSnapshot, + setViewMode, + toggleChecked, + toggleExpanded, + toggleFiles, + viewMode, + }; +}; diff --git a/frontend/src/pages/FileTreePage.jsx b/frontend/src/pages/FileTreePage.jsx deleted file mode 100644 index a0ebc84..0000000 --- a/frontend/src/pages/FileTreePage.jsx +++ /dev/null @@ -1,1120 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useOutletContext } from "react-router-dom"; -import PanelIssueNotice from "../components/PanelIssueNotice"; -import { FileType, fileTypeLabel } from "../graphConstants"; -import { useViewInGraph } from "../hooks/useViewInGraph"; -import { - UI_BODY_MUTED_ITALIC_CLASS, - UI_FIELD_LABEL_MB_CLASS, - UI_FILE_COUNT_BADGE_CLASS, - UI_HELPER_TEXT_CLASS, - UI_MONO_MUTED_CLASS, - UI_MONO_MUTED_NOWRAP_CLASS, - UI_MONO_VALUE_CLASS, -} from "../uiTypography"; - -const FILE_TYPES = new Set([ - FileType.DATA, - FileType.POSITION_DELETE, - FileType.EQUALITY_DELETE, -]); - -function Dropdown({ triggerLabel, isOpen, onToggle, dropdownRef, children }) { - return ( -
- - {isOpen && ( -
- {children} -
- )} -
- ); -} - -function DropdownItem({ label, badge, active, onClick }) { - return ( - - ); -} - -function getAllFilesFromNode(node) { - const result = [...node.files]; - for (const child of Object.values(node.children)) { - result.push(...getAllFilesFromNode(child)); - } - return result; -} - -function getAllTreePaths(node, prefix) { - const paths = []; - for (const [label, child] of Object.entries(node.children)) { - const path = prefix ? `${prefix}/${label}` : label; - paths.push(path); - paths.push(...getAllTreePaths(child, path)); - } - return paths; -} - -function getFolderLastModified(node, fileTimestampMap) { - const files = getAllFilesFromNode(node); - const timestamps = files.map((f) => fileTimestampMap[f]).filter(Boolean); - return timestamps.length > 0 - ? timestamps.reduce((a, b) => (a > b ? a : b)) - : null; -} - -function buildTree(partitions) { - const root = { children: {}, files: [] }; - for (const [partitionStr, files] of partitions) { - if (partitionStr === "(unpartitioned)") { - root.files.push(...files); - continue; - } - const segments = partitionStr.split(", "); - let node = root; - for (const segment of segments) { - if (!node.children[segment]) { - node.children[segment] = { children: {}, files: [] }; - } - node = node.children[segment]; - } - node.files.push(...files); - } - return root; -} - -function getSnapshotFileErrors(snapshot, adjacency, nodeById) { - if (!snapshot) return []; - - const errors = []; - const visited = new Set(); - const queue = [snapshot.id]; - - while (queue.length > 0) { - const current = queue.shift(); - if (visited.has(current)) continue; - visited.add(current); - - const node = nodeById[current]; - if (!node) continue; - - if (node.details?.error) { - errors.push( - `${fileTypeLabel(node.type)} (${node.label || node.id}): ${node.details.error}`, - ); - } - - for (const { to } of adjacency[current] || []) queue.push(to); - } - - return errors; -} - -function FileRow({ - filePath, - checkedFiles, - toggleFile, - viewInGraph, - duplicatingNodeId, - timestamp, -}) { - return ( -
toggleFile(filePath)} - className={`flex items-center gap-2.5 px-3 py-2 rounded-md border transition cursor-pointer group ${ - checkedFiles.has(filePath) - ? "bg-accent-muted border-accent/40" - : "bg-canvas border-transparent hover:bg-[#131c2b] hover:border-edge" - }`} - > - toggleFile(filePath)} - onClick={(e) => e.stopPropagation()} - className="w-3.5 h-3.5 rounded accent-[#2E86C1] cursor-pointer shrink-0" - /> - - {"\u202A" + filePath + "\u202C"} - - {timestamp && ( - - {timestamp} - - )} - -
- ); -} - -function TreeNode({ - label, - node, - path, - checkedFiles, - toggleFile, - toggleBulk, - viewInGraph, - duplicatingNodeId, - collapsed, - toggleCollapse, - setCollapsed, - fileTimestampMap, -}) { - const allFiles = getAllFilesFromNode(node); - const allChecked = - allFiles.length > 0 && allFiles.every((f) => checkedFiles.has(f)); - const someChecked = !allChecked && allFiles.some((f) => checkedFiles.has(f)); - const folderLastModified = getFolderLastModified(node, fileTimestampMap); - const isCollapsed = collapsed[path]; - const sortedChildren = Object.entries(node.children).sort(([a], [b]) => - b.localeCompare(a), - ); - const hasChildFolders = sortedChildren.length > 0; - - const expandInner = (e) => { - e.stopPropagation(); - const subPaths = getAllTreePaths(node, path); - setCollapsed((prev) => { - const next = { ...prev }; - delete next[path]; // open this folder too if it was closed - for (const p of subPaths) delete next[p]; - return next; - }); - }; - - const collapseInner = (e) => { - e.stopPropagation(); - const subPaths = getAllTreePaths(node, path); - setCollapsed((prev) => ({ - ...prev, - [path]: true, - ...Object.fromEntries(subPaths.map((p) => [p, true])), - })); - }; - - return ( -
-
toggleCollapse(path)} - > -
- - - - - - - {label} -
-
- {folderLastModified && ( - - {folderLastModified} - - )} - {allFiles.length} - {hasChildFolders && ( - <> - - - - )} - { - if (el) el.indeterminate = someChecked; - }} - onChange={() => toggleBulk(allFiles)} - onClick={(e) => e.stopPropagation()} - className="w-3.5 h-3.5 rounded accent-[#2E86C1] cursor-pointer" - title="Select all in folder" - /> -
-
- - {!isCollapsed && ( -
- {sortedChildren.map(([childLabel, childNode]) => ( - - ))} - {node.files.length > 0 && ( -
- {node.files.map((filePath) => ( - - ))} -
- )} -
- )} -
- ); -} - -export default function FileTreePage() { - const { nodes, edges, metadata } = useOutletContext(); - const { viewInGraph, duplicatingNodeId } = useViewInGraph(); - const [search, setSearch] = useState(""); - const [selectedBranch, setSelectedBranch] = useState(null); - const [selectedIdx, setSelectedIdx] = useState(null); - const [collapsed, setCollapsed] = useState({}); - const [checkedFiles, setCheckedFiles] = useState(new Set()); - const [copied, setCopied] = useState(false); - const [copiedSnapshotId, setCopiedSnapshotId] = useState(false); - const [branchDropdownOpen, setBranchDropdownOpen] = useState(false); - const [snapshotDropdownOpen, setSnapshotDropdownOpen] = useState(false); - const [viewMode, setViewMode] = useState("tree"); // 'flat' | 'tree' - const branchDropdownRef = useRef(null); - const snapshotDropdownRef = useRef(null); - - useEffect(() => { - if (!branchDropdownOpen) return; - const handler = (e) => { - if ( - branchDropdownRef.current && - !branchDropdownRef.current.contains(e.target) - ) - setBranchDropdownOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [branchDropdownOpen]); - - useEffect(() => { - if (!snapshotDropdownOpen) return; - const handler = (e) => { - if ( - snapshotDropdownRef.current && - !snapshotDropdownRef.current.contains(e.target) - ) - setSnapshotDropdownOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [snapshotDropdownOpen]); - - const { snapshots, adjacency, nodeById, snapshotById } = useMemo(() => { - const allNodes = nodes || []; - const allEdges = edges || []; - - const byId = {}; - for (const n of allNodes) byId[n.id] = n; - - const snaps = allNodes - .filter((n) => n.type === FileType.SNAPSHOT) - .sort((a, b) => (a.details.timestamp || 0) - (b.details.timestamp || 0)); - - const snapById = {}; - for (const s of snaps) { - if (s.details.snapshot_id) snapById[s.details.snapshot_id] = s; - } - - const adj = {}; - for (const e of allEdges) { - if (!adj[e.from]) adj[e.from] = []; - adj[e.from].push({ to: e.to, is_deleted: !!e.is_deleted }); - } - - return { - snapshots: snaps, - adjacency: adj, - nodeById: byId, - snapshotById: snapById, - }; - }, [nodes, edges]); - - const branches = useMemo(() => { - if (!metadata?.refs) return []; - return Object.entries(metadata.refs) - .filter(([, ref]) => ref.type === "branch") - .map(([name, ref]) => ({ - name, - headSnapshotId: String(ref["snapshot-id"]), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [metadata]); - - useEffect(() => { - if (selectedBranch !== null) return; - const mainBranch = branches.find((b) => b.name === "main"); - if (mainBranch) setSelectedBranch("main"); - }, [branches]); - - const displayedSnapshots = useMemo(() => { - if (!selectedBranch) return snapshots; - const branch = branches.find((b) => b.name === selectedBranch); - if (!branch) return snapshots; - - const result = []; - const visited = new Set(); - let currentId = branch.headSnapshotId; - while (currentId && !visited.has(currentId)) { - visited.add(currentId); - const node = snapshotById[currentId]; - if (!node) break; - result.push(node); - currentId = node.details.parent_id; - } - return result.reverse(); - }, [selectedBranch, branches, snapshots, snapshotById]); - - const effectiveIdx = - selectedIdx !== null ? selectedIdx : displayedSnapshots.length - 1; - - const partitionMap = useMemo(() => { - if (displayedSnapshots.length === 0) return {}; - const snapshot = displayedSnapshots[effectiveIdx]; - if (!snapshot) return {}; - - const visited = new Set(); - const queue = [snapshot.id]; - const dataFiles = []; - - while (queue.length > 0) { - const current = queue.shift(); - if (visited.has(current)) continue; - visited.add(current); - - for (const { to, is_deleted } of adjacency[current] || []) { - const child = nodeById[to]; - if (!child) continue; - if (FILE_TYPES.has(child.type)) { - if (!is_deleted) dataFiles.push(child); - } else if (child.type === FileType.MANIFEST) { - queue.push(to); - } - } - } - - const partMap = {}; - for (const f of dataFiles) { - const partition = f.details.partition || "(unpartitioned)"; - if (!partMap[partition]) partMap[partition] = []; - partMap[partition].push(f.id); - } - return partMap; - }, [displayedSnapshots, effectiveIdx, adjacency, nodeById]); - - const filteredPartitions = useMemo(() => { - const q = search.trim().toLowerCase(); - return Object.entries(partitionMap) - .filter(([part]) => !q || part.toLowerCase().includes(q)) - .sort(([a], [b]) => b.localeCompare(a)); - }, [partitionMap, search]); - - const treeData = useMemo( - () => buildTree(filteredPartitions), - [filteredPartitions], - ); - - const fileTimestampMap = useMemo(() => { - const map = {}; - for (const files of Object.values(partitionMap)) { - for (const filePath of files) { - const ts = - nodeById[filePath]?.details?.earliest_appearing_snapshot_timestamp; - if (ts) map[filePath] = ts; - } - } - return map; - }, [partitionMap, nodeById]); - - const totalPartitions = filteredPartitions.length; - const totalFiles = filteredPartitions.reduce( - (sum, [, f]) => sum + f.length, - 0, - ); - - useEffect(() => { - if (viewMode === "flat") { - setCollapsed( - Object.fromEntries(Object.keys(partitionMap).map((p) => [p, true])), - ); - } else { - const fullTree = buildTree(Object.entries(partitionMap)); - setCollapsed( - Object.fromEntries(getAllTreePaths(fullTree, "").map((p) => [p, true])), - ); - } - }, [partitionMap, viewMode]); - - const resetSelection = () => { - setSelectedIdx(null); - setCheckedFiles(new Set()); - }; - - const toggleCollapse = (key) => - setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); - - const toggleFile = (path) => - setCheckedFiles((prev) => { - const next = new Set(prev); - next.has(path) ? next.delete(path) : next.add(path); - return next; - }); - - const toggleBulk = (files) => { - const allChecked = files.every((f) => checkedFiles.has(f)); - setCheckedFiles((prev) => { - const next = new Set(prev); - files.forEach((f) => (allChecked ? next.delete(f) : next.add(f))); - return next; - }); - }; - - const collapseAll = () => { - if (viewMode === "flat") { - setCollapsed( - Object.fromEntries(filteredPartitions.map(([p]) => [p, true])), - ); - } else { - setCollapsed( - Object.fromEntries(getAllTreePaths(treeData, "").map((p) => [p, true])), - ); - } - }; - - const expandAll = () => setCollapsed({}); - - const copyPaths = () => { - navigator.clipboard.writeText([...checkedFiles].join("\n")); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - if (snapshots.length === 0) { - return ( -
-

No snapshots available.

-
- ); - } - - const currentSnapshot = displayedSnapshots[effectiveIdx]; - const snapshotFileErrors = getSnapshotFileErrors( - currentSnapshot, - adjacency, - nodeById, - ); - - return ( -
-
-
- {branches.length > 0 && ( - <> - setBranchDropdownOpen((p) => !p)} - triggerLabel={ - selectedBranch ? ( - <>{selectedBranch} - ) : ( - All branches - ) - } - > - { - setSelectedBranch(null); - resetSelection(); - setBranchDropdownOpen(false); - }} - /> -
- {branches.map((b) => ( - { - setSelectedBranch(b.name); - resetSelection(); - setBranchDropdownOpen(false); - }} - /> - ))} - -
- - )} - - setSnapshotDropdownOpen((p) => !p)} - triggerLabel={ - - - Snapshot {effectiveIdx + 1} - {effectiveIdx === displayedSnapshots.length - 1 && ( - - latest - - )} - - {currentSnapshot?.details.snapshot_id && ( - - {currentSnapshot.details.snapshot_id} - - )} - - } - > - {displayedSnapshots.map((snap, i) => ( - { - setSelectedIdx(i); - setCollapsed({}); - setCheckedFiles(new Set()); - setSnapshotDropdownOpen(false); - }} - /> - ))} - - - {currentSnapshot?.details.snapshot_id && ( - - )} - -
-
- i -
-
- Snapshots are numbered in chronological order — Snapshot 1 is the - oldest, the highest number is the latest. -
-
-
-
- - setSearch(e.target.value)} - className="flex-1 min-w-30 max-w-xs text-sm bg-surface border border-edge text-ink rounded-lg px-3 py-1.5 placeholder-slate-500 focus:outline-none focus:border-accent" - /> - -
-
- -
- -
- -
- - - - -
- - - - -
- - - -
- - {totalPartitions} / {totalFiles} - -
-
-
- Partitions - - {totalPartitions} - -
-
- Files - {totalFiles} -
-
-
-
-
- -
- {snapshotFileErrors.length > 0 && ( - - {snapshotFileErrors.join("\n")} - - )} - - {totalPartitions === 0 && ( -

- {search - ? "No partitions match the search." - : "No data files found for this snapshot."} -

- )} - - {viewMode === "flat" && - filteredPartitions.map(([partition, files]) => { - const allChecked = files.every((f) => checkedFiles.has(f)); - const someChecked = - !allChecked && files.some((f) => checkedFiles.has(f)); - const flatFolderLastModified = files - .map((f) => fileTimestampMap[f]) - .filter(Boolean) - .reduce((a, b) => (a > b ? a : b), null); - return ( -
-
toggleCollapse(partition)} - > -
- - - - {partition} -
-
- {flatFolderLastModified && ( - - {flatFolderLastModified} - - )} - - {files.length} - - { - if (el) el.indeterminate = someChecked; - }} - onChange={() => toggleBulk(files)} - onClick={(e) => e.stopPropagation()} - className="w-3.5 h-3.5 rounded accent-[#2E86C1] cursor-pointer" - title="Select all in partition" - /> -
-
- {!collapsed[partition] && ( -
- {files.map((filePath) => ( - - ))} -
- )} -
- ); - })} - - {viewMode === "tree" && totalPartitions > 0 && ( - <> - {treeData.files.length > 0 && ( -
- (unpartitioned) - {treeData.files.map((filePath) => ( - - ))} -
- )} - {Object.entries(treeData.children) - .sort(([a], [b]) => b.localeCompare(a)) - .map(([label, node]) => ( - - ))} - - )} -
-
- ); -} diff --git a/frontend/src/pages/FileTreePage.tsx b/frontend/src/pages/FileTreePage.tsx new file mode 100644 index 0000000..bb3ab51 --- /dev/null +++ b/frontend/src/pages/FileTreePage.tsx @@ -0,0 +1 @@ +export { default } from "../features/fileTree/FileTreePage"; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 28518de..9dc4236 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "jsx": "react-jsx", "types": ["vite/client", "node"], From c742c4547676c9f19fdfaee560ec22f6c87a9d27 Mon Sep 17 00:00:00 2001 From: Yaniv Zalach Date: Wed, 19 Aug 2026 00:31:43 +0300 Subject: [PATCH 02/11] Update --- frontend/src/components/PanelContent.jsx | 31 ++- .../src/components/ResizableSidePanel.jsx | 145 +++++++------- frontend/src/components/SidePanelFrame.tsx | 93 +++++++++ .../src/features/fileTree/FileTreePage.tsx | 11 +- .../fileTree/components/FileTreeDetailRow.tsx | 30 --- .../fileTree/components/FileTreeFileRow.tsx | 21 +- .../fileTree/components/FileTreeFolder.tsx | 10 +- .../fileTree/components/FileTreeInspector.tsx | 181 ++++++++++-------- .../fileTree/components/FileTreePartition.tsx | 6 +- .../fileTree/components/FileTreeToolbar.tsx | 10 +- .../components/FileTreeViewSettings.tsx | 21 +- .../features/fileTree/fileTreeModel.test.ts | 26 +++ .../src/features/fileTree/fileTreeModel.ts | 21 ++ 13 files changed, 383 insertions(+), 223 deletions(-) create mode 100644 frontend/src/components/SidePanelFrame.tsx delete mode 100644 frontend/src/features/fileTree/components/FileTreeDetailRow.tsx diff --git a/frontend/src/components/PanelContent.jsx b/frontend/src/components/PanelContent.jsx index d7ddb76..8ca5b54 100644 --- a/frontend/src/components/PanelContent.jsx +++ b/frontend/src/components/PanelContent.jsx @@ -71,7 +71,16 @@ function renderBreakablePath(path) { )); } -export function PanelHeader({ title, titleColor, subtitle, meta }) { +/** + * @param {{ title: string, titleColor?: string, subtitle?: string, meta?: string, preserveSubtitleEnd?: boolean }} props + */ +export function PanelHeader({ + title, + titleColor = null, + subtitle = null, + meta = null, + preserveSubtitleEnd = false, +}) { const opaqueColor = stripAlpha(titleColor); return (
@@ -82,9 +91,23 @@ export function PanelHeader({ title, titleColor, subtitle, meta }) { {title}
{subtitle ? ( -
- {renderBreakablePath(subtitle)} -
+ preserveSubtitleEnd ? ( +
+ {"\u202A" + subtitle + "\u202C"} +
+ ) : ( +
+ {renderBreakablePath(subtitle)} +
+ ) ) : null} {meta ?
{meta}
: null}
diff --git a/frontend/src/components/ResizableSidePanel.jsx b/frontend/src/components/ResizableSidePanel.jsx index 17da454..7c844d0 100644 --- a/frontend/src/components/ResizableSidePanel.jsx +++ b/frontend/src/components/ResizableSidePanel.jsx @@ -7,7 +7,7 @@ import { pxToRem, remToPx, } from "../layoutConstants"; -import { PanelHeader } from "./PanelContent"; +import SidePanelFrame from "./SidePanelFrame"; export { PANEL_WIDTH_RELAXED_REM as PANEL_WIDTH_RELAXED } from "../layoutConstants"; @@ -103,8 +103,6 @@ const ResizableSidePanel = forwardRef(function ResizableSidePanel( [panelWidthRem, maxContainerWidth], ); - const contentPad = isFullscreen ? "px-5" : "pl-9 pr-5"; - useEffect(() => { onLayoutChange?.({ isFullscreen, panelWidthRem }); }, [isFullscreen, panelWidthRem, onLayoutChange]); @@ -124,91 +122,80 @@ const ResizableSidePanel = forwardRef(function ResizableSidePanel( return () => window.removeEventListener("keydown", onKey); }, []); - return ( + const resizeHandle = !isFullscreen ? (
+ + ) : null; + + const fullscreenAction = ( + + ); + + return ( + - {!isFullscreen && ( -
- - )} -
-
- {header} -
- - -
-
-
- {children} -
-
-
+ {children} +
); }); diff --git a/frontend/src/components/SidePanelFrame.tsx b/frontend/src/components/SidePanelFrame.tsx new file mode 100644 index 0000000..c2b462d --- /dev/null +++ b/frontend/src/components/SidePanelFrame.tsx @@ -0,0 +1,93 @@ +import { forwardRef } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { cn } from "../shared/lib/cn"; + +type SidePanelVariant = "docked" | "floating"; + +interface SidePanelFrameProps { + ariaLabel?: string; + children: ReactNode; + className?: string; + closeLabel?: string; + contentClassName?: string; + contentTestId?: string; + header: ReactNode; + headerActions?: ReactNode; + headerClassName?: string; + leading?: ReactNode; + onClose: () => void; + style?: CSSProperties; + variant: SidePanelVariant; +} + +const SidePanelFrame = forwardRef( + ( + { + ariaLabel, + children, + className, + closeLabel = "Close panel", + contentClassName, + contentTestId, + header, + headerActions, + headerClassName, + leading, + onClose, + style, + variant, + }, + scrollRef, + ) => ( + + ), +); + +SidePanelFrame.displayName = "SidePanelFrame"; + +export default SidePanelFrame; diff --git a/frontend/src/features/fileTree/FileTreePage.tsx b/frontend/src/features/fileTree/FileTreePage.tsx index 0e8e967..91d07f8 100644 --- a/frontend/src/features/fileTree/FileTreePage.tsx +++ b/frontend/src/features/fileTree/FileTreePage.tsx @@ -85,7 +85,7 @@ const FileTreePage = () => { : null; return ( -
+
{ snapshots={displayedSnapshots} viewMode={pageState.viewMode} /> -
-
+
+
{snapshotErrors.length > 0 && (
diff --git a/frontend/src/features/fileTree/components/FileTreeDetailRow.tsx b/frontend/src/features/fileTree/components/FileTreeDetailRow.tsx deleted file mode 100644 index 6db1d8f..0000000 --- a/frontend/src/features/fileTree/components/FileTreeDetailRow.tsx +++ /dev/null @@ -1,30 +0,0 @@ -interface FileTreeDetailRowProps { - label: string; - value: unknown; -} - -const formatDetailValue = (value: unknown): string => { - if (value === null || value === undefined || value === "") return "-"; - if (typeof value === "string") return value; - if ( - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return String(value); - } - return JSON.stringify(value, null, 2); -}; - -const FileTreeDetailRow = ({ label, value }: FileTreeDetailRowProps) => ( -
- - {label} - - - {formatDetailValue(value)} - -
-); - -export default FileTreeDetailRow; diff --git a/frontend/src/features/fileTree/components/FileTreeFileRow.tsx b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx index 1ef1775..27d8da1 100644 --- a/frontend/src/features/fileTree/components/FileTreeFileRow.tsx +++ b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx @@ -63,17 +63,28 @@ const FileTreeFileRow = ({
- {file.id} + {"\u202A" + file.id + "\u202C"}
-
- {FILE_TYPE_LABELS[file.type]} +
+ + {FILE_TYPE_LABELS[file.type]} + + {timestamp != null && ( + + {timestamp} + + )}
{timestamp != null && ( - + {timestamp} )} diff --git a/frontend/src/features/fileTree/components/FileTreeFolder.tsx b/frontend/src/features/fileTree/components/FileTreeFolder.tsx index 6cfaef0..460639c 100644 --- a/frontend/src/features/fileTree/components/FileTreeFolder.tsx +++ b/frontend/src/features/fileTree/components/FileTreeFolder.tsx @@ -75,12 +75,12 @@ const FileTreeFolder = ({ onToggleExpanded(folder.id); }} className={cn( - "shrink-0 cursor-pointer text-accent transition-transform", + "flex size-8 shrink-0 cursor-pointer items-center justify-center rounded text-accent transition hover:bg-accent-muted", isExpanded ? "" : "-rotate-90", )} >
{latestTimestamp !== null && ( - + {latestTimestamp} )} @@ -121,7 +121,7 @@ const FileTreeFolder = ({ event.stopPropagation(); onExpandMany([folder.id, ...descendantFolderIds]); }} - className="cursor-pointer rounded p-1 text-slate-600 hover:bg-edge hover:text-slate-300" + className="flex size-8 cursor-pointer items-center justify-center rounded text-base text-slate-600 hover:bg-edge hover:text-slate-300" > ⇊ @@ -133,7 +133,7 @@ const FileTreeFolder = ({ event.stopPropagation(); onCollapseMany([folder.id, ...descendantFolderIds]); }} - className="cursor-pointer rounded p-1 text-slate-600 hover:bg-edge hover:text-slate-300" + className="flex size-8 cursor-pointer items-center justify-center rounded text-base text-slate-600 hover:bg-edge hover:text-slate-300" > ⇈ diff --git a/frontend/src/features/fileTree/components/FileTreeInspector.tsx b/frontend/src/features/fileTree/components/FileTreeInspector.tsx index da4a276..709321d 100644 --- a/frontend/src/features/fileTree/components/FileTreeInspector.tsx +++ b/frontend/src/features/fileTree/components/FileTreeInspector.tsx @@ -1,8 +1,13 @@ -import { useState } from "react"; import type { MouseEvent } from "react"; -import { calculateFileStatistics } from "../fileTreeModel"; +import { + isEmptyValue, + PanelDetailRow, + PanelHeader, + PanelSectionTitle, +} from "../../../components/PanelContent"; +import SidePanelFrame from "../../../components/SidePanelFrame"; +import { formatByteSize, getFileSizeBytes } from "../fileTreeModel"; import type { InspectedFileTreeItem } from "../fileTreeTypes"; -import FileTreeDetailRow from "./FileTreeDetailRow"; import FileTreeStatistics from "./FileTreeStatistics"; interface FileTreeInspectorProps { @@ -18,13 +23,20 @@ const humanizeKey = (key: string): string => .replaceAll("-", " ") .replace(/^./, (firstCharacter) => firstCharacter.toUpperCase()); +const FILE_SUMMARY_KEYS = new Set([ + "file_path", + "format", + "row_count", + "size_gb", + "type", +]); + const FileTreeInspector = ({ duplicatingNodeId, inspectedItem, onClose, onViewInGraph, }: FileTreeInspectorProps) => { - const [isPathCopied, setIsPathCopied] = useState(false); const title = inspectedItem.kind === "file" ? "Data file" @@ -38,86 +50,99 @@ const FileTreeInspector = ({ ? inspectedItem.folder.path : inspectedItem.partition.name; const statistics = + inspectedItem.kind === "folder" + ? inspectedItem.folder.statistics + : inspectedItem.kind === "partition" + ? inspectedItem.partition.statistics + : null; + const fileSummaryRows = inspectedItem.kind === "file" - ? calculateFileStatistics([inspectedItem.file]) - : inspectedItem.kind === "folder" - ? inspectedItem.folder.statistics - : inspectedItem.partition.statistics; - - const handleCopyPath = async () => { - await navigator.clipboard.writeText(subtitle); - setIsPathCopied(true); - window.setTimeout(() => { - setIsPathCopied(false); - }, 2000); - }; + ? [ + inspectedItem.file.details.size_gb === undefined + ? null + : [ + "File size", + formatByteSize(getFileSizeBytes(inspectedItem.file)), + ], + inspectedItem.file.details.row_count === undefined + ? null + : ["Rows", inspectedItem.file.details.row_count.toLocaleString()], + inspectedItem.file.details.format === undefined + ? null + : ["Format", inspectedItem.file.details.format], + ["File type", humanizeKey(inspectedItem.file.type)], + ].filter((row): row is [string, string] => row !== null) + : []; + const fileDetailRows = + inspectedItem.kind === "file" + ? Object.entries(inspectedItem.file.details).filter( + ([key, value]) => !FILE_SUMMARY_KEYS.has(key) && !isEmptyValue(value), + ) + : []; return ( - + + + )} + ); }; diff --git a/frontend/src/features/fileTree/components/FileTreePartition.tsx b/frontend/src/features/fileTree/components/FileTreePartition.tsx index 76e67bf..8afe668 100644 --- a/frontend/src/features/fileTree/components/FileTreePartition.tsx +++ b/frontend/src/features/fileTree/components/FileTreePartition.tsx @@ -66,12 +66,12 @@ const FileTreePartition = ({ onToggleExpanded(partition.id); }} className={cn( - "shrink-0 cursor-pointer text-accent transition-transform", + "flex size-8 shrink-0 cursor-pointer items-center justify-center rounded text-accent transition hover:bg-accent-muted", isExpanded ? "" : "-rotate-90", )} >
{latestTimestamp !== null && ( - + {latestTimestamp} )} diff --git a/frontend/src/features/fileTree/components/FileTreeToolbar.tsx b/frontend/src/features/fileTree/components/FileTreeToolbar.tsx index 20a2d5e..a9da01c 100644 --- a/frontend/src/features/fileTree/components/FileTreeToolbar.tsx +++ b/frontend/src/features/fileTree/components/FileTreeToolbar.tsx @@ -30,7 +30,7 @@ interface FileTreeToolbarProps { } const ACTION_CLASS = - "cursor-pointer rounded-lg border border-edge px-3 py-1.5 text-sm text-slate-400 transition hover:border-edge-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-30"; + "shrink-0 cursor-pointer rounded-lg border border-edge px-3 py-1.5 text-sm text-slate-400 transition hover:border-edge-hover hover:text-ink disabled:cursor-not-allowed disabled:opacity-30"; const FileTreeToolbar = ({ branches, @@ -64,7 +64,7 @@ const FileTreeToolbar = ({ }; return ( -
+
{ onSearchChange(event.target.value); }} - className="min-w-44 flex-1 rounded-lg border border-edge bg-surface px-3 py-1.5 text-sm text-ink placeholder:text-slate-500 focus:border-accent focus:outline-none sm:max-w-xs" + className="min-w-0 flex-1 rounded-lg border border-edge bg-surface px-3 py-1.5 text-sm text-ink placeholder:text-slate-500 focus:border-accent focus:outline-none sm:min-w-44 sm:max-w-xs" /> -
+
diff --git a/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx index 6c8c92a..e9274b1 100644 --- a/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx +++ b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "../../../shared/lib/cn"; +import { formatSnapshotVersion } from "../fileTreeModel"; import type { Branch, FileTreeViewMode, @@ -40,12 +41,10 @@ const FileTreeViewSettings = ({ useEffect(() => { if (!isOpen) return; const handleOutsideClick = (event: globalThis.MouseEvent) => { - if ( + const clickedInside = event.target instanceof Node && - !containerRef.current?.contains(event.target) - ) { - setIsOpen(false); - } + containerRef.current?.contains(event.target); + if (!clickedInside) setIsOpen(false); }; document.addEventListener("mousedown", handleOutsideClick); return () => { @@ -88,11 +87,9 @@ const FileTreeViewSettings = ({
-
-

View settings

-
+

View settings

{branches.length > 0 && (
); -} +}; diff --git a/frontend/src/features/fileTree/FileTreePage.tsx b/frontend/src/features/fileTree/FileTreeView.tsx similarity index 91% rename from frontend/src/features/fileTree/FileTreePage.tsx rename to frontend/src/features/fileTree/FileTreeView.tsx index 91d07f8..6a9e81e 100644 --- a/frontend/src/features/fileTree/FileTreePage.tsx +++ b/frontend/src/features/fileTree/FileTreeView.tsx @@ -1,5 +1,4 @@ import type { MouseEvent } from "react"; -import { useOutletContext } from "react-router-dom"; import PanelIssueNotice from "../../components/PanelIssueNotice"; import { useViewInGraph } from "../../hooks/useViewInGraph"; import FileTreeContent from "./components/FileTreeContent"; @@ -15,21 +14,23 @@ import { getSnapshotFileErrors, getSnapshotFiles, groupFilesByPartition, -} from "./fileTreeModel"; -import { fileTreeContextSchema } from "./fileTreeSchemas"; -import { useFileTreePageState } from "./useFileTreePageState"; +} from "./model"; +import type { FileTreeContext } from "./schemas"; +import { useFileTreeState } from "./useFileTreeState"; -const FileTreePage = () => { - const rawContext: unknown = useOutletContext(); - const context = fileTreeContextSchema.parse(rawContext); +interface FileTreeViewProps { + graphData: FileTreeContext; +} + +const FileTreeView = ({ graphData }: FileTreeViewProps) => { const { duplicatingNodeId, viewInGraph } = useViewInGraph(); const activeDuplicatingNodeId = typeof duplicatingNodeId === "string" ? duplicatingNodeId : null; - const pageState = useFileTreePageState(); + const pageState = useFileTreeState(); - const graphIndex = buildFileTreeGraphIndex(context); - const branches = getBranches(context); + const graphIndex = buildFileTreeGraphIndex(graphData); + const branches = getBranches(graphData); const selectedBranchName = branches.some( (branch) => branch.name === pageState.requestedBranchName, ) @@ -171,4 +172,4 @@ const FileTreePage = () => { ); }; -export default FileTreePage; +export default FileTreeView; diff --git a/frontend/src/features/fileTree/components/FileTreeContent.tsx b/frontend/src/features/fileTree/components/FileTreeContent.tsx index bb59998..e635d05 100644 --- a/frontend/src/features/fileTree/components/FileTreeContent.tsx +++ b/frontend/src/features/fileTree/components/FileTreeContent.tsx @@ -4,7 +4,7 @@ import type { FileTreeFolder, FileTreeViewMode, PartitionGroup, -} from "../fileTreeTypes"; +} from "../types"; import FileTreeFolderComponent from "./FileTreeFolder"; import FileTreePartition from "./FileTreePartition"; diff --git a/frontend/src/features/fileTree/components/FileTreeFileRow.tsx b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx index 27d8da1..5e1224c 100644 --- a/frontend/src/features/fileTree/components/FileTreeFileRow.tsx +++ b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx @@ -1,6 +1,6 @@ import type { MouseEvent } from "react"; import { cn } from "../../../shared/lib/cn"; -import type { DataFileNode } from "../fileTreeTypes"; +import type { DataFileNode } from "../types"; interface FileTreeFileRowProps { checkedFileIds: Set; diff --git a/frontend/src/features/fileTree/components/FileTreeFolder.tsx b/frontend/src/features/fileTree/components/FileTreeFolder.tsx index 460639c..617e6e6 100644 --- a/frontend/src/features/fileTree/components/FileTreeFolder.tsx +++ b/frontend/src/features/fileTree/components/FileTreeFolder.tsx @@ -1,7 +1,7 @@ import type { MouseEvent } from "react"; import { cn } from "../../../shared/lib/cn"; -import { getAllFolderIds, getLatestFileTimestamp } from "../fileTreeModel"; -import type { DataFileNode, FileTreeFolder as Folder } from "../fileTreeTypes"; +import { getAllFolderIds, getLatestFileTimestamp } from "../model"; +import type { DataFileNode, FileTreeFolder as Folder } from "../types"; import FileTreeFileRow from "./FileTreeFileRow"; interface FileTreeFolderProps { diff --git a/frontend/src/features/fileTree/components/FileTreeInspector.tsx b/frontend/src/features/fileTree/components/FileTreeInspector.tsx index 73c577c..3488cd4 100644 --- a/frontend/src/features/fileTree/components/FileTreeInspector.tsx +++ b/frontend/src/features/fileTree/components/FileTreeInspector.tsx @@ -1,16 +1,16 @@ import { useState } from "react"; import type { CSSProperties, MouseEvent } from "react"; import { - isEmptyValue, PanelDetailRow, PanelHeader, PanelSectionTitle, } from "../../../components/PanelContent"; +import { isEmptyValue } from "../../../shared/lib/isEmptyValue"; import SidePanelFrame, { SidePanelResizeHandle, } from "../../../components/SidePanelFrame"; -import { formatByteSize, getFileSizeBytes } from "../fileTreeModel"; -import type { InspectedFileTreeItem } from "../fileTreeTypes"; +import { formatByteSize, getFileSizeBytes } from "../model"; +import type { InspectedFileTreeItem } from "../types"; import FileTreeStatistics from "./FileTreeStatistics"; interface FileTreeInspectorProps { diff --git a/frontend/src/features/fileTree/components/FileTreePartition.tsx b/frontend/src/features/fileTree/components/FileTreePartition.tsx index 8afe668..2b5a057 100644 --- a/frontend/src/features/fileTree/components/FileTreePartition.tsx +++ b/frontend/src/features/fileTree/components/FileTreePartition.tsx @@ -1,7 +1,7 @@ import type { MouseEvent } from "react"; import { cn } from "../../../shared/lib/cn"; -import { getLatestFileTimestamp } from "../fileTreeModel"; -import type { DataFileNode, PartitionGroup } from "../fileTreeTypes"; +import { getLatestFileTimestamp } from "../model"; +import type { DataFileNode, PartitionGroup } from "../types"; import FileTreeFileRow from "./FileTreeFileRow"; interface FileTreePartitionProps { diff --git a/frontend/src/features/fileTree/components/FileTreeStatistics.tsx b/frontend/src/features/fileTree/components/FileTreeStatistics.tsx index 96c834c..5da4dd4 100644 --- a/frontend/src/features/fileTree/components/FileTreeStatistics.tsx +++ b/frontend/src/features/fileTree/components/FileTreeStatistics.tsx @@ -1,5 +1,5 @@ -import { formatByteSize } from "../fileTreeModel"; -import type { FileStatistics } from "../fileTreeTypes"; +import { formatByteSize } from "../model"; +import type { FileStatistics } from "../types"; interface FileTreeStatisticsProps { statistics: FileStatistics; diff --git a/frontend/src/features/fileTree/components/FileTreeToolbar.tsx b/frontend/src/features/fileTree/components/FileTreeToolbar.tsx index a9da01c..6465319 100644 --- a/frontend/src/features/fileTree/components/FileTreeToolbar.tsx +++ b/frontend/src/features/fileTree/components/FileTreeToolbar.tsx @@ -4,7 +4,7 @@ import type { FileTreeViewMode, SnapshotFileScope, SnapshotNode, -} from "../fileTreeTypes"; +} from "../types"; import FileTreeViewSettings from "./FileTreeViewSettings"; interface FileTreeToolbarProps { diff --git a/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx index 0b549dc..ee8dc1b 100644 --- a/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx +++ b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from "react"; import { useHotkey } from "@tanstack/react-hotkeys"; import { cn } from "../../../shared/lib/cn"; -import { formatSnapshotVersion } from "../fileTreeModel"; +import { formatSnapshotVersion } from "../model"; import type { Branch, FileTreeViewMode, SnapshotFileScope, SnapshotNode, -} from "../fileTreeTypes"; +} from "../types"; interface FileTreeViewSettingsProps { branches: Branch[]; diff --git a/frontend/src/features/fileTree/fileTreeModel.ts b/frontend/src/features/fileTree/model.ts similarity index 99% rename from frontend/src/features/fileTree/fileTreeModel.ts rename to frontend/src/features/fileTree/model.ts index 4821408..f99c05f 100644 --- a/frontend/src/features/fileTree/fileTreeModel.ts +++ b/frontend/src/features/fileTree/model.ts @@ -1,4 +1,4 @@ -import type { FileTreeContext, GraphNode } from "./fileTreeSchemas"; +import type { FileTreeContext, GraphNode } from "./schemas"; import type { Branch, DataFileNode, @@ -9,7 +9,7 @@ import type { PartitionGroup, SnapshotFileScope, SnapshotNode, -} from "./fileTreeTypes"; +} from "./types"; const BYTES_PER_GIBIBYTE = 1024 ** 3; const DATA_FILE_TYPES = new Set([ diff --git a/frontend/src/features/fileTree/fileTreeSchemas.ts b/frontend/src/features/fileTree/schemas.ts similarity index 91% rename from frontend/src/features/fileTree/fileTreeSchemas.ts rename to frontend/src/features/fileTree/schemas.ts index 4e405ed..1fd6b4d 100644 --- a/frontend/src/features/fileTree/fileTreeSchemas.ts +++ b/frontend/src/features/fileTree/schemas.ts @@ -11,7 +11,7 @@ const numericValueSchema = z .transform((value) => Number(value)) .pipe(z.number()); -const metadataReferenceSchema = z +const referenceSchema = z .object({ type: z.string(), "snapshot-id": identifierSchema, @@ -28,7 +28,7 @@ export const fileDetailsSchema = z earliest_appearing_snapshot_id: nullableIdentifierSchema, earliest_appearing_snapshot_timestamp: z.string().nullish(), format: z.string().optional(), - refs: z.record(z.string(), metadataReferenceSchema).optional(), + refs: z.record(z.string(), referenceSchema).optional(), size_gb: numericValueSchema.optional(), row_count: numericValueSchema.optional(), }) @@ -49,7 +49,7 @@ export const graphEdgeSchema = z.object({ const metadataSchema = z .object({ - refs: z.record(z.string(), metadataReferenceSchema).optional(), + refs: z.record(z.string(), referenceSchema).optional(), }) .catchall(z.unknown()); diff --git a/frontend/src/features/fileTree/fileTreeTypes.ts b/frontend/src/features/fileTree/types.ts similarity index 96% rename from frontend/src/features/fileTree/fileTreeTypes.ts rename to frontend/src/features/fileTree/types.ts index 967b821..aa38471 100644 --- a/frontend/src/features/fileTree/fileTreeTypes.ts +++ b/frontend/src/features/fileTree/types.ts @@ -1,4 +1,4 @@ -import type { GraphNode } from "./fileTreeSchemas"; +import type { GraphNode } from "./schemas"; export type DataFileType = "data" | "position_delete" | "equality_delete"; export type FileTreeViewMode = "flat" | "tree"; diff --git a/frontend/src/features/fileTree/useFileTreePageState.ts b/frontend/src/features/fileTree/useFileTreeState.ts similarity index 97% rename from frontend/src/features/fileTree/useFileTreePageState.ts rename to frontend/src/features/fileTree/useFileTreeState.ts index 9b2e9e7..6d03521 100644 --- a/frontend/src/features/fileTree/useFileTreePageState.ts +++ b/frontend/src/features/fileTree/useFileTreeState.ts @@ -6,9 +6,9 @@ import type { InspectedFileTreeItem, PartitionGroup, SnapshotFileScope, -} from "./fileTreeTypes"; +} from "./types"; -interface FileTreePageState { +interface FileTreeState { checkedFileIds: Set; closeInspector: () => void; collapseAll: () => void; @@ -35,7 +35,7 @@ interface FileTreePageState { viewMode: FileTreeViewMode; } -export const useFileTreePageState = (): FileTreePageState => { +export const useFileTreeState = (): FileTreeState => { const [search, setSearchState] = useState(""); const [requestedBranchName, setRequestedBranchName] = useState( "main", diff --git a/frontend/src/pages/FileTreePage.tsx b/frontend/src/pages/FileTreePage.tsx index bb3ab51..99a0c1c 100644 --- a/frontend/src/pages/FileTreePage.tsx +++ b/frontend/src/pages/FileTreePage.tsx @@ -1 +1,12 @@ -export { default } from "../features/fileTree/FileTreePage"; +import { useOutletContext } from "react-router-dom"; +import FileTreeView from "../features/fileTree/FileTreeView"; +import { fileTreeContextSchema } from "../features/fileTree/schemas"; + +const FileTreePage = () => { + const rawGraphData: unknown = useOutletContext(); + const graphData = fileTreeContextSchema.parse(rawGraphData); + + return ; +}; + +export default FileTreePage; diff --git a/frontend/src/shared/lib/isEmptyValue.ts b/frontend/src/shared/lib/isEmptyValue.ts new file mode 100644 index 0000000..2a9a117 --- /dev/null +++ b/frontend/src/shared/lib/isEmptyValue.ts @@ -0,0 +1,6 @@ +export const isEmptyValue = (value: unknown): boolean => { + if (value === null || value === undefined || value === "") return true; + if (Array.isArray(value)) return value.length === 0; + if (typeof value === "object") return Object.keys(value).length === 0; + return false; +}; From 13c8a3753a0ad8ced923ca6c1d19e43467496dbd Mon Sep 17 00:00:00 2001 From: Yaniv Zalach Date: Wed, 19 Aug 2026 23:01:17 +0300 Subject: [PATCH 07/11] Linting --- frontend/src/components/PanelContent.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/PanelContent.tsx b/frontend/src/components/PanelContent.tsx index c7fdbd5..ef028f0 100644 --- a/frontend/src/components/PanelContent.tsx +++ b/frontend/src/components/PanelContent.tsx @@ -178,9 +178,9 @@ export const PanelDetailRow = ({ {isCollapsible && (
diff --git a/frontend/src/features/fileTree/components/FileTreeInspector.tsx b/frontend/src/features/fileTree/components/FileTreeInspector.tsx index 3488cd4..a665671 100644 --- a/frontend/src/features/fileTree/components/FileTreeInspector.tsx +++ b/frontend/src/features/fileTree/components/FileTreeInspector.tsx @@ -52,18 +52,18 @@ const FileTreeInspector = ({ const title = inspectedItem.kind === "file" ? "Data file" - : inspectedItem.kind === "folder" - ? "Folder statistics" + : inspectedItem.kind === "partition-path" + ? "Partition path statistics" : "Partition statistics"; const subtitle = inspectedItem.kind === "file" ? inspectedItem.file.id - : inspectedItem.kind === "folder" - ? inspectedItem.folder.path + : inspectedItem.kind === "partition-path" + ? inspectedItem.partitionPathNode.path : inspectedItem.partition.name; const statistics = - inspectedItem.kind === "folder" - ? inspectedItem.folder.statistics + inspectedItem.kind === "partition-path" + ? inspectedItem.partitionPathNode.statistics : inspectedItem.kind === "partition" ? inspectedItem.partition.statistics : null; diff --git a/frontend/src/features/fileTree/components/PartitionPathIcon.tsx b/frontend/src/features/fileTree/components/PartitionPathIcon.tsx new file mode 100644 index 0000000..a3247e0 --- /dev/null +++ b/frontend/src/features/fileTree/components/PartitionPathIcon.tsx @@ -0,0 +1,12 @@ +const PartitionPathIcon = () => ( + +); + +export default PartitionPathIcon; diff --git a/frontend/src/features/fileTree/components/FileTreeFolder.tsx b/frontend/src/features/fileTree/components/PartitionPathNode.tsx similarity index 64% rename from frontend/src/features/fileTree/components/FileTreeFolder.tsx rename to frontend/src/features/fileTree/components/PartitionPathNode.tsx index 617e6e6..ef7224e 100644 --- a/frontend/src/features/fileTree/components/FileTreeFolder.tsx +++ b/frontend/src/features/fileTree/components/PartitionPathNode.tsx @@ -1,78 +1,86 @@ import type { MouseEvent } from "react"; import { cn } from "../../../shared/lib/cn"; -import { getAllFolderIds, getLatestFileTimestamp } from "../model"; -import type { DataFileNode, FileTreeFolder as Folder } from "../types"; +import { getAllPartitionPathNodeIds, getLatestFileTimestamp } from "../model"; +import type { + DataFileNode, + PartitionPathNode as PartitionPathNodeData, +} from "../types"; import FileTreeFileRow from "./FileTreeFileRow"; +import PartitionPathIcon from "./PartitionPathIcon"; -interface FileTreeFolderProps { +interface PartitionPathNodeProps { checkedFileIds: Set; depth: number; duplicatingNodeId: string | null; expandedItemIds: Set; inspectedFileId: string | null; - inspectedFolderId: string | null; - folder: Folder; - onCollapseMany: (folderIds: string[]) => void; - onExpandMany: (folderIds: string[]) => void; + inspectedPartitionPathNodeId: string | null; + onCollapseMany: (itemIds: string[]) => void; + onExpandMany: (itemIds: string[]) => void; onInspectFile: (file: DataFileNode) => void; - onInspectFolder: (folder: Folder) => void; + onInspectPartitionPathNode: (node: PartitionPathNodeData) => void; onToggleChecked: (fileId: string) => void; onToggleExpanded: (itemId: string) => void; onToggleFiles: (files: DataFileNode[]) => void; onViewInGraph: (event: MouseEvent, fileId: string) => void; + partitionPathNode: PartitionPathNodeData; } -const FileTreeFolder = ({ +const PartitionPathNode = ({ checkedFileIds, depth, duplicatingNodeId, expandedItemIds, inspectedFileId, - inspectedFolderId, - folder, + inspectedPartitionPathNodeId, onCollapseMany, onExpandMany, onInspectFile, - onInspectFolder, + onInspectPartitionPathNode, onToggleChecked, onToggleExpanded, onToggleFiles, onViewInGraph, -}: FileTreeFolderProps) => { - const isExpanded = expandedItemIds.has(folder.id); + partitionPathNode, +}: PartitionPathNodeProps) => { + const isExpanded = expandedItemIds.has(partitionPathNode.id); const isAllChecked = - folder.allFiles.length > 0 && - folder.allFiles.every((file) => checkedFileIds.has(file.id)); + partitionPathNode.allFiles.length > 0 && + partitionPathNode.allFiles.every((file) => checkedFileIds.has(file.id)); const isSomeChecked = !isAllChecked && - folder.allFiles.some((file) => checkedFileIds.has(file.id)); - const descendantFolderIds = getAllFolderIds(folder.children); - const latestTimestamp = getLatestFileTimestamp(folder.allFiles); + partitionPathNode.allFiles.some((file) => checkedFileIds.has(file.id)); + const descendantNodeIds = getAllPartitionPathNodeIds( + partitionPathNode.children, + ); + const latestTimestamp = getLatestFileTimestamp(partitionPathNode.allFiles); return (
{ - onInspectFolder(folder); + onInspectPartitionPathNode(partitionPathNode); }} className="flex cursor-pointer items-center px-4 py-2.5 transition hover:bg-surface-hover" >
- + - {folder.label} + {partitionPathNode.label}
@@ -109,17 +110,17 @@ const FileTreeFolder = ({ )} - {folder.allFiles.length} + {partitionPathNode.allFiles.length} - {folder.children.length > 0 && ( + {partitionPathNode.children.length > 0 && ( <>
{pageState.inspectedItem !== null && ( , fileId: string) => void; } -const FILE_TYPE_LABELS = { - data: "Data", - equality_delete: "Equality delete", - position_delete: "Position delete", -} as const; - const FileTreeFileRow = ({ checkedFileIds, duplicatingNodeId, @@ -74,7 +69,7 @@ const FileTreeFileRow = ({
- {FILE_TYPE_LABELS[file.type]} + {fileTypeLabel(file.type)} {timestamp != null && ( diff --git a/frontend/src/features/fileTree/components/FileTreeInspector.tsx b/frontend/src/features/fileTree/components/FileTreeInspector.tsx index 9a646fb..6a437b8 100644 --- a/frontend/src/features/fileTree/components/FileTreeInspector.tsx +++ b/frontend/src/features/fileTree/components/FileTreeInspector.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { CSSProperties, MouseEvent } from "react"; +import { fileTypeLabel } from "../../../graphConstants.js"; import { PanelDetailRow, PanelHeader, @@ -82,7 +83,7 @@ const FileTreeInspector = ({ inspectedItem.file.details.format === undefined ? null : ["Format", inspectedItem.file.details.format], - ["File type", humanizeKey(inspectedItem.file.type)], + ["File type", fileTypeLabel(inspectedItem.file.type)], ].filter((row): row is [string, string] => row !== null) : []; const fileDetailRows = @@ -137,6 +138,7 @@ const FileTreeInspector = ({ className="h-[55%] min-h-0 w-full shrink-0 border-t border-edge bg-surface md:h-auto md:w-[var(--file-tree-inspector-width)] md:min-w-[20rem] md:max-w-[70%] md:border-l-0 md:border-t-0" contentClassName="gap-5 overscroll-contain px-4 sm:px-5" contentTestId="file-tree-inspector-scroll" + enableScrollHotkeys header={ void; + snapshots: SnapshotNode[]; +} + +const FileTreeSnapshotSelect = ({ + className, + currentSnapshotId, + onChange, + snapshots, +}: FileTreeSnapshotSelectProps) => ( + +); + +export default FileTreeSnapshotSelect; diff --git a/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx index ee8dc1b..259ccee 100644 --- a/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx +++ b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from "react"; import { useHotkey } from "@tanstack/react-hotkeys"; import { cn } from "../../../shared/lib/cn"; -import { formatSnapshotVersion } from "../model"; import type { Branch, FileTreeViewMode, SnapshotFileScope, SnapshotNode, } from "../types"; +import FileTreeSnapshotSelect from "./FileTreeSnapshotSelect"; interface FileTreeViewSettingsProps { branches: Branch[]; @@ -114,29 +114,12 @@ const FileTreeViewSettings = ({ )} - +
File scope diff --git a/frontend/src/features/fileTree/model.ts b/frontend/src/features/fileTree/model.ts index c7197d0..fb05575 100644 --- a/frontend/src/features/fileTree/model.ts +++ b/frontend/src/features/fileTree/model.ts @@ -10,6 +10,7 @@ import type { SnapshotFileScope, SnapshotNode, } from "./types"; +import { fileTypeLabel } from "../../graphConstants.js"; const BYTES_PER_GIBIBYTE = 1024 ** 3; const DATA_FILE_TYPES = new Set([ @@ -149,7 +150,8 @@ export const getCurrentSnapshot = ( ): SnapshotNode | undefined => { if (requestedSnapshotId !== null) { const requestedSnapshot = snapshots.find( - (snapshot) => snapshot.details.snapshot_id === requestedSnapshotId, + (snapshot) => + (snapshot.details.snapshot_id ?? snapshot.id) === requestedSnapshotId, ); if (requestedSnapshot !== undefined) return requestedSnapshot; } @@ -384,7 +386,7 @@ export const getSnapshotFileErrors = ( if (node === undefined) continue; if (node.details.error) { errors.push( - `${node.type} (${node.label ?? node.id}): ${node.details.error}`, + `${fileTypeLabel(node.type)} (${node.label ?? node.id}): ${node.details.error}`, ); } for (const connection of graphIndex.adjacencyByNodeId[currentNodeId] ?? diff --git a/frontend/src/features/fileTree/schemas.ts b/frontend/src/features/fileTree/schemas.ts index 1fd6b4d..b42279c 100644 --- a/frontend/src/features/fileTree/schemas.ts +++ b/frontend/src/features/fileTree/schemas.ts @@ -11,6 +11,17 @@ const numericValueSchema = z .transform((value) => Number(value)) .pipe(z.number()); +const optionalTimestampSchema = z + .union([z.string(), z.number()]) + .nullish() + .transform((value) => value ?? undefined) + .optional(); + +const optionalNumericValueSchema = numericValueSchema + .nullish() + .transform((value) => value ?? undefined) + .optional(); + const referenceSchema = z .object({ type: z.string(), @@ -21,7 +32,7 @@ const referenceSchema = z export const fileDetailsSchema = z .object({ error: z.string().nullish(), - timestamp: z.union([z.string(), z.number()]).optional(), + timestamp: optionalTimestampSchema, snapshot_id: nullableIdentifierSchema, parent_id: nullableIdentifierSchema, partition: z.string().optional(), @@ -29,8 +40,8 @@ export const fileDetailsSchema = z earliest_appearing_snapshot_timestamp: z.string().nullish(), format: z.string().optional(), refs: z.record(z.string(), referenceSchema).optional(), - size_gb: numericValueSchema.optional(), - row_count: numericValueSchema.optional(), + size_gb: optionalNumericValueSchema, + row_count: optionalNumericValueSchema, }) .catchall(z.unknown()); diff --git a/frontend/src/pages/FileTreePage.tsx b/frontend/src/pages/FileTreePage.tsx index 99a0c1c..87f33a5 100644 --- a/frontend/src/pages/FileTreePage.tsx +++ b/frontend/src/pages/FileTreePage.tsx @@ -1,12 +1,25 @@ import { useOutletContext } from "react-router-dom"; +import PanelIssueNotice from "../components/PanelIssueNotice"; import FileTreeView from "../features/fileTree/FileTreeView"; import { fileTreeContextSchema } from "../features/fileTree/schemas"; const FileTreePage = () => { const rawGraphData: unknown = useOutletContext(); - const graphData = fileTreeContextSchema.parse(rawGraphData); + const graphDataResult = fileTreeContextSchema.safeParse(rawGraphData); + if (!graphDataResult.success) { + const validationErrors = graphDataResult.error.issues + .map(({ message, path }) => `${path.join(".") || "data"}: ${message}`) + .join("\n"); + return ( +
+ + {`File tree data is invalid.\n${validationErrors}`} + +
+ ); + } - return ; + return ; }; export default FileTreePage;