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 (
+
+ );
+};
+
+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 && (
+
+ )}
+
+
+
+
+
+ )}
+
+ );
+};
+
+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)}
- >
-
-
- {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)}
- >
-
-
- {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;
+};