diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e172c1f..bb7823e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "icegraph-frontend", "dependencies": { - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-hotkeys": "^0.10.0", "clsx": "^2.1.1", "json-bigint": "^1.0.0", "react": "^19.2.8", @@ -1807,30 +1807,69 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tanstack/query-core": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", - "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "node_modules/@tanstack/hotkeys": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@tanstack/hotkeys/-/hotkeys-0.8.0.tgz", + "integrity": "sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==", "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=18" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/react-query": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", - "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "node_modules/@tanstack/react-hotkeys": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-hotkeys/-/react-hotkeys-0.10.0.tgz", + "integrity": "sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.4" + "@tanstack/hotkeys": "0.8.0", + "@tanstack/react-store": "^0.11.0" + }, + "engines": { + "node": ">=18" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "react": "^18 || ^19" + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tweenjs/tween.js": { @@ -4647,6 +4686,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index e8d865a..eb601a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "msw:init": "msw init public/" }, "dependencies": { + "@tanstack/react-hotkeys": "^0.10.0", "clsx": "^2.1.1", "json-bigint": "^1.0.0", "react": "^19.2.8", diff --git a/frontend/src/components/PanelContent.jsx b/frontend/src/components/PanelContent.tsx similarity index 63% rename from frontend/src/components/PanelContent.jsx rename to frontend/src/components/PanelContent.tsx index d7ddb76..ef028f0 100644 --- a/frontend/src/components/PanelContent.jsx +++ b/frontend/src/components/PanelContent.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { ReactNode } from "react"; import CopyIconButton from "./CopyIconButton"; import { UI_BODY_MUTED_ITALIC_CLASS, @@ -47,17 +48,21 @@ export const PANEL_DIFF_AFTER_VALUE_CLASS = `${PANEL_DIFF_VALUE_BASE_CLASS} bg-g export const DEFAULT_COLLAPSE_LINES = 15; -const colorParseCtx = document.createElement("canvas").getContext("2d"); +const colorParseContext = document.createElement("canvas").getContext("2d"); -function stripAlpha(color) { - if (typeof color !== "string") return color; - colorParseCtx.fillStyle = color; - const m = colorParseCtx.fillStyle.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); +const stripAlpha = (color: string | null): string | null => { + if (color === null || colorParseContext === null) return color; + colorParseContext.fillStyle = color; + const normalizedColor = colorParseContext.fillStyle; + if (typeof normalizedColor !== "string") return color; + const match = normalizedColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); - return m ? `rgb(${m[1]}, ${m[2]}, ${m[3]})` : colorParseCtx.fillStyle; -} + return match + ? `rgb(${String(match[1])}, ${String(match[2])}, ${String(match[3])})` + : normalizedColor; +}; -function renderBreakablePath(path) { +const renderBreakablePath = (path: string) => { const segments = path.split("/"); return segments.map((segment, i) => ( @@ -69,9 +74,23 @@ function renderBreakablePath(path) { ) : null} )); +}; + +interface PanelHeaderProps { + meta?: ReactNode; + preserveSubtitleEnd?: boolean; + subtitle?: string | null; + title: ReactNode; + titleColor?: string | null; } -export function PanelHeader({ title, titleColor, subtitle, meta }) { +export const PanelHeader = ({ + title, + titleColor = null, + subtitle = null, + meta = null, + preserveSubtitleEnd = false, +}: PanelHeaderProps) => { const opaqueColor = stripAlpha(titleColor); return (
@@ -82,43 +101,71 @@ export function PanelHeader({ title, titleColor, subtitle, meta }) { {title}
{subtitle ? ( -
- {renderBreakablePath(subtitle)} -
+ preserveSubtitleEnd ? ( +
+ {"\u202A" + subtitle + "\u202C"} +
+ ) : ( +
+ {renderBreakablePath(subtitle)} +
+ ) ) : null} {meta ?
{meta}
: null} ); +}; + +interface PanelSectionTitleProps { + children: ReactNode; + className?: string; } -export function PanelSectionTitle({ children, className = "" }) { +export const PanelSectionTitle = ({ + children, + className = "", +}: PanelSectionTitleProps) => { return (
{children}
); -} +}; -export function isEmptyValue(value) { - if (value == null || value === "") return true; - if (Array.isArray(value)) return value.length === 0; - if (typeof value === "object") return Object.keys(value).length === 0; - return false; +interface PanelDetailRowProps { + collapseLineCount?: number; + label: ReactNode; + relaxedCollapse?: boolean; + value: unknown; } -export function PanelDetailRow({ +export const PanelDetailRow = ({ label, value, relaxedCollapse = false, collapseLineCount = DEFAULT_COLLAPSE_LINES, -}) { - const displayValue = - typeof value === "object" && value !== null - ? JSON.stringify(value, null, 2) - : value; - - const textToCopy = - displayValue != null && displayValue !== "" ? String(displayValue) : ""; +}: PanelDetailRowProps) => { + const textToCopy = (() => { + if (value === null || value === undefined || value === "") return ""; + if (typeof value === "object") return JSON.stringify(value, null, 2); + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "bigint" || + typeof value === "boolean" + ) { + return String(value); + } + return ""; + })(); const hasValue = textToCopy !== ""; const lineCount = hasValue ? textToCopy.split("\n").length : 0; const isCollapsible = lineCount > collapseLineCount; @@ -131,10 +178,12 @@ export function PanelDetailRow({ {isCollapsible && ( )} @@ -165,4 +214,4 @@ export function PanelDetailRow({ ); -} +}; diff --git a/frontend/src/components/ResizableSidePanel.jsx b/frontend/src/components/ResizableSidePanel.jsx index 17da454..1402308 100644 --- a/frontend/src/components/ResizableSidePanel.jsx +++ b/frontend/src/components/ResizableSidePanel.jsx @@ -1,13 +1,12 @@ import { forwardRef, useCallback, useEffect, useState } from "react"; import { - PANEL_ACCENT_BORDER_REM, PANEL_GUTTER_REM, PANEL_WIDTH_DEFAULT_REM, PANEL_WIDTH_MIN_REM, pxToRem, remToPx, } from "../layoutConstants"; -import { PanelHeader } from "./PanelContent"; +import SidePanelFrame, { SidePanelResizeHandle } from "./SidePanelFrame"; export { PANEL_WIDTH_RELAXED_REM as PANEL_WIDTH_RELAXED } from "../layoutConstants"; @@ -103,8 +102,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 +121,49 @@ const ResizableSidePanel = forwardRef(function ResizableSidePanel( return () => window.removeEventListener("keydown", onKey); }, []); + 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..cc96796 --- /dev/null +++ b/frontend/src/components/SidePanelFrame.tsx @@ -0,0 +1,174 @@ +import { forwardRef, useRef } from "react"; +import type { CSSProperties, MouseEventHandler, ReactNode } from "react"; +import { useHotkey } from "@tanstack/react-hotkeys"; +import { PANEL_ACCENT_BORDER_REM } from "../layoutConstants"; +import { cn } from "../shared/lib/cn"; + +type SidePanelVariant = "docked" | "floating"; + +interface SidePanelFrameProps { + ariaLabel?: string; + children: ReactNode; + className?: string; + closeLabel?: string; + contentClassName?: string; + contentTestId?: string; + enableScrollHotkeys?: boolean; + header: ReactNode; + headerActions?: ReactNode; + headerClassName?: string; + leading?: ReactNode; + onClose: () => void; + style?: CSSProperties; + variant: SidePanelVariant; +} + +interface SidePanelResizeHandleProps { + accentColor: string; + className?: string; + onMouseDown: MouseEventHandler; + title?: string; +} + +export const SidePanelResizeHandle = ({ + accentColor, + className, + onMouseDown, + title = "Drag to resize", +}: SidePanelResizeHandleProps) => ( +
+ +); + +const SidePanelFrame = forwardRef( + ( + { + ariaLabel, + children, + className, + closeLabel = "Close panel", + contentClassName, + contentTestId, + enableScrollHotkeys = false, + header, + headerActions, + headerClassName, + leading, + onClose, + style, + variant, + }, + scrollRef, + ) => { + const contentRef = useRef(null); + const setContentRef = (node: HTMLDivElement | null) => { + contentRef.current = node; + if (typeof scrollRef === "function") scrollRef(node); + else if (scrollRef !== null) scrollRef.current = node; + }; + useHotkey( + "J", + () => { + contentRef.current?.scrollBy({ behavior: "smooth", top: 120 }); + }, + { enabled: enableScrollHotkeys }, + ); + useHotkey( + "K", + () => { + contentRef.current?.scrollBy({ behavior: "smooth", top: -120 }); + }, + { enabled: enableScrollHotkeys }, + ); + + return ( + + ); + }, +); + +SidePanelFrame.displayName = "SidePanelFrame"; + +export default SidePanelFrame; diff --git a/frontend/src/features/fileTree/FileTreeView.tsx b/frontend/src/features/fileTree/FileTreeView.tsx new file mode 100644 index 0000000..d387609 --- /dev/null +++ b/frontend/src/features/fileTree/FileTreeView.tsx @@ -0,0 +1,175 @@ +import type { MouseEvent } from "react"; +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 { + buildPartitionPathTree, + buildFileTreeGraphIndex, + getAllPartitionPathNodeIds, + getBranches, + getCurrentSnapshot, + getDisplayedSnapshots, + getSnapshotFileErrors, + getSnapshotFiles, + groupFilesByPartition, +} from "./model"; +import type { FileTreeContext } from "./schemas"; +import { useFileTreeState } from "./useFileTreeState"; + +interface FileTreeViewProps { + graphData: FileTreeContext; +} + +const FileTreeView = ({ graphData }: FileTreeViewProps) => { + const { duplicatingNodeId, viewInGraph } = useViewInGraph(); + const activeDuplicatingNodeId = + typeof duplicatingNodeId === "string" ? duplicatingNodeId : null; + + const pageState = useFileTreeState(); + + const graphIndex = buildFileTreeGraphIndex(graphData); + const branches = getBranches(graphData); + 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 partitionPathNodes = buildPartitionPathTree(partitions); + const visibleFiles = partitions.flatMap((partition) => partition.files); + const snapshotErrors = getSnapshotFileErrors(currentSnapshot, graphIndex); + + const handleViewInGraph = ( + event: MouseEvent, + fileId: string, + ) => { + void viewInGraph(event, fileId); + }; + + const inspectedFileId = + pageState.inspectedItem?.kind === "file" + ? pageState.inspectedItem.file.id + : null; + const inspectedPartitionPathNodeId = + pageState.inspectedItem?.kind === "partition-path" + ? pageState.inspectedItem.partitionPathNode.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" + ? [ + ...getAllPartitionPathNodeIds(partitionPathNodes), + ...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} + /> +
+
+ {currentSnapshot === undefined ? ( +

+ No snapshots available for this branch in the loaded range. +

+ ) : ( + <> + {snapshotErrors.length > 0 && ( +
+ + {snapshotErrors.join("\n")} + +
+ )} + + + )} +
+ {pageState.inspectedItem !== null && ( + + )} +
+
+ ); +}; + +export default FileTreeView; diff --git a/frontend/src/features/fileTree/components/FileTreeContent.tsx b/frontend/src/features/fileTree/components/FileTreeContent.tsx new file mode 100644 index 0000000..606b091 --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeContent.tsx @@ -0,0 +1,139 @@ +import type { MouseEvent } from "react"; +import type { + DataFileNode, + FileTreeViewMode, + PartitionGroup, + PartitionPathNode, +} from "../types"; +import FileTreePartition from "./FileTreePartition"; +import PartitionPathNodeComponent from "./PartitionPathNode"; + +interface FileTreeContentProps { + checkedFileIds: Set; + duplicatingNodeId: string | null; + expandedItemIds: Set; + inspectedFileId: string | null; + inspectedPartitionPathNodeId: string | null; + inspectedPartitionId: string | null; + onCollapseMany: (itemIds: string[]) => void; + onExpandMany: (itemIds: string[]) => void; + onInspectFile: (file: DataFileNode) => void; + onInspectPartitionPathNode: (node: PartitionPathNode) => 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[]; + partitionPathNodes: PartitionPathNode[]; + search: string; + viewMode: FileTreeViewMode; +} + +const FileTreeContent = ({ + checkedFileIds, + duplicatingNodeId, + expandedItemIds, + inspectedFileId, + inspectedPartitionPathNodeId, + inspectedPartitionId, + onCollapseMany, + onExpandMany, + onInspectFile, + onInspectPartitionPathNode, + onInspectPartition, + onToggleChecked, + onToggleExpanded, + onToggleFiles, + onViewInGraph, + partitions, + partitionPathNodes, + 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 && ( + + )} +
+ {partitionPathNodes.map((partitionPathNode) => ( + + ))} +
+
+ ); +}; + +export default FileTreeContent; diff --git a/frontend/src/features/fileTree/components/FileTreeFileRow.tsx b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx new file mode 100644 index 0000000..b965e7a --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeFileRow.tsx @@ -0,0 +1,114 @@ +import type { MouseEvent } from "react"; +import { fileTypeLabel } from "../../../graphConstants.js"; +import { cn } from "../../../shared/lib/cn"; +import type { DataFileNode } from "../types"; + +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 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" + /> +
+
+ {"\u202A" + file.id + "\u202C"} +
+
+ + {fileTypeLabel(file.type)} + + {timestamp != null && ( + + {timestamp} + + )} +
+
+ {timestamp != null && ( + + {timestamp} + + )} + +
+ ); +}; + +export default FileTreeFileRow; diff --git a/frontend/src/features/fileTree/components/FileTreeInspector.tsx b/frontend/src/features/fileTree/components/FileTreeInspector.tsx new file mode 100644 index 0000000..6a437b8 --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeInspector.tsx @@ -0,0 +1,210 @@ +import { useState } from "react"; +import type { CSSProperties, MouseEvent } from "react"; +import { fileTypeLabel } from "../../../graphConstants.js"; +import { + PanelDetailRow, + PanelHeader, + PanelSectionTitle, +} from "../../../components/PanelContent"; +import { isEmptyValue } from "../../../shared/lib/isEmptyValue"; +import SidePanelFrame, { + SidePanelResizeHandle, +} from "../../../components/SidePanelFrame"; +import { formatByteSize, getFileSizeBytes } from "../model"; +import type { InspectedFileTreeItem } from "../types"; +import FileTreeStatistics from "./FileTreeStatistics"; + +interface FileTreeInspectorProps { + duplicatingNodeId: string | null; + inspectedItem: InspectedFileTreeItem; + onClose: () => void; + onViewInGraph: (event: MouseEvent, fileId: string) => void; +} + +interface FileTreeInspectorStyle extends CSSProperties { + "--file-tree-inspector-width": string; + "--panel-accent": string; +} + +const PANEL_ACCENT = "#2e86c1"; +const PANEL_MIN_WIDTH_PX = 320; + +const humanizeKey = (key: string): string => + key + .replaceAll("_", " ") + .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 [panelWidthPx, setPanelWidthPx] = useState(null); + const title = + inspectedItem.kind === "file" + ? "Data file" + : inspectedItem.kind === "partition-path" + ? "Partition path statistics" + : "Partition statistics"; + const subtitle = + inspectedItem.kind === "file" + ? inspectedItem.file.id + : inspectedItem.kind === "partition-path" + ? inspectedItem.partitionPathNode.path + : inspectedItem.partition.name; + const statistics = + inspectedItem.kind === "partition-path" + ? inspectedItem.partitionPathNode.statistics + : inspectedItem.kind === "partition" + ? inspectedItem.partition.statistics + : null; + const fileSummaryRows = + inspectedItem.kind === "file" + ? [ + 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", fileTypeLabel(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), + ) + : []; + const panelStyle: FileTreeInspectorStyle = { + "--file-tree-inspector-width": + panelWidthPx === null ? "38%" : `${String(panelWidthPx)}px`, + "--panel-accent": PANEL_ACCENT, + }; + + const startResize = (event: MouseEvent) => { + event.preventDefault(); + const panel = event.currentTarget.parentElement; + if (panel === null) return; + const container = panel.parentElement; + if (container === null) return; + + const startX = event.clientX; + const startWidth = panel.getBoundingClientRect().width; + const maximumWidth = Math.max( + PANEL_MIN_WIDTH_PX, + container.getBoundingClientRect().width * 0.7, + ); + const handleMove = (moveEvent: globalThis.MouseEvent) => { + const nextWidth = Math.min( + maximumWidth, + Math.max(PANEL_MIN_WIDTH_PX, startWidth + startX - moveEvent.clientX), + ); + setPanelWidthPx(nextWidth); + }; + const handleUp = () => { + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = "ew-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleUp); + }; + + return ( + + } + leading={ + + } + onClose={onClose} + style={panelStyle} + > + {inspectedItem.kind === "file" ? ( +
+ File summary +
+ {fileSummaryRows.map(([label, value]) => ( + + ))} +
+
+ ) : statistics !== null ? ( + + ) : null} + {inspectedItem.kind === "file" && ( + <> +
+ +
+
+ + File information + +
+ + {fileDetailRows.map(([key, value]) => ( + + ))} +
+
+ + )} +
+ ); +}; + +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..2b5a057 --- /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 "../model"; +import type { DataFileNode, PartitionGroup } from "../types"; +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/FileTreeSnapshotSelect.tsx b/frontend/src/features/fileTree/components/FileTreeSnapshotSelect.tsx new file mode 100644 index 0000000..9ae364f --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeSnapshotSelect.tsx @@ -0,0 +1,42 @@ +import { formatSnapshotVersion } from "../model"; +import type { SnapshotNode } from "../types"; + +interface FileTreeSnapshotSelectProps { + className: string; + currentSnapshotId: string; + onChange: (snapshotId: string) => void; + snapshots: SnapshotNode[]; +} + +const FileTreeSnapshotSelect = ({ + className, + currentSnapshotId, + onChange, + snapshots, +}: FileTreeSnapshotSelectProps) => ( + +); + +export default FileTreeSnapshotSelect; diff --git a/frontend/src/features/fileTree/components/FileTreeStatistics.tsx b/frontend/src/features/fileTree/components/FileTreeStatistics.tsx new file mode 100644 index 0000000..5da4dd4 --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeStatistics.tsx @@ -0,0 +1,64 @@ +import { formatByteSize } from "../model"; +import type { FileStatistics } from "../types"; + +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..6465319 --- /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 "../types"; +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 = + "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, + 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-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" + /> +
+ + + + + + + {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..259ccee --- /dev/null +++ b/frontend/src/features/fileTree/components/FileTreeViewSettings.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef, useState } from "react"; +import { useHotkey } from "@tanstack/react-hotkeys"; +import { cn } from "../../../shared/lib/cn"; +import type { + Branch, + FileTreeViewMode, + SnapshotFileScope, + SnapshotNode, +} from "../types"; +import FileTreeSnapshotSelect from "./FileTreeSnapshotSelect"; + +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); + useHotkey("Escape", setIsOpen.bind(null, false), { enabled: isOpen }); + + useEffect(() => { + if (!isOpen) return; + const handleOutsideClick = (event: globalThis.MouseEvent) => { + const clickedInside = + event.target instanceof Node && + containerRef.current?.contains(event.target); + if (!clickedInside) 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/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/PartitionPathNode.tsx b/frontend/src/features/fileTree/components/PartitionPathNode.tsx new file mode 100644 index 0000000..ef7224e --- /dev/null +++ b/frontend/src/features/fileTree/components/PartitionPathNode.tsx @@ -0,0 +1,204 @@ +import type { MouseEvent } from "react"; +import { cn } from "../../../shared/lib/cn"; +import { getAllPartitionPathNodeIds, getLatestFileTimestamp } from "../model"; +import type { + DataFileNode, + PartitionPathNode as PartitionPathNodeData, +} from "../types"; +import FileTreeFileRow from "./FileTreeFileRow"; +import PartitionPathIcon from "./PartitionPathIcon"; + +interface PartitionPathNodeProps { + checkedFileIds: Set; + depth: number; + duplicatingNodeId: string | null; + expandedItemIds: Set; + inspectedFileId: string | null; + inspectedPartitionPathNodeId: string | null; + onCollapseMany: (itemIds: string[]) => void; + onExpandMany: (itemIds: string[]) => void; + onInspectFile: (file: DataFileNode) => 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 PartitionPathNode = ({ + checkedFileIds, + depth, + duplicatingNodeId, + expandedItemIds, + inspectedFileId, + inspectedPartitionPathNodeId, + onCollapseMany, + onExpandMany, + onInspectFile, + onInspectPartitionPathNode, + onToggleChecked, + onToggleExpanded, + onToggleFiles, + onViewInGraph, + partitionPathNode, +}: PartitionPathNodeProps) => { + const isExpanded = expandedItemIds.has(partitionPathNode.id); + const isAllChecked = + partitionPathNode.allFiles.length > 0 && + partitionPathNode.allFiles.every((file) => checkedFileIds.has(file.id)); + const isSomeChecked = + !isAllChecked && + partitionPathNode.allFiles.some((file) => checkedFileIds.has(file.id)); + const descendantNodeIds = getAllPartitionPathNodeIds( + partitionPathNode.children, + ); + const latestTimestamp = getLatestFileTimestamp(partitionPathNode.allFiles); + + return ( +
+
{ + onInspectPartitionPathNode(partitionPathNode); + }} + className="flex cursor-pointer items-center px-4 py-2.5 transition hover:bg-surface-hover" + > +
+ + + + {partitionPathNode.label} + +
+
+ {latestTimestamp !== null && ( + + {latestTimestamp} + + )} + + {partitionPathNode.allFiles.length} + + {partitionPathNode.children.length > 0 && ( + <> + + + + )} + { + if (element !== null) element.indeterminate = isSomeChecked; + }} + onChange={() => { + onToggleFiles(partitionPathNode.allFiles); + }} + onClick={(event) => { + event.stopPropagation(); + }} + className="size-3.5 cursor-pointer rounded accent-accent" + /> +
+
+ {isExpanded && ( +
+ {partitionPathNode.children.map((child) => ( + + ))} + {partitionPathNode.directFiles.map((file) => ( + + ))} +
+ )} +
+ ); +}; + +export default PartitionPathNode; diff --git a/frontend/src/features/fileTree/model.ts b/frontend/src/features/fileTree/model.ts new file mode 100644 index 0000000..fb05575 --- /dev/null +++ b/frontend/src/features/fileTree/model.ts @@ -0,0 +1,411 @@ +import type { FileTreeContext, GraphNode } from "./schemas"; +import type { + Branch, + DataFileNode, + DataFileType, + FileStatistics, + FileTreeGraphIndex, + PartitionGroup, + PartitionPathNode, + SnapshotFileScope, + SnapshotNode, +} from "./types"; +import { fileTypeLabel } from "../../graphConstants.js"; + +const BYTES_PER_GIBIBYTE = 1024 ** 3; +const DATA_FILE_TYPES = new Set([ + "data", + "position_delete", + "equality_delete", +]); + +interface MutablePartitionPathNode { + children: Map; + directFiles: DataFileNode[]; + label: string; + path: string; +} + +interface BranchReference { + "snapshot-id": string; + type: string; +} + +interface VersionedBranch extends Branch { + timestamp: number; +} + +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 = (node: GraphNode): number => { + const timestamp = node.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[] => { + const branchesByName = new Map(); + const recordBranch = ( + name: string, + reference: BranchReference, + timestamp: number, + ) => { + if (reference.type !== "branch") return; + const existing = branchesByName.get(name); + if (existing !== undefined && existing.timestamp > timestamp) return; + branchesByName.set(name, { + headSnapshotId: reference["snapshot-id"], + name, + timestamp, + }); + }; + + for (const node of context.nodes) { + if (node.type !== "metadata" && node.type !== "main_metadata") continue; + for (const [name, reference] of Object.entries(node.details.refs ?? {})) { + recordBranch(name, reference, getTimestampSortValue(node)); + } + } + for (const [name, reference] of Object.entries( + context.metadata?.refs ?? {}, + )) { + recordBranch(name, reference, Number.POSITIVE_INFINITY); + } + + return [...branchesByName.values()] + .map(({ headSnapshotId, name }) => ({ headSnapshotId, 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 ?? snapshot.id) === requestedSnapshotId, + ); + if (requestedSnapshot !== undefined) return requestedSnapshot; + } + return snapshots.at(-1); +}; + +const collectSnapshotFiles = ( + snapshot: SnapshotNode | undefined, + graphIndex: FileTreeGraphIndex, +): 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); + } + } + } + + return [...filesById.values()]; +}; + +export const getSnapshotFiles = ( + snapshot: SnapshotNode | undefined, + graphIndex: FileTreeGraphIndex, + scope: SnapshotFileScope, +): DataFileNode[] => { + const snapshotFiles = collectSnapshotFiles(snapshot, graphIndex); + if (scope === "snapshot") return snapshotFiles; + if (snapshot === undefined) return []; + + const parentSnapshotId = snapshot.details.parent_id; + const parentSnapshot = + parentSnapshotId == null + ? undefined + : graphIndex.snapshotsBySnapshotId[parentSnapshotId]; + if (parentSnapshot !== undefined) { + const parentFileIds = new Set( + collectSnapshotFiles(parentSnapshot, graphIndex).map(({ id }) => id), + ); + return snapshotFiles.filter((file) => !parentFileIds.has(file.id)); + } + + 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 getFileSizeBytes = (file: DataFileNode): number => + (file.details.size_gb ?? 0) * BYTES_PER_GIBIBYTE; + +export const formatSnapshotVersion = ( + snapshot: SnapshotNode, + isLatest: boolean, +): string => { + const snapshotId = snapshot.details.snapshot_id ?? snapshot.id; + const rawOperation = snapshot.details.operation_description; + const operation = + typeof rawOperation === "string" && rawOperation.trim() !== "" + ? rawOperation + : "Snapshot"; + const timestamp = snapshot.details.timestamp; + const timestampLabel = + typeof timestamp === "string" || typeof timestamp === "number" + ? String(timestamp) + : "Unknown time"; + return `ID ${snapshotId} · ${operation} · ${timestampLabel}${isLatest ? " · latest" : ""}`; +}; + +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 convertMutablePartitionPathNode = ( + mutableNode: MutablePartitionPathNode, +): PartitionPathNode => { + const children = [...mutableNode.children.values()] + .map(convertMutablePartitionPathNode) + .sort((first, second) => second.label.localeCompare(first.label)); + const allFiles = [ + ...mutableNode.directFiles, + ...children.flatMap((child) => child.allFiles), + ]; + return { + allFiles, + children, + directFiles: mutableNode.directFiles, + id: `partition-path:${mutableNode.path}`, + label: mutableNode.label, + path: mutableNode.path, + statistics: calculateFileStatistics(allFiles), + }; +}; + +export const buildPartitionPathTree = ( + partitions: PartitionGroup[], +): PartitionPathNode[] => { + const root: MutablePartitionPathNode = { + children: new Map(), + directFiles: [], + label: "", + path: "", + }; + + for (const partition of partitions) { + if (partition.name === "(unpartitioned)") continue; + let currentNode = root; + for (const segment of partition.name.split(", ")) { + const path = + currentNode.path === "" ? segment : `${currentNode.path}/${segment}`; + const existingNode = currentNode.children.get(segment); + if (existingNode !== undefined) { + currentNode = existingNode; + continue; + } + const newNode: MutablePartitionPathNode = { + children: new Map(), + directFiles: [], + label: segment, + path, + }; + currentNode.children.set(segment, newNode); + currentNode = newNode; + } + currentNode.directFiles.push(...partition.files); + } + + return [...root.children.values()] + .map(convertMutablePartitionPathNode) + .sort((first, second) => second.label.localeCompare(first.label)); +}; + +export const getAllPartitionPathNodeIds = ( + nodes: PartitionPathNode[], +): string[] => + nodes.flatMap((node) => [ + node.id, + ...getAllPartitionPathNodeIds(node.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( + `${fileTypeLabel(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/schemas.ts b/frontend/src/features/fileTree/schemas.ts new file mode 100644 index 0000000..b42279c --- /dev/null +++ b/frontend/src/features/fileTree/schemas.ts @@ -0,0 +1,76 @@ +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()); + +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(), + "snapshot-id": identifierSchema, + }) + .catchall(z.unknown()); + +export const fileDetailsSchema = z + .object({ + error: z.string().nullish(), + timestamp: optionalTimestampSchema, + 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(), + refs: z.record(z.string(), referenceSchema).optional(), + size_gb: optionalNumericValueSchema, + row_count: optionalNumericValueSchema, + }) + .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 metadataSchema = z + .object({ + refs: z.record(z.string(), referenceSchema).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/types.ts b/frontend/src/features/fileTree/types.ts new file mode 100644 index 0000000..bb0f89f --- /dev/null +++ b/frontend/src/features/fileTree/types.ts @@ -0,0 +1,64 @@ +import type { GraphNode } from "./schemas"; + +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 PartitionPathNode { + allFiles: DataFileNode[]; + children: PartitionPathNode[]; + 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" } + | { kind: "partition-path"; partitionPathNode: PartitionPathNode } + | { kind: "partition"; partition: PartitionGroup }; diff --git a/frontend/src/features/fileTree/useFileTreeState.ts b/frontend/src/features/fileTree/useFileTreeState.ts new file mode 100644 index 0000000..e47bdd0 --- /dev/null +++ b/frontend/src/features/fileTree/useFileTreeState.ts @@ -0,0 +1,151 @@ +import { useState } from "react"; +import type { + DataFileNode, + FileTreeViewMode, + InspectedFileTreeItem, + PartitionGroup, + PartitionPathNode, + SnapshotFileScope, +} from "./types"; + +interface FileTreeState { + checkedFileIds: Set; + closeInspector: () => void; + collapseAll: () => void; + collapseMany: (itemIds: string[]) => void; + expandItems: (itemIds: string[]) => void; + expandedItemIds: Set; + inspectFile: (file: DataFileNode) => void; + inspectedItem: InspectedFileTreeItem | null; + inspectPartitionPathNode: (node: PartitionPathNode) => 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 useFileTreeState = (): FileTreeState => { + 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, + inspectPartitionPathNode: (partitionPathNode) => { + setInspectedItem({ kind: "partition-path", partitionPathNode }); + }, + 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..87f33a5 --- /dev/null +++ b/frontend/src/pages/FileTreePage.tsx @@ -0,0 +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 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 ; +}; + +export default FileTreePage; diff --git a/frontend/src/pages/GraphPage.jsx b/frontend/src/pages/GraphPage.jsx index b6e47a7..c9ae0ac 100644 --- a/frontend/src/pages/GraphPage.jsx +++ b/frontend/src/pages/GraphPage.jsx @@ -2,12 +2,12 @@ import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { useOutletContext, useSearchParams } from "react-router-dom"; import ForceGraph2D from "react-force-graph-2d"; import { - isEmptyValue, PanelDetailRow, PanelHeader, PANEL_STATUS_BADGE_CLASS, } from "../components/PanelContent"; import PanelIssueNotice from "../components/PanelIssueNotice"; +import { isEmptyValue } from "../shared/lib/isEmptyValue"; import { UI_DIALOG_SECTION_TITLE_CLASS, UI_POPUP_HINT_CLASS, 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; +};