@aiquants/directory-tree 3.0.5 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,978 @@
1
+ /**
2
+ * @module DirectoryTree
3
+ * @description A component to display a directory tree structure.
4
+ * ディレクトリツリー構造を表示するためのコンポーネント。
5
+ */
6
+ import { ScrollBar, VirtualScroll, type VirtualScrollHandle, type VirtualScrollRange } from "@aiquants/virtualscroll"
7
+ import type { CSSProperties, MouseEvent } from "react"
8
+ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
9
+ import { twMerge } from "tailwind-merge"
10
+ import { DirectoryTreeGridFooterRow, DirectoryTreeGridHeaderRow, DirectoryTreeGridRow } from "./DirectoryTreeGrid.tsx"
11
+ import { DEFAULT_ITEM_HEIGHT, directoryTreeClasses, EXPAND_GLYPH_SIZE, getFileExtension, INDENT_SIZE, resolveEntryIcon, resolveExpandIcon } from "./entryRendering.tsx"
12
+ import { buildNumericTemplate, clampHScroll, computeGridMetrics, DEFAULT_GRID_SCROLLBAR_WIDTH, flattenAllEntries } from "./gridUtils.ts"
13
+ import { TreeLine } from "./TreeLine.tsx"
14
+ import type { DirectoryEntry, DirectoryEntryItemProps, DirectoryTreeProps, DirectoryTreeVirtualScrollOptions, TreeLineGeometry, TreeLineGlyph, TreeLineItemInfo, TreeLineSegment } from "./types.ts"
15
+ import { useEntryInteraction } from "./useEntryInteraction.ts"
16
+
17
+ /**
18
+ * カスタムスクロールバー非表示スタイル
19
+ * Custom styles to hide scrollbar
20
+ */
21
+ const scrollbarHideStyle: CSSProperties = {
22
+ msOverflowStyle: "none" /* IE and Edge */,
23
+ scrollbarWidth: "none" /* Firefox */,
24
+ }
25
+
26
+ /**
27
+ * Webkit スクロールバー非表示のスタイルを動的に適用
28
+ * Apply webkit scrollbar hiding styles dynamically
29
+ */
30
+ const applyWebkitScrollbarHide = () => {
31
+ // 既存のスタイルタグがあるかチェック
32
+ let styleElement = document.getElementById("directory-tree-webkit-scrollbar-hide")
33
+
34
+ if (!styleElement) {
35
+ styleElement = document.createElement("style")
36
+ styleElement.id = "directory-tree-webkit-scrollbar-hide"
37
+ styleElement.textContent = ".directory-tree-container::-webkit-scrollbar { display: none; }"
38
+ document.head.appendChild(styleElement)
39
+ }
40
+ }
41
+
42
+ type FlattenedEntry = { entry: DirectoryEntry; indentLevel: number; parentIsLast: boolean[] }
43
+
44
+ /** A flattened entry with its resolved row height. / 解決済みの行高を持つフラット化エントリ。 */
45
+ type VirtualItem = FlattenedEntry & { rowHeight: number }
46
+
47
+ type TreeTraversalResult = {
48
+ flatItems: FlattenedEntry[]
49
+ lineItems: TreeLineItemInfo[]
50
+ maxIndent: number
51
+ }
52
+
53
+ /**
54
+ * @function collectTreeData
55
+ * @description Traverses directory entries to build flattened items and tree line metadata.
56
+ * ディレクトリエントリを走査してフラットなアイテムとツリーライン用メタデータを構築する。
57
+ */
58
+ const collectTreeData = (entries: DirectoryEntry[], resolver: (absolutePath: string, relativePath?: string) => boolean, includeTreeLines: boolean, removeRootIndent: boolean): TreeTraversalResult => {
59
+ const flatItems: FlattenedEntry[] = []
60
+ const lineItems: TreeLineItemInfo[] = []
61
+ let maxIndent = 0
62
+
63
+ const walk = (currentEntries: DirectoryEntry[], indentLevel: number, ancestorChain: boolean[]): void => {
64
+ for (let index = 0; index < currentEntries.length; index++) {
65
+ const entry = currentEntries[index]
66
+ const isLastChild = index === currentEntries.length - 1
67
+ const nextAncestorChain = [...ancestorChain, isLastChild]
68
+ const isDirectory = entry.children !== undefined
69
+ const isExpanded = isDirectory && resolver(entry.absolutePath, entry.relativePath)
70
+
71
+ let effectiveIndentLevel = indentLevel
72
+ let effectiveNextAncestorChain = nextAncestorChain
73
+ let effectiveAncestorChain = ancestorChain
74
+
75
+ if (removeRootIndent) {
76
+ effectiveIndentLevel = indentLevel - 1
77
+ effectiveNextAncestorChain = nextAncestorChain.slice(1)
78
+ effectiveAncestorChain = ancestorChain.slice(1)
79
+ }
80
+
81
+ flatItems.push({ entry, indentLevel: effectiveIndentLevel, parentIsLast: effectiveNextAncestorChain })
82
+
83
+ if (includeTreeLines) {
84
+ if (effectiveIndentLevel > maxIndent) {
85
+ maxIndent = effectiveIndentLevel
86
+ }
87
+ lineItems.push({
88
+ id: entry.absolutePath,
89
+ name: entry.name,
90
+ absolutePath: entry.absolutePath,
91
+ indentLevel: effectiveIndentLevel,
92
+ isLastChild,
93
+ isDirectory,
94
+ isExpanded,
95
+ ancestorIsLast: [...effectiveAncestorChain],
96
+ hideLines: removeRootIndent && indentLevel === 0,
97
+ })
98
+ }
99
+
100
+ if (isDirectory && isExpanded && entry.children) {
101
+ walk(entry.children, indentLevel + 1, nextAncestorChain)
102
+ }
103
+ }
104
+ }
105
+
106
+ walk(entries, 0, [])
107
+ return { flatItems, lineItems, maxIndent }
108
+ }
109
+
110
+ type RangeMetrics = {
111
+ renderStart: number
112
+ renderEnd: number
113
+ lookaheadEnd: number
114
+ }
115
+
116
+ /**
117
+ * @function findItemIndexAtOffset
118
+ * @description Binary-searches a cumulative offset table for the item covering a pixel position.
119
+ * 累積オフセット表を二分探索し、指定ピクセル位置を含むアイテムのインデックスを返す。
120
+ * @param offsets - 累積オフセット表 (長さ = アイテム数 + 1、offsets[i] が item i の上端)。
121
+ * @param position - ピクセル位置。
122
+ * @returns offsets[i] <= position を満たす最大の i (0..itemCount-1 にクランプ)。
123
+ */
124
+ const findItemIndexAtOffset = (offsets: number[], position: number): number => {
125
+ const itemCount = offsets.length - 1
126
+ if (itemCount <= 0) return 0
127
+ let low = 0
128
+ let high = itemCount - 1
129
+ let result = 0
130
+ while (low <= high) {
131
+ const mid = (low + high) >>> 1
132
+ if (offsets[mid] <= position) {
133
+ result = mid
134
+ low = mid + 1
135
+ } else {
136
+ high = mid - 1
137
+ }
138
+ }
139
+ return result
140
+ }
141
+
142
+ /**
143
+ * @function computeRangeMetrics
144
+ * @description Calculates render range metrics for tree line drawing.
145
+ * ツリーライン描画のためのレンダリング範囲指標を算出する。
146
+ * itemOffsets を用いるため可変高さの行でも正しく機能する。
147
+ */
148
+ const computeRangeMetrics = (itemCount: number, viewportHeight: number, overscanCount: number, renderingRange: { renderStart: number; renderEnd: number } | null, scrollPosition: number, itemOffsets: number[]): RangeMetrics => {
149
+ const fallbackStart = renderingRange?.renderStart ?? Math.max(0, findItemIndexAtOffset(itemOffsets, scrollPosition))
150
+ const fallbackEndByHeight = viewportHeight > 0 ? findItemIndexAtOffset(itemOffsets, scrollPosition + viewportHeight) + overscanCount : fallbackStart + overscanCount
151
+ const fallbackEnd = itemCount > 0 ? Math.min(itemCount - 1, Math.max(fallbackStart, fallbackEndByHeight)) : fallbackStart
152
+ const renderEnd = renderingRange?.renderEnd ?? fallbackEnd
153
+ const lookaheadEnd = itemCount > 0 ? Math.min(itemCount - 1, renderEnd + overscanCount) : renderEnd
154
+ return { renderStart: fallbackStart, renderEnd, lookaheadEnd }
155
+ }
156
+
157
+ /**
158
+ * Builds geometry data for the canvas based tree line renderer, including segments and glyphs.
159
+ *
160
+ * Canvas ベースのツリーラインレンダラ用に線分とグリフのジオメトリデータを構築する。
161
+ *
162
+ * @param items - ツリーラインアイテムの配列。
163
+ * @param itemHeights - 各アイテムの高さの配列 (items と同じ順序・長さ)。
164
+ * @param itemOffsets - 各アイテムの累積上端オフセット表 (長さ = items.length + 1)。
165
+ * @param segmentWidth - インデントセグメントの幅。
166
+ * @param includeGlyphs - グリフ情報を含めるかどうか。
167
+ * @param glyphSizeOverride - グリフサイズの上書き値。
168
+ * @returns セグメントおよびグリフの配列をまとめたジオメトリデータ。
169
+ */
170
+ export const buildTreeLineGeometry = (items: TreeLineItemInfo[], itemHeights: number[], itemOffsets: number[], segmentWidth: number, includeGlyphs: boolean, glyphSizeOverride?: number): TreeLineGeometry => {
171
+ if (items.length === 0) {
172
+ return { segmentsByItem: [], glyphsByItem: [] }
173
+ }
174
+
175
+ const segmentsByItem: TreeLineSegment[][] = Array.from({ length: items.length }, () => [])
176
+ const glyphsByItem: TreeLineGlyph[][] = Array.from({ length: items.length }, () => [])
177
+ // グリフサイズは行ごとの高さから解決する(override があればそれを優先)。可変高さ行に対応するため行内で算出する。
178
+ const resolveGlyphSize = (rowHeight: number): number => {
179
+ if (!includeGlyphs) {
180
+ return 0
181
+ }
182
+ if (typeof glyphSizeOverride === "number" && Number.isFinite(glyphSizeOverride) && glyphSizeOverride > 0) {
183
+ return glyphSizeOverride
184
+ }
185
+ return Math.max(8, Math.min(segmentWidth * 0.9, rowHeight * 0.7))
186
+ }
187
+
188
+ const xOffset = 3
189
+
190
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
191
+ const item = items[itemIndex]
192
+ if (item.hideLines) {
193
+ continue
194
+ }
195
+ const group: TreeLineSegment[] = []
196
+ const rowHeight = itemHeights[itemIndex] ?? 0
197
+ const yBase = itemOffsets[itemIndex] ?? 0
198
+ const yCenter = yBase + rowHeight / 2
199
+ const computedGlyphSize = resolveGlyphSize(rowHeight)
200
+ const iconGap = computedGlyphSize > 0 ? computedGlyphSize / 2 + 2 : segmentWidth / 1.5
201
+
202
+ for (let depth = 0; depth < item.indentLevel; depth++) {
203
+ const ancestorIsLast = item.ancestorIsLast[depth] ?? false
204
+ if (ancestorIsLast) {
205
+ continue
206
+ }
207
+
208
+ let drawVerticalLine = false
209
+ for (let forwardIndex = itemIndex; forwardIndex < items.length; forwardIndex++) {
210
+ const candidate = items[forwardIndex]
211
+ if (candidate.indentLevel > depth && candidate.ancestorIsLast.length > depth && !candidate.ancestorIsLast[depth]) {
212
+ drawVerticalLine = true
213
+ break
214
+ }
215
+ /* v8 ignore next 3 -- 到達不能: forwardIndex===itemIndex(自分自身)が上の条件を常に満たすため、この break には到達しない。 */
216
+ if (forwardIndex > itemIndex && candidate.indentLevel === depth) {
217
+ break
218
+ }
219
+ }
220
+
221
+ if (drawVerticalLine) {
222
+ const x = depth * segmentWidth + segmentWidth / 2 + xOffset
223
+ group.push({
224
+ key: `${item.id}-ancestor-${depth}`,
225
+ x1: x,
226
+ y1: yBase,
227
+ x2: x,
228
+ y2: yBase + rowHeight,
229
+ itemIndex,
230
+ })
231
+ }
232
+ }
233
+
234
+ const connectorXBase = item.indentLevel * segmentWidth
235
+ const connectorX = connectorXBase + segmentWidth / 2 + xOffset
236
+
237
+ if (item.isDirectory) {
238
+ group.push({
239
+ key: `${item.id}-connector-top`,
240
+ x1: connectorX,
241
+ y1: yBase,
242
+ x2: connectorX,
243
+ y2: yCenter - iconGap,
244
+ itemIndex,
245
+ })
246
+ } else {
247
+ group.push({
248
+ key: `${item.id}-connector-top`,
249
+ x1: connectorX,
250
+ y1: yBase,
251
+ x2: connectorX,
252
+ y2: yCenter,
253
+ itemIndex,
254
+ })
255
+ }
256
+
257
+ if (!item.isDirectory && item.isLastChild) {
258
+ group.push({
259
+ key: `${item.id}-connector-horizontal`,
260
+ x1: connectorX,
261
+ y1: yCenter,
262
+ x2: connectorXBase + segmentWidth + xOffset,
263
+ y2: yCenter,
264
+ itemIndex,
265
+ })
266
+ }
267
+
268
+ if (!item.isLastChild) {
269
+ if (item.isDirectory) {
270
+ group.push({
271
+ key: `${item.id}-connector-bottom`,
272
+ x1: connectorX,
273
+ y1: yCenter + iconGap,
274
+ x2: connectorX,
275
+ y2: yBase + rowHeight,
276
+ itemIndex,
277
+ })
278
+ } else {
279
+ group.push({
280
+ key: `${item.id}-connector-bottom`,
281
+ x1: connectorX,
282
+ y1: yCenter,
283
+ x2: connectorX,
284
+ y2: yBase + rowHeight,
285
+ itemIndex,
286
+ })
287
+ }
288
+ }
289
+
290
+ segmentsByItem[itemIndex] = group
291
+
292
+ if (includeGlyphs && item.isDirectory) {
293
+ const size = computedGlyphSize
294
+ const centerX = connectorX
295
+ const centerY = yCenter
296
+ glyphsByItem[itemIndex] = [
297
+ {
298
+ key: `${item.id}-glyph`,
299
+ itemIndex,
300
+ centerX,
301
+ centerY,
302
+ size,
303
+ isExpanded: item.isExpanded,
304
+ },
305
+ ]
306
+ }
307
+ }
308
+
309
+ return { segmentsByItem, glyphsByItem }
310
+ }
311
+
312
+ /**
313
+ * @component DirectoryEntryItem
314
+ * @description Renders a single entry (file or directory) in the directory tree. Memoized for performance.
315
+ * ディレクトリツリー内の単一エントリ (ファイルまたはディレクトリ) をレンダリングする。パフォーマンスのためにメモ化されている。
316
+ */
317
+ const DirectoryEntryItem = memo<DirectoryEntryItemProps>(
318
+ ({
319
+ entry,
320
+ indentLevel,
321
+ rowHeight,
322
+ isDirOpen,
323
+ parentIsLast,
324
+ renderChildren = true,
325
+ expansion: { toggleDirectory, onToggleDirectoryRecursive },
326
+ selection: { onEntryClick, selectedPath, mode: selectionMode = "none", selectedItems, onSelectionChange },
327
+ visual: {
328
+ iconOverrides,
329
+ showExpandIcons = true,
330
+ showDirectoryIcons = true,
331
+ showFileIcons = true,
332
+ useCanvasExpandIcons = false,
333
+ expandIconSize,
334
+ highlightStyles,
335
+ entryClassName,
336
+ entryStyle,
337
+ nameClassName,
338
+ nameStyle,
339
+ directoryNameClassName,
340
+ directoryNameStyle,
341
+ fileNameClassName,
342
+ fileNameStyle,
343
+ },
344
+ }) => {
345
+ const [childrenToRender, setChildrenToRender] = useState<DirectoryEntry[] | null>(null)
346
+ const [isHovered, setIsHovered] = useState(false)
347
+
348
+ const isDirectory = entry.children !== undefined
349
+ const isExpanded = isDirectory && isDirOpen(entry.absolutePath)
350
+ const isSelected = entry.absolutePath === selectedPath
351
+ const isItemSelected = !isDirectory && selectedItems?.has(entry.absolutePath)
352
+ const extension = isDirectory ? undefined : getFileExtension(entry.name)
353
+
354
+ /**
355
+ * @effect Lazy Children Loading
356
+ * @description Loads children only when expanded to optimize rendering performance.
357
+ * レンダリングパフォーマンス最適化のため、展開時のみ子要素を読み込む。
358
+ */
359
+ useEffect(() => {
360
+ setChildrenToRender(isExpanded && entry.children ? entry.children : null)
361
+ }, [isExpanded, entry.children])
362
+
363
+ const handleInteraction = useEntryInteraction({ entry, isDirectory, isExpanded, isItemSelected: Boolean(isItemSelected), selectionMode, onSelectionChange, onEntryClick, toggleDirectory, onToggleDirectoryRecursive })
364
+
365
+ const hoverClass = isHovered ? (highlightStyles?.hoverClassName !== undefined ? highlightStyles.hoverClassName : directoryTreeClasses.entryHover) : ""
366
+
367
+ const selectedClass = (() => {
368
+ if (isDirectory) {
369
+ if (isSelected) {
370
+ return highlightStyles?.directorySelectedClassName !== undefined ? highlightStyles.directorySelectedClassName : directoryTreeClasses.entrySelected
371
+ }
372
+ } else {
373
+ if (isSelected || isItemSelected) {
374
+ if (highlightStyles?.itemSelectedClassName !== undefined) {
375
+ return highlightStyles.itemSelectedClassName
376
+ }
377
+ return twMerge(isSelected && directoryTreeClasses.entrySelected, isItemSelected && directoryTreeClasses.entryItemSelected)
378
+ }
379
+ }
380
+ return ""
381
+ })()
382
+
383
+ const entryClasses = twMerge(directoryTreeClasses.entry, hoverClass, selectedClass, entryClassName, entry.className)
384
+ const expandIconClasses = twMerge(directoryTreeClasses.expandIcon, (isSelected || isItemSelected) && directoryTreeClasses.expandIconSelected)
385
+ const typeIconClasses = twMerge(directoryTreeClasses.typeIcon, (isSelected || isItemSelected) && directoryTreeClasses.typeIconSelected)
386
+ const nameClasses = twMerge(directoryTreeClasses.name, nameClassName, isDirectory && directoryTreeClasses.nameDirectory, isDirectory && directoryNameClassName, !isDirectory && fileNameClassName, (isSelected || isItemSelected) && directoryTreeClasses.nameSelected)
387
+
388
+ const expandIconContent = resolveExpandIcon({ isDirectory, isExpanded, showExpandIcons, useCanvasExpandIcons, expandIconSize })
389
+ const resolvedIcon = (isDirectory ? showDirectoryIcons : showFileIcons) ? resolveEntryIcon({ entry, isDirectory, isExpanded, isSelected, isItemSelected: Boolean(isItemSelected), extension }, iconOverrides) : null
390
+
391
+ const customHighlightStyle = {
392
+ ...(isHovered ? highlightStyles?.hoverStyle : {}),
393
+ ...(isDirectory && isSelected ? highlightStyles?.directorySelectedStyle : {}),
394
+ ...(!isDirectory && (isSelected || isItemSelected) ? highlightStyles?.itemSelectedStyle : {}),
395
+ }
396
+
397
+ return (
398
+ <Fragment key={entry.absolutePath}>
399
+ <div
400
+ className={entryClasses}
401
+ style={{
402
+ paddingLeft: `${Math.max(0, indentLevel * INDENT_SIZE)}px`,
403
+ transform: indentLevel < 0 ? `translateX(${indentLevel * INDENT_SIZE}px)` : undefined,
404
+ width: indentLevel < 0 ? `calc(100% + ${Math.abs(indentLevel * INDENT_SIZE)}px)` : undefined,
405
+ height: `${rowHeight}px`,
406
+ ...customHighlightStyle,
407
+ ...entryStyle,
408
+ ...entry.style,
409
+ }}
410
+ data-entry-type={isDirectory ? "directory" : "file"}
411
+ data-entry-expanded={isDirectory ? String(isExpanded) : undefined}
412
+ data-entry-selected={isSelected ? "true" : undefined}
413
+ data-entry-item-selected={isItemSelected ? "true" : undefined}
414
+ onClick={handleInteraction}
415
+ onMouseEnter={() => setIsHovered(true)}
416
+ onMouseLeave={() => setIsHovered(false)}
417
+ onKeyDown={(e) => e.key === "Enter" && handleInteraction(e as unknown as MouseEvent<HTMLDivElement>)}
418
+ tabIndex={0}
419
+ role="treeitem"
420
+ aria-expanded={isDirectory ? isExpanded : undefined}
421
+ aria-label={`${entry.name} (${isDirectory ? "directory" : "file"})`}>
422
+ {(showExpandIcons || useCanvasExpandIcons) && <span className={expandIconClasses}>{expandIconContent}</span>}
423
+ {(isDirectory ? showDirectoryIcons : showFileIcons) && <span className={typeIconClasses}>{resolvedIcon}</span>}
424
+ <span
425
+ className={nameClasses}
426
+ style={{
427
+ ...nameStyle,
428
+ ...(isDirectory ? directoryNameStyle : fileNameStyle),
429
+ }}
430
+ data-entry-type={isDirectory ? "directory" : "file"}
431
+ data-entry-role="name">
432
+ {entry.label ?? entry.name}
433
+ </span>
434
+ </div>
435
+ {/* v8 ignore start -- 到達不能: DirectoryTree は常に renderChildren=false で描画し(仮想化)、本コンポーネントは非公開のため、この非仮想化の再帰描画経路は使われない。 */}
436
+ {isExpanded && childrenToRender && renderChildren && (
437
+ <fieldset>
438
+ {childrenToRender.map((child, index) => (
439
+ <DirectoryEntryItem
440
+ key={child.absolutePath}
441
+ entry={child}
442
+ indentLevel={indentLevel + 1}
443
+ // 非仮想化の再帰描画では親行の高さを一様に流用する (DirectoryTree は renderChildren=false のため通常この経路は通らない)。
444
+ rowHeight={rowHeight}
445
+ isDirOpen={isDirOpen}
446
+ parentIsLast={[...parentIsLast, index === childrenToRender.length - 1]}
447
+ expansion={{ toggleDirectory, onToggleDirectoryRecursive }}
448
+ selection={{ onEntryClick, selectedPath, mode: selectionMode, selectedItems, onSelectionChange }}
449
+ visual={{
450
+ iconOverrides,
451
+ showExpandIcons,
452
+ showDirectoryIcons,
453
+ showFileIcons,
454
+ useCanvasExpandIcons,
455
+ expandIconSize,
456
+ highlightStyles,
457
+ entryClassName,
458
+ entryStyle,
459
+ nameClassName,
460
+ nameStyle,
461
+ directoryNameClassName,
462
+ directoryNameStyle,
463
+ fileNameClassName,
464
+ fileNameStyle,
465
+ }}
466
+ />
467
+ ))}
468
+ </fieldset>
469
+ )}
470
+ {/* v8 ignore stop */}
471
+ </Fragment>
472
+ )
473
+ },
474
+ (prev, next) => {
475
+ if (prev.entry !== next.entry) return false
476
+ const prevIsDir = prev.entry.children !== undefined
477
+ const nextIsDir = next.entry.children !== undefined
478
+ const prevExpanded = prevIsDir && prev.isDirOpen(prev.entry.absolutePath)
479
+ const nextExpanded = nextIsDir && next.isDirOpen(next.entry.absolutePath)
480
+
481
+ return (
482
+ prev.indentLevel === next.indentLevel &&
483
+ prev.rowHeight === next.rowHeight &&
484
+ prevExpanded === nextExpanded &&
485
+ prev.selection.selectedPath === next.selection.selectedPath &&
486
+ prev.selection.mode === next.selection.mode &&
487
+ prev.selection.selectedItems === next.selection.selectedItems &&
488
+ prev.visual.iconOverrides === next.visual.iconOverrides &&
489
+ prev.visual.showExpandIcons === next.visual.showExpandIcons &&
490
+ prev.visual.showDirectoryIcons === next.visual.showDirectoryIcons &&
491
+ prev.visual.showFileIcons === next.visual.showFileIcons &&
492
+ prev.visual.useCanvasExpandIcons === next.visual.useCanvasExpandIcons &&
493
+ prev.visual.expandIconSize === next.visual.expandIconSize &&
494
+ prev.visual.highlightStyles === next.visual.highlightStyles &&
495
+ prev.visual.entryClassName === next.visual.entryClassName &&
496
+ prev.visual.entryStyle === next.visual.entryStyle &&
497
+ prev.visual.nameClassName === next.visual.nameClassName &&
498
+ prev.visual.nameStyle === next.visual.nameStyle &&
499
+ prev.visual.directoryNameClassName === next.visual.directoryNameClassName &&
500
+ prev.visual.directoryNameStyle === next.visual.directoryNameStyle &&
501
+ prev.visual.fileNameClassName === next.visual.fileNameClassName &&
502
+ prev.visual.fileNameStyle === next.visual.fileNameStyle &&
503
+ prev.parentIsLast.length === next.parentIsLast.length &&
504
+ prev.parentIsLast.every((val, i) => val === next.parentIsLast[i])
505
+ )
506
+ },
507
+ )
508
+ DirectoryEntryItem.displayName = "DirectoryEntryItem"
509
+
510
+ // TreeLine コンポーネントが使用する props の型は TreeLine.tsx からインポートされる
511
+
512
+ /**
513
+ * @function getAllDescendantPaths
514
+ * @description Recursively gets all descendant directory paths for a given entry.
515
+ * 指定されたエントリのすべての子孫ディレクトリパスを再帰的に取得する。
516
+ * @param {DirectoryEntry} entry - The directory entry to start from. 開始点となるディレクトリのエントリ。
517
+ * @returns {string[]} An array of absolute paths of all descendant directories. すべての子孫ディレクトリの絶対パスの配列。
518
+ */
519
+ const getAllDescendantPaths = (entry: DirectoryEntry): string[] => {
520
+ const paths: string[] = []
521
+ if (entry.children) {
522
+ for (const child of entry.children) {
523
+ if (child.type === "directory") {
524
+ paths.push(child.absolutePath)
525
+ paths.push(...getAllDescendantPaths(child))
526
+ }
527
+ }
528
+ }
529
+ return paths
530
+ }
531
+
532
+ /**
533
+ * @component DirectoryTree
534
+ * @description A component to display a directory tree structure. It allows navigation and selection of files.
535
+ * ディレクトリツリー構造を表示するコンポーネント。ファイルのナビゲーションと選択が可能。
536
+ */
537
+ export const DirectoryTree = ({
538
+ entries,
539
+ expansion: { toggle, isExpanded, expandMultiple, collapseMultiple, isPending, alwaysExpanded = false, doubleClickAction = "recursive" },
540
+ selection: { onEntryClick, selectedPath, mode: selectionMode = "none", selectedItems, onSelectionChange },
541
+ visual: {
542
+ className,
543
+ style,
544
+ lineColor = "#A0AEC0",
545
+ showTreeLines = true,
546
+ showExpandIcons = true,
547
+ showDirectoryIcons = true,
548
+ showFileIcons = true,
549
+ iconOverrides,
550
+ expandIconSize,
551
+ itemHeight = DEFAULT_ITEM_HEIGHT,
552
+ removeRootIndent = false,
553
+ highlightStyles,
554
+ entryClassName,
555
+ entryStyle,
556
+ nameClassName,
557
+ nameStyle,
558
+ directoryNameClassName,
559
+ directoryNameStyle,
560
+ fileNameClassName,
561
+ fileNameStyle,
562
+ } = {},
563
+ virtualScroll: virtualScrollOptions,
564
+ grid,
565
+ }: DirectoryTreeProps) => {
566
+ const isGrid = grid !== undefined
567
+ const [isClient, setIsClient] = useState(false)
568
+ const containerRef = useRef<HTMLDivElement | null>(null)
569
+ const bodyRef = useRef<HTMLDivElement | null>(null)
570
+ const virtualScrollRef = useRef<VirtualScrollHandle>(null)
571
+ const [measuredViewportHeight, setMeasuredViewportHeight] = useState(0)
572
+ const [measuredWidth, setMeasuredWidth] = useState(0)
573
+ const [hscroll, setHscroll] = useState(0)
574
+ const [renderingRange, setRenderingRange] = useState<{ renderStart: number; renderEnd: number } | null>(null)
575
+
576
+ const {
577
+ overscanCount: userOverscanCount = 15,
578
+ className: virtualScrollClassName,
579
+ background: virtualScrollBackground,
580
+ onScroll: virtualScrollOnScroll,
581
+ onRangeChange: virtualScrollOnRangeChange,
582
+ initialScrollIndex,
583
+ initialScrollOffset,
584
+ callbackThrottleMs = 5,
585
+ contentInsets: virtualScrollContentInsets,
586
+ onItemFocus,
587
+ viewportHeightOverride,
588
+ scrollBarOptions,
589
+ behaviorOptions,
590
+ }: DirectoryTreeVirtualScrollOptions = virtualScrollOptions ?? {}
591
+
592
+ const [scrollPosition, setScrollPosition] = useState(typeof initialScrollOffset === "number" ? initialScrollOffset : 0)
593
+
594
+ const handleVirtualScroll = useCallback(
595
+ (position: number, totalHeight: number) => {
596
+ setScrollPosition(position)
597
+ virtualScrollOnScroll?.(position, totalHeight)
598
+ },
599
+ [virtualScrollOnScroll],
600
+ )
601
+
602
+ const handleRangeChange = useCallback(
603
+ (range: VirtualScrollRange) => {
604
+ const { renderingStartIndex: start, renderingEndIndex: end } = range
605
+ setRenderingRange((prev) => (prev && prev.renderStart === start && prev.renderEnd === end ? prev : { renderStart: start, renderEnd: end }))
606
+ virtualScrollOnRangeChange?.(range)
607
+ },
608
+ [virtualScrollOnRangeChange],
609
+ )
610
+
611
+ /**
612
+ * @effect Initial Scroll Sync
613
+ * @description Synchronizes the internal scroll position state with the provided initial offset.
614
+ * 提供された初期オフセットと内部スクロール位置状態を同期する。
615
+ */
616
+ useEffect(() => {
617
+ if (typeof initialScrollOffset === "number") setScrollPosition(initialScrollOffset)
618
+ }, [initialScrollOffset])
619
+
620
+ /**
621
+ * @effect Client Mount & Scrollbar Hide
622
+ * @description Sets client-side flag and injects styles to hide Webkit scrollbars.
623
+ * クライアントサイドフラグを設定し、Webkit スクロールバーを非表示にするスタイルを注入する。
624
+ */
625
+ useEffect(() => {
626
+ setIsClient(true)
627
+ applyWebkitScrollbarHide()
628
+ }, [])
629
+
630
+ /**
631
+ * @effect Viewport Height Observer
632
+ * @description Monitors the container's height using ResizeObserver to update the virtual scroll viewport.
633
+ * ResizeObserver を使用してコンテナの高さを監視し、仮想スクロールのビューポートを更新する。
634
+ */
635
+ useEffect(() => {
636
+ const container = containerRef.current
637
+ // In grid mode header/footer are siblings of the body, so the viewport height must come
638
+ // from the body region only — the whole container would over-count by their heights.
639
+ // grid モードではヘッダ/フッタが本体の兄弟なので、ビューポート高は本体領域のみから取る。
640
+ const heightTarget = isGrid ? bodyRef.current : container
641
+
642
+ const measure = () => {
643
+ if (container) {
644
+ const nextWidth = container.clientWidth
645
+ setMeasuredWidth((prev) => (Math.abs(prev - nextWidth) > 1 ? nextWidth : prev))
646
+ }
647
+ if (typeof viewportHeightOverride === "number") {
648
+ setMeasuredViewportHeight((prev) => (Math.abs(prev - viewportHeightOverride) > 1 ? viewportHeightOverride : prev))
649
+ } else if (heightTarget) {
650
+ const nextHeight = heightTarget.clientHeight
651
+ setMeasuredViewportHeight((prev) => (Math.abs(prev - nextHeight) > 1 ? nextHeight : prev))
652
+ }
653
+ }
654
+
655
+ if (!(container || heightTarget)) return setMeasuredViewportHeight(0)
656
+ measure()
657
+
658
+ const resizeObserver = new ResizeObserver(measure)
659
+ if (container) resizeObserver.observe(container)
660
+ if (heightTarget && heightTarget !== container) resizeObserver.observe(heightTarget)
661
+ return () => resizeObserver.disconnect()
662
+ }, [viewportHeightOverride, isGrid])
663
+
664
+ const viewportHeight = typeof viewportHeightOverride === "number" ? viewportHeightOverride : measuredViewportHeight
665
+
666
+ const safeIsExpanded = useCallback((path: string) => alwaysExpanded || (isClient ? isExpanded(path) : false), [isClient, isExpanded, alwaysExpanded])
667
+
668
+ const containerClasses = twMerge(directoryTreeClasses.container, isPending && directoryTreeClasses.containerPending, className)
669
+ const containerStyle = useMemo(
670
+ () => ({
671
+ ...scrollbarHideStyle,
672
+ ...(style ?? {}),
673
+ ...(typeof viewportHeightOverride === "number" ? { height: viewportHeightOverride, minHeight: viewportHeightOverride } : {}),
674
+ }),
675
+ [style, viewportHeightOverride],
676
+ )
677
+
678
+ /**
679
+ * @callback handleDirectoryDoubleClick
680
+ * @description Handles double-click actions on directories (recursive expand/collapse or toggle).
681
+ * ディレクトリのダブルクリックアクション (再帰的展開/折りたたみ、または切り替え) を処理する。
682
+ */
683
+ const handleDirectoryDoubleClick = useCallback(
684
+ (entry: DirectoryEntry, overrideAction?: "toggle" | "recursive") => {
685
+ const allPaths = [entry.absolutePath, ...getAllDescendantPaths(entry)]
686
+ const action = overrideAction || doubleClickAction
687
+ const allExpanded = allPaths.every((path) => safeIsExpanded(path))
688
+
689
+ if (allExpanded) return collapseMultiple(allPaths)
690
+
691
+ const allCollapsed = allPaths.every((path) => !safeIsExpanded(path))
692
+ if (allCollapsed || action === "recursive") return expandMultiple(allPaths)
693
+ if (action === "toggle") return collapseMultiple(allPaths)
694
+
695
+ /* v8 ignore next -- 到達不能: doubleClickAction は "toggle" | "recursive" のみ(型で保証)。防御的フォールバック。 */
696
+ console.warn(`[DirectoryTree] Unknown double click action: ${action}. No action taken.`)
697
+ },
698
+ [safeIsExpanded, expandMultiple, collapseMultiple, doubleClickAction],
699
+ )
700
+
701
+ // `removeRootIndent` relies on a negative-indent translateX shift that conflicts with the
702
+ // frozen name column (which must start at x=0), so it is intentionally ignored in grid mode.
703
+ // grid モードでは名前列を x=0 に凍結するため、負インデントに依存する removeRootIndent は無効化する。
704
+ const effectiveRemoveRootIndent = isGrid ? false : removeRootIndent
705
+ const { flatItems, lineItems, maxIndent } = useMemo(() => collectTreeData(entries, safeIsExpanded, showTreeLines, effectiveRemoveRootIndent), [entries, safeIsExpanded, showTreeLines, effectiveRemoveRootIndent])
706
+
707
+ // 各行の高さを解決する。number ならそのまま、関数なら (entry, flatIndex) で算出する。
708
+ // 不正値 (NaN / Infinity / 0 / 負値) は既定高さにフォールバックし、オフセット表の単調性と CSS 高さの妥当性を保証する。
709
+ const virtualItems = useMemo<VirtualItem[]>(
710
+ () =>
711
+ flatItems.map((item, index) => {
712
+ const resolved = typeof itemHeight === "function" ? itemHeight(item.entry, index) : itemHeight
713
+ return { ...item, rowHeight: Number.isFinite(resolved) && resolved > 0 ? resolved : DEFAULT_ITEM_HEIGHT }
714
+ }),
715
+ [flatItems, itemHeight],
716
+ )
717
+
718
+ // ツリーライン幾何・可視範囲計算に用いる高さ配列と累積オフセット表 (offsets[i] が item i の上端)。
719
+ const itemHeights = useMemo(() => virtualItems.map((item) => item.rowHeight), [virtualItems])
720
+ const itemOffsets = useMemo(() => {
721
+ const offsets = new Array<number>(itemHeights.length + 1)
722
+ offsets[0] = 0
723
+ for (let index = 0; index < itemHeights.length; index++) {
724
+ offsets[index + 1] = offsets[index] + itemHeights[index]
725
+ }
726
+ return offsets
727
+ }, [itemHeights])
728
+
729
+ // VirtualScroll に渡す高さ取得関数。identity は itemHeights が変わったときだけ更新される。
730
+ const getItemHeight = useCallback((index: number) => virtualItems[index]?.rowHeight ?? DEFAULT_ITEM_HEIGHT, [virtualItems])
731
+
732
+ const treeLineGeometry = useMemo(
733
+ () => (!showTreeLines || lineItems.length === 0 ? { segmentsByItem: [], glyphsByItem: [] } : buildTreeLineGeometry(lineItems, itemHeights, itemOffsets, INDENT_SIZE, showTreeLines, expandIconSize ?? EXPAND_GLYPH_SIZE)),
734
+ [lineItems, itemHeights, itemOffsets, showTreeLines, expandIconSize],
735
+ )
736
+
737
+ const treeLineWidth = useMemo(() => (!showTreeLines || entries.length === 0 ? 0 : (maxIndent + 2) * INDENT_SIZE), [entries.length, maxIndent, showTreeLines])
738
+ const { renderStart, renderEnd, lookaheadEnd } = useMemo(
739
+ () => computeRangeMetrics(virtualItems.length, viewportHeight, userOverscanCount, renderingRange, scrollPosition, itemOffsets),
740
+ [virtualItems.length, viewportHeight, userOverscanCount, renderingRange, scrollPosition, itemOffsets],
741
+ )
742
+
743
+ const treeLineBackground = useMemo(() => {
744
+ if (!showTreeLines || treeLineWidth <= 0 || viewportHeight <= 0 || treeLineGeometry.segmentsByItem.length === 0) return null
745
+ return (
746
+ <div className="pointer-events-none absolute inset-0 z-0" aria-hidden="true">
747
+ <div className="absolute top-0 left-0" style={{ width: treeLineWidth, height: viewportHeight }}>
748
+ <TreeLine
749
+ segmentsByItem={treeLineGeometry.segmentsByItem}
750
+ glyphsByItem={treeLineGeometry.glyphsByItem}
751
+ itemHeight={itemHeights[0] ?? DEFAULT_ITEM_HEIGHT}
752
+ width={treeLineWidth}
753
+ viewportHeight={viewportHeight}
754
+ scrollPosition={scrollPosition}
755
+ color={lineColor}
756
+ expandGlyphColor={lineColor}
757
+ expandGlyphSize={expandIconSize ?? EXPAND_GLYPH_SIZE}
758
+ renderStartIndex={renderStart}
759
+ renderEndIndex={renderEnd}
760
+ lookaheadEndIndex={lookaheadEnd}
761
+ />
762
+ </div>
763
+ </div>
764
+ )
765
+ }, [treeLineWidth, viewportHeight, renderStart, renderEnd, lookaheadEnd, scrollPosition, treeLineGeometry, lineColor, showTreeLines, expandIconSize, itemHeights])
766
+
767
+ const getItem = useCallback((index: number) => virtualItems[index], [virtualItems])
768
+
769
+ // ===== TreeGrid (columns) mode derived values =====
770
+ const gridColumns = grid?.columns
771
+ // Align the header/footer spacer with the body's vertical scrollbar: default to the actual
772
+ // VirtualScroll scrollbar width so columns stay aligned even when a consumer customizes it.
773
+ const gridScrollBarWidth = grid?.scrollBarWidth ?? scrollBarOptions?.width ?? DEFAULT_GRID_SCROLLBAR_WIDTH
774
+ const gridMetrics = useMemo(() => (gridColumns ? computeGridMetrics({ measuredWidth, columns: gridColumns, scrollBarWidth: gridScrollBarWidth }) : null), [gridColumns, measuredWidth, gridScrollBarWidth])
775
+ const numericTemplate = useMemo(() => (gridColumns ? buildNumericTemplate(gridColumns) : ""), [gridColumns])
776
+ // Footer aggregates over ALL nodes (collapsed included); only compute when a footer is shown.
777
+ const allEntriesFlat = useMemo(() => (grid?.showFooter && gridColumns ? flattenAllEntries(entries) : []), [grid?.showFooter, gridColumns, entries])
778
+ const maxHScroll = gridMetrics?.maxHScroll ?? 0
779
+ // Re-clamp the horizontal offset when the layout shrinks (resize / column change).
780
+ useEffect(() => {
781
+ setHscroll((prev) => clampHScroll(prev, maxHScroll))
782
+ }, [maxHScroll])
783
+ const handleWheelHorizontal = useCallback((deltaX: number) => setHscroll((prev) => clampHScroll(prev + deltaX, maxHScroll)), [maxHScroll])
784
+ // Stable visual object so horizontal scrolling (which changes --dt-hscroll only) never churns the memoized rows.
785
+ const gridRowVisual = useMemo(
786
+ () => ({ iconOverrides, showExpandIcons, showDirectoryIcons, showFileIcons, useCanvasExpandIcons: showTreeLines, expandIconSize, highlightStyles }),
787
+ [iconOverrides, showExpandIcons, showDirectoryIcons, showFileIcons, showTreeLines, expandIconSize, highlightStyles],
788
+ )
789
+
790
+ const isEmpty = entries.length === 0
791
+
792
+ // The VirtualScroll element is memoized WITHOUT hscroll in its deps: a horizontal-scroll tick only
793
+ // updates the container's `--dt-hscroll` variable (the numeric tracks translate via CSS and the
794
+ // memoized rows are skipped), so the virtualized body is not re-rendered. It still recomputes on
795
+ // vertical scroll (via treeLineBackground) and on any data/selection/visual change.
796
+ const virtualScrollElement = useMemo(
797
+ () => (
798
+ <VirtualScroll
799
+ ref={virtualScrollRef}
800
+ itemCount={virtualItems.length}
801
+ getItem={getItem}
802
+ getItemHeight={getItemHeight}
803
+ viewportSize={viewportHeight}
804
+ overscanCount={userOverscanCount}
805
+ onScroll={handleVirtualScroll}
806
+ onRangeChange={handleRangeChange}
807
+ className={virtualScrollClassName}
808
+ onWheelHorizontal={isGrid ? handleWheelHorizontal : undefined}
809
+ background={
810
+ <>
811
+ {isGrid && gridMetrics ? (
812
+ // Clip the connector-line canvas to the frozen name column so deep trees never bleed into the numeric region.
813
+ <div className="pointer-events-none absolute top-0 left-0 z-0 h-full overflow-hidden" style={{ width: gridMetrics.nameWidth }} aria-hidden="true">
814
+ {treeLineBackground}
815
+ </div>
816
+ ) : (
817
+ treeLineBackground
818
+ )}
819
+ {virtualScrollBackground}
820
+ </>
821
+ }
822
+ initialScrollIndex={initialScrollIndex}
823
+ initialScrollOffset={initialScrollOffset}
824
+ onItemFocus={onItemFocus}
825
+ callbackThrottleMs={callbackThrottleMs}
826
+ contentInsets={virtualScrollContentInsets}
827
+ scrollBarOptions={scrollBarOptions}
828
+ behaviorOptions={behaviorOptions}>
829
+ {(item) => {
830
+ if (!item) return null
831
+ if (isGrid && grid && gridMetrics) {
832
+ return (
833
+ <DirectoryTreeGridRow
834
+ key={item.entry.absolutePath}
835
+ entry={item.entry}
836
+ indentLevel={item.indentLevel}
837
+ rowHeight={item.rowHeight}
838
+ isDirOpen={safeIsExpanded}
839
+ columns={grid.columns}
840
+ numericTemplate={numericTemplate}
841
+ numericTotal={gridMetrics.numericTotal}
842
+ nameWidth={gridMetrics.nameWidth}
843
+ rowClassName={grid.rowClassName}
844
+ selection={{ onEntryClick, selectedPath: selectedPath ?? null, mode: selectionMode, selectedItems, onSelectionChange }}
845
+ expansion={{ toggleDirectory: toggle, onToggleDirectoryRecursive: handleDirectoryDoubleClick }}
846
+ visual={gridRowVisual}
847
+ />
848
+ )
849
+ }
850
+ return (
851
+ <DirectoryEntryItem
852
+ key={item.entry.absolutePath}
853
+ entry={item.entry}
854
+ indentLevel={item.indentLevel}
855
+ rowHeight={item.rowHeight}
856
+ isDirOpen={safeIsExpanded}
857
+ parentIsLast={item.parentIsLast}
858
+ renderChildren={false}
859
+ expansion={{ toggleDirectory: toggle, onToggleDirectoryRecursive: handleDirectoryDoubleClick }}
860
+ selection={{ onEntryClick, selectedPath: selectedPath ?? null, mode: selectionMode, selectedItems, onSelectionChange }}
861
+ visual={{
862
+ iconOverrides,
863
+ showExpandIcons,
864
+ showDirectoryIcons,
865
+ showFileIcons,
866
+ useCanvasExpandIcons: showTreeLines,
867
+ expandIconSize,
868
+ highlightStyles,
869
+ entryClassName,
870
+ entryStyle,
871
+ nameClassName,
872
+ nameStyle,
873
+ directoryNameClassName,
874
+ directoryNameStyle,
875
+ fileNameClassName,
876
+ fileNameStyle,
877
+ }}
878
+ />
879
+ )
880
+ }}
881
+ </VirtualScroll>
882
+ ),
883
+ [
884
+ virtualItems.length,
885
+ getItem,
886
+ getItemHeight,
887
+ viewportHeight,
888
+ userOverscanCount,
889
+ handleVirtualScroll,
890
+ handleRangeChange,
891
+ virtualScrollClassName,
892
+ isGrid,
893
+ handleWheelHorizontal,
894
+ gridMetrics,
895
+ treeLineBackground,
896
+ virtualScrollBackground,
897
+ initialScrollIndex,
898
+ initialScrollOffset,
899
+ onItemFocus,
900
+ callbackThrottleMs,
901
+ virtualScrollContentInsets,
902
+ scrollBarOptions,
903
+ behaviorOptions,
904
+ grid,
905
+ numericTemplate,
906
+ safeIsExpanded,
907
+ onEntryClick,
908
+ selectedPath,
909
+ selectionMode,
910
+ selectedItems,
911
+ onSelectionChange,
912
+ toggle,
913
+ handleDirectoryDoubleClick,
914
+ gridRowVisual,
915
+ iconOverrides,
916
+ showExpandIcons,
917
+ showDirectoryIcons,
918
+ showFileIcons,
919
+ showTreeLines,
920
+ expandIconSize,
921
+ highlightStyles,
922
+ entryClassName,
923
+ entryStyle,
924
+ nameClassName,
925
+ nameStyle,
926
+ directoryNameClassName,
927
+ directoryNameStyle,
928
+ fileNameClassName,
929
+ fileNameStyle,
930
+ ],
931
+ )
932
+
933
+ if (!(grid && gridMetrics)) {
934
+ if (isEmpty) return <div className={containerClasses} style={containerStyle} />
935
+ return (
936
+ <div ref={containerRef} className={containerClasses} style={containerStyle} role="tree">
937
+ {virtualScrollElement}
938
+ </div>
939
+ )
940
+ }
941
+
942
+ const showHeader = grid.showHeader ?? true
943
+ const showFooter = grid.showFooter ?? false
944
+ const gridContainerStyle = { ...containerStyle, "--dt-hscroll": `${hscroll}px` } as CSSProperties
945
+
946
+ return (
947
+ <div ref={containerRef} className={twMerge(containerClasses, "dt-grid")} style={gridContainerStyle} role="treegrid">
948
+ {showHeader && <DirectoryTreeGridHeaderRow columns={grid.columns} numericTemplate={numericTemplate} numericTotal={gridMetrics.numericTotal} nameWidth={gridMetrics.nameWidth} scrollBarWidth={gridScrollBarWidth} className={grid.headerClassName} />}
949
+ <div ref={bodyRef} className="dt-grid-body">
950
+ {!isEmpty && virtualScrollElement}
951
+ </div>
952
+ {maxHScroll > 0 && (
953
+ <div className="dt-grid-hscrollbar-row">
954
+ <div className="dt-grid-scrollbar-spacer" style={{ width: gridMetrics.nameWidth }} aria-hidden="true" />
955
+ <ScrollBar
956
+ horizontal
957
+ contentSize={gridMetrics.numericTotal}
958
+ viewportSize={gridMetrics.numericVisible}
959
+ scrollPosition={hscroll}
960
+ scrollBarWidth={gridScrollBarWidth}
961
+ onScroll={(next) => {
962
+ const resolved = typeof next === "function" ? next(hscroll) : next
963
+ const clamped = clampHScroll(resolved, maxHScroll)
964
+ setHscroll(clamped)
965
+ return clamped
966
+ }}
967
+ />
968
+ <div className="dt-grid-scrollbar-spacer" style={{ width: gridScrollBarWidth }} aria-hidden="true" />
969
+ </div>
970
+ )}
971
+ {showFooter && (
972
+ <DirectoryTreeGridFooterRow columns={grid.columns} numericTemplate={numericTemplate} numericTotal={gridMetrics.numericTotal} nameWidth={gridMetrics.nameWidth} scrollBarWidth={gridScrollBarWidth} allEntries={allEntriesFlat} className={grid.footerClassName} />
973
+ )}
974
+ </div>
975
+ )
976
+ }
977
+
978
+ export { directoryTreeClasses }