@aiquants/directory-tree 3.0.4 → 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.
- package/README.md +25 -8
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/styles/directory-tree.css +1 -1
- package/dist/styles/directory-tree.standalone.css +3 -0
- package/package.json +10 -8
- package/src/DirectoryTree.spec.md +473 -0
- package/src/DirectoryTree.tsx +978 -0
- package/src/DirectoryTreeGrid.tsx +261 -0
- package/src/TreeLine.tsx +300 -0
- package/src/cli.server.ts +38 -0
- package/src/entryRendering.tsx +131 -0
- package/src/gridUtils.ts +88 -0
- package/src/index.ts +31 -0
- package/src/logger.ts +162 -0
- package/src/styles/components.entry.css +8 -0
- package/src/styles/directory-tree.css +89 -0
- package/src/styles/standalone.entry.css +12 -0
- package/src/types.ts +619 -0
- package/src/useDirectoryTreeState.ts +133 -0
- package/src/useEntryInteraction.ts +74 -0
- package/dist/directory-tree.css +0 -1
|
@@ -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 }
|