@leavepulse/ui 0.14.9 → 0.14.10
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/dist/chunks/LpFileTree-0Ko_uj8q.js +371 -0
- package/dist/chunks/LpFileTreeNode-D3lNEP-1.js +216 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpFileTree.vue.d.ts +106 -0
- package/dist/components/LpFileTree.vue.js +4 -0
- package/dist/components/LpFileTreeNode.vue.d.ts +45 -0
- package/dist/components/LpFileTreeNode.vue.js +4 -0
- package/dist/components/LpLogViewer.vue.d.ts +1 -1
- package/dist/components/fileTree.d.ts +54 -0
- package/dist/components/fileTree.js +155 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +85 -74
- package/package.json +1 -1
- package/src/component-names.ts +2 -0
- package/src/components/LpFileTree.vue +512 -0
- package/src/components/LpFileTreeNode.vue +264 -0
- package/src/components/fileTree.ts +214 -0
- package/src/index.ts +13 -0
- package/src/tokens/tokens.css +22 -6
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* File/folder tree — the kit convention for browsing a hierarchy of files so
|
|
4
|
+
* apps stop re-implementing indent math, chevron state and tree keyboard rules.
|
|
5
|
+
* Data-driven: pass a `FileNode[]`; it renders directories-first rows with
|
|
6
|
+
* type-derived icons, expand/collapse, single selection, and optional per-row
|
|
7
|
+
* context menus.
|
|
8
|
+
*
|
|
9
|
+
* Read-only by design — it navigates, it doesn't mutate. Renames/uploads belong
|
|
10
|
+
* to the caller, reachable through `node.menu`.
|
|
11
|
+
*
|
|
12
|
+
* Lazy loading: a directory whose `children` is undefined is treated as
|
|
13
|
+
* not-yet-loaded — it stays expandable, shows a spinner while the caller fetches
|
|
14
|
+
* (`loadingIds`), and emits `expand` once. An empty array means genuinely empty.
|
|
15
|
+
*
|
|
16
|
+
* Keyboard (role="tree" conventions): ↑/↓ move through visible rows, → opens a
|
|
17
|
+
* directory or steps into it, ← collapses or steps out to the parent, Home/End
|
|
18
|
+
* jump to the ends, Enter/Space activates. A roving tabindex keeps the tree a
|
|
19
|
+
* single tab stop.
|
|
20
|
+
*/
|
|
21
|
+
import { computed, ref, watch } from "vue"
|
|
22
|
+
import {
|
|
23
|
+
ancestorIds,
|
|
24
|
+
checkStateOf,
|
|
25
|
+
formatSize,
|
|
26
|
+
sortNodes,
|
|
27
|
+
subtreeIds,
|
|
28
|
+
subtreeSize,
|
|
29
|
+
type FileNode,
|
|
30
|
+
} from "./fileTree"
|
|
31
|
+
import LpEmptyState from "./LpEmptyState.vue"
|
|
32
|
+
import LpFileTreeNode from "./LpFileTreeNode.vue"
|
|
33
|
+
import LpScrollArea from "./LpScrollArea.vue"
|
|
34
|
+
|
|
35
|
+
export type { FileNode } from "./fileTree"
|
|
36
|
+
|
|
37
|
+
/** Rolled-up stats for the ticked entries — what a backup picker summarises. */
|
|
38
|
+
export interface FileTreeSummary {
|
|
39
|
+
/** Ticked files (directories excluded — they're containers, not payload). */
|
|
40
|
+
files: number
|
|
41
|
+
/** Ticked directories. */
|
|
42
|
+
dirs: number
|
|
43
|
+
/** Total bytes across ticked files, or undefined when no size is known. */
|
|
44
|
+
bytes?: number
|
|
45
|
+
/** `bytes` pre-formatted ("2.4 GB"), or "—". */
|
|
46
|
+
sizeLabel: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const props = withDefaults(
|
|
50
|
+
defineProps<{
|
|
51
|
+
nodes: FileNode[]
|
|
52
|
+
/** Selected node id (v-model:selected). */
|
|
53
|
+
selected?: string
|
|
54
|
+
/** Expanded directory ids (v-model:expanded). Omit to let the tree own it. */
|
|
55
|
+
expanded?: string[]
|
|
56
|
+
/** Directory ids currently fetching children — shows a spinner on the row. */
|
|
57
|
+
loadingIds?: string[]
|
|
58
|
+
/**
|
|
59
|
+
* Multi-select mode: every row gets a tri-state checkbox and ticking a
|
|
60
|
+
* directory cascades to its loaded subtree. This is the mode for "pick what
|
|
61
|
+
* to back up" — `selected` stays independent (it's the highlighted row).
|
|
62
|
+
*/
|
|
63
|
+
checkable?: boolean
|
|
64
|
+
/** Ticked node ids (v-model:checked). */
|
|
65
|
+
checked?: string[]
|
|
66
|
+
/** Right-aligned size column (directories roll up their subtree). */
|
|
67
|
+
showSize?: boolean
|
|
68
|
+
/** Right-aligned last-modified column. */
|
|
69
|
+
showModified?: boolean
|
|
70
|
+
/** Directories first, then by name. Turn off to keep the caller's order. */
|
|
71
|
+
sort?: boolean
|
|
72
|
+
/** Row icon size in px. */
|
|
73
|
+
iconSize?: number
|
|
74
|
+
/** Skeleton rows while the initial listing loads. */
|
|
75
|
+
loading?: boolean
|
|
76
|
+
skeletonRows?: number
|
|
77
|
+
/** Empty-state copy when `nodes` is empty. */
|
|
78
|
+
emptyLabel?: string
|
|
79
|
+
emptyDescription?: string
|
|
80
|
+
}>(),
|
|
81
|
+
{
|
|
82
|
+
sort: true,
|
|
83
|
+
iconSize: 15,
|
|
84
|
+
checkable: false,
|
|
85
|
+
showSize: false,
|
|
86
|
+
showModified: false,
|
|
87
|
+
loading: false,
|
|
88
|
+
skeletonRows: 8,
|
|
89
|
+
emptyLabel: "No files",
|
|
90
|
+
emptyDescription: "This folder is empty.",
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const emit = defineEmits<{
|
|
95
|
+
(e: "update:selected", id: string): void
|
|
96
|
+
(e: "update:expanded", ids: string[]): void
|
|
97
|
+
/** Any row activated (file or directory). */
|
|
98
|
+
(e: "select", node: FileNode): void
|
|
99
|
+
/** A file row activated — the common case for opening a preview. */
|
|
100
|
+
(e: "open", node: FileNode): void
|
|
101
|
+
/** A directory opened for the first time: fetch its children here. */
|
|
102
|
+
(e: "expand", node: FileNode): void
|
|
103
|
+
(e: "collapse", node: FileNode): void
|
|
104
|
+
(e: "update:checked", ids: string[]): void
|
|
105
|
+
/** Ticked set changed, with the rolled-up stats already computed. */
|
|
106
|
+
(e: "summary", summary: FileTreeSummary): void
|
|
107
|
+
}>()
|
|
108
|
+
|
|
109
|
+
defineSlots<{
|
|
110
|
+
/** Replace the row's label (icons, chevron and indent stay). */
|
|
111
|
+
row(props: { node: FileNode; depth: number; expanded: boolean; selected: boolean }): unknown
|
|
112
|
+
/** Footer under the tree, handed the live selection stats. */
|
|
113
|
+
summary(props: FileTreeSummary): unknown
|
|
114
|
+
}>()
|
|
115
|
+
|
|
116
|
+
// Expansion is optionally controlled: with the `expanded` prop bound we mirror
|
|
117
|
+
// it, otherwise the tree owns the set. Either way the internal ref is the single
|
|
118
|
+
// source the rows read, so uncontrolled use needs no wiring from the caller.
|
|
119
|
+
const openIds = ref<Set<string>>(new Set(props.expanded ?? []))
|
|
120
|
+
watch(
|
|
121
|
+
() => props.expanded,
|
|
122
|
+
(ids) => {
|
|
123
|
+
if (ids) openIds.value = new Set(ids)
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const loadingSet = computed(() => new Set(props.loadingIds ?? []))
|
|
128
|
+
|
|
129
|
+
// Same controlled/uncontrolled deal as `expanded`.
|
|
130
|
+
const checkedIds = ref<Set<string>>(new Set(props.checked ?? []))
|
|
131
|
+
watch(
|
|
132
|
+
() => props.checked,
|
|
133
|
+
(ids) => {
|
|
134
|
+
if (ids) checkedIds.value = new Set(ids)
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
/** Index every node by id once per data change — the tree walks it a lot. */
|
|
139
|
+
const byId = computed(() => {
|
|
140
|
+
const map = new Map<string, FileNode>()
|
|
141
|
+
function walk(list: FileNode[]) {
|
|
142
|
+
for (const node of list) {
|
|
143
|
+
map.set(node.id, node)
|
|
144
|
+
if (node.children) walk(node.children)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
walk(props.nodes)
|
|
148
|
+
return map
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What the ticked set adds up to. Directories are counted but not sized — their
|
|
153
|
+
* bytes arrive through the files inside them, so summing both would double-count.
|
|
154
|
+
* A ticked-but-unexpanded directory contributes its own `size` when it has one,
|
|
155
|
+
* since none of its files are in the set yet.
|
|
156
|
+
*/
|
|
157
|
+
const summary = computed<FileTreeSummary>(() => {
|
|
158
|
+
let files = 0
|
|
159
|
+
let dirs = 0
|
|
160
|
+
let bytes = 0
|
|
161
|
+
let sized = false
|
|
162
|
+
for (const id of checkedIds.value) {
|
|
163
|
+
const node = byId.value.get(id)
|
|
164
|
+
if (!node) continue
|
|
165
|
+
if (node.kind === "dir") {
|
|
166
|
+
dirs++
|
|
167
|
+
// Only when nothing inside it is ticked (unexpanded/lazy folder).
|
|
168
|
+
const inside = node.children?.length
|
|
169
|
+
? subtreeIds(node).some((child) => child !== id && checkedIds.value.has(child))
|
|
170
|
+
: false
|
|
171
|
+
if (!inside) {
|
|
172
|
+
const size = subtreeSize(node)
|
|
173
|
+
if (size !== undefined) {
|
|
174
|
+
bytes += size
|
|
175
|
+
sized = true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
files++
|
|
180
|
+
if (node.size !== undefined) {
|
|
181
|
+
bytes += node.size
|
|
182
|
+
sized = true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
files,
|
|
188
|
+
dirs,
|
|
189
|
+
bytes: sized ? bytes : undefined,
|
|
190
|
+
sizeLabel: sized ? formatSize(bytes) : "—",
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
watch(summary, (value) => emit("summary", value), { deep: false })
|
|
195
|
+
|
|
196
|
+
function setChecked(next: Set<string>) {
|
|
197
|
+
checkedIds.value = next
|
|
198
|
+
emit("update:checked", [...next])
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Tick/untick a node and its loaded subtree, then reconcile ancestors: a parent
|
|
203
|
+
* is in the set only while every one of its children is, so the derived
|
|
204
|
+
* tri-state and the emitted id list agree.
|
|
205
|
+
*/
|
|
206
|
+
function onCheck(node: FileNode, value: boolean) {
|
|
207
|
+
if (node.disabled) return
|
|
208
|
+
const next = new Set(checkedIds.value)
|
|
209
|
+
for (const id of subtreeIds(node)) {
|
|
210
|
+
const target = byId.value.get(id)
|
|
211
|
+
if (target?.disabled) continue // never tick an entry the caller locked
|
|
212
|
+
if (value) next.add(id)
|
|
213
|
+
else next.delete(id)
|
|
214
|
+
}
|
|
215
|
+
// Walk ancestors outward, re-deriving each from its children.
|
|
216
|
+
for (const ancestorId of ancestorIds(props.nodes, node.id).reverse()) {
|
|
217
|
+
const ancestor = byId.value.get(ancestorId)
|
|
218
|
+
if (!ancestor?.children?.length) continue
|
|
219
|
+
const all = ancestor.children.every((c) => c.disabled || next.has(c.id))
|
|
220
|
+
if (all) next.add(ancestorId)
|
|
221
|
+
else next.delete(ancestorId)
|
|
222
|
+
}
|
|
223
|
+
setChecked(next)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Tick every node in the tree (skipping disabled ones). */
|
|
227
|
+
function checkAll() {
|
|
228
|
+
const next = new Set<string>()
|
|
229
|
+
for (const [id, node] of byId.value) if (!node.disabled) next.add(id)
|
|
230
|
+
setChecked(next)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function clearChecked() {
|
|
234
|
+
setChecked(new Set())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Directories we've already emitted `expand` for, so a collapse/re-expand cycle
|
|
238
|
+
// doesn't refetch. Callers that want a refresh can drop `children` again.
|
|
239
|
+
const requested = new Set<string>()
|
|
240
|
+
|
|
241
|
+
const roots = computed(() => (props.sort ? sortNodes(props.nodes) : props.nodes))
|
|
242
|
+
|
|
243
|
+
/** Rows currently visible, in render order — the axis the arrow keys walk. */
|
|
244
|
+
const visible = computed(() => {
|
|
245
|
+
const out: FileNode[] = []
|
|
246
|
+
function walk(list: FileNode[]) {
|
|
247
|
+
for (const node of props.sort ? sortNodes(list) : list) {
|
|
248
|
+
out.push(node)
|
|
249
|
+
if (node.kind === "dir" && openIds.value.has(node.id) && node.children?.length) {
|
|
250
|
+
walk(node.children)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
walk(props.nodes)
|
|
255
|
+
return out
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// The roving-tabindex row. Follows selection when the caller sets one; falls
|
|
259
|
+
// back to the first row so the tree is reachable by Tab before any click.
|
|
260
|
+
const focusedId = ref<string | undefined>(props.selected)
|
|
261
|
+
watch(
|
|
262
|
+
() => props.selected,
|
|
263
|
+
(id) => {
|
|
264
|
+
if (id) focusedId.value = id
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
watch(
|
|
268
|
+
visible,
|
|
269
|
+
(rows) => {
|
|
270
|
+
if (!rows.length) {
|
|
271
|
+
focusedId.value = undefined
|
|
272
|
+
} else if (!rows.some((n) => n.id === focusedId.value)) {
|
|
273
|
+
// The focused row scrolled out of existence (parent collapsed, data
|
|
274
|
+
// changed) — fall back to the first visible row.
|
|
275
|
+
focusedId.value = rows[0].id
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
{ immediate: true },
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
const root = ref<HTMLElement | null>(null)
|
|
282
|
+
|
|
283
|
+
function focusRow(id: string) {
|
|
284
|
+
focusedId.value = id
|
|
285
|
+
// Rows are plain divs keyed by data-node-id; query rather than hold refs
|
|
286
|
+
// through the recursion.
|
|
287
|
+
const el = root.value?.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(id)}"]`)
|
|
288
|
+
el?.focus()
|
|
289
|
+
el?.scrollIntoView({ block: "nearest" })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function setExpanded(next: Set<string>) {
|
|
293
|
+
openIds.value = next
|
|
294
|
+
emit("update:expanded", [...next])
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function expand(node: FileNode) {
|
|
298
|
+
if (node.kind !== "dir" || openIds.value.has(node.id)) return
|
|
299
|
+
const next = new Set(openIds.value)
|
|
300
|
+
next.add(node.id)
|
|
301
|
+
setExpanded(next)
|
|
302
|
+
// Fetch on first open only when the children aren't loaded yet.
|
|
303
|
+
if (node.children === undefined && !requested.has(node.id)) {
|
|
304
|
+
requested.add(node.id)
|
|
305
|
+
emit("expand", node)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function collapse(node: FileNode) {
|
|
310
|
+
if (!openIds.value.has(node.id)) return
|
|
311
|
+
const next = new Set(openIds.value)
|
|
312
|
+
next.delete(node.id)
|
|
313
|
+
setExpanded(next)
|
|
314
|
+
emit("collapse", node)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function onToggle(node: FileNode) {
|
|
318
|
+
if (openIds.value.has(node.id)) collapse(node)
|
|
319
|
+
else expand(node)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function onSelect(node: FileNode) {
|
|
323
|
+
if (node.disabled) return
|
|
324
|
+
emit("update:selected", node.id)
|
|
325
|
+
emit("select", node)
|
|
326
|
+
if (node.kind === "file") emit("open", node)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Nearest expanded ancestor of `id`, or undefined at the top level. */
|
|
330
|
+
function parentOf(id: string): FileNode | undefined {
|
|
331
|
+
const trail = ancestorIds(props.nodes, id)
|
|
332
|
+
const parentId = trail.at(-1)
|
|
333
|
+
return parentId ? visible.value.find((n) => n.id === parentId) : undefined
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function move(delta: number) {
|
|
337
|
+
const rows = visible.value
|
|
338
|
+
if (!rows.length) return
|
|
339
|
+
const i = rows.findIndex((n) => n.id === focusedId.value)
|
|
340
|
+
const next = rows[Math.min(rows.length - 1, Math.max(0, (i < 0 ? 0 : i) + delta))]
|
|
341
|
+
if (next) focusRow(next.id)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function current(): FileNode | undefined {
|
|
345
|
+
return visible.value.find((n) => n.id === focusedId.value)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function onKeydown(e: KeyboardEvent) {
|
|
349
|
+
const node = current()
|
|
350
|
+
switch (e.key) {
|
|
351
|
+
case "ArrowDown":
|
|
352
|
+
e.preventDefault()
|
|
353
|
+
move(1)
|
|
354
|
+
break
|
|
355
|
+
case "ArrowUp":
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
move(-1)
|
|
358
|
+
break
|
|
359
|
+
case "ArrowRight": {
|
|
360
|
+
if (!node) return
|
|
361
|
+
e.preventDefault()
|
|
362
|
+
// Closed directory opens; already-open one steps to its first child.
|
|
363
|
+
if (node.kind === "dir" && !openIds.value.has(node.id)) expand(node)
|
|
364
|
+
else if (node.kind === "dir" && node.children?.length) move(1)
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
case "ArrowLeft": {
|
|
368
|
+
if (!node) return
|
|
369
|
+
e.preventDefault()
|
|
370
|
+
// Open directory closes; anything else steps out to the parent row.
|
|
371
|
+
if (node.kind === "dir" && openIds.value.has(node.id)) collapse(node)
|
|
372
|
+
else {
|
|
373
|
+
const parent = parentOf(node.id)
|
|
374
|
+
if (parent) focusRow(parent.id)
|
|
375
|
+
}
|
|
376
|
+
break
|
|
377
|
+
}
|
|
378
|
+
case "Home":
|
|
379
|
+
e.preventDefault()
|
|
380
|
+
if (visible.value[0]) focusRow(visible.value[0].id)
|
|
381
|
+
break
|
|
382
|
+
case "End": {
|
|
383
|
+
e.preventDefault()
|
|
384
|
+
const last = visible.value.at(-1)
|
|
385
|
+
if (last) focusRow(last.id)
|
|
386
|
+
break
|
|
387
|
+
}
|
|
388
|
+
case " ": {
|
|
389
|
+
if (!node) return
|
|
390
|
+
e.preventDefault()
|
|
391
|
+
// In checkable mode Space is the tick — the standard tree behaviour for a
|
|
392
|
+
// multi-select tree — and Enter stays "open/activate".
|
|
393
|
+
if (props.checkable) {
|
|
394
|
+
onCheck(node, checkStateOf(node, checkedIds.value) !== "checked")
|
|
395
|
+
break
|
|
396
|
+
}
|
|
397
|
+
onSelect(node)
|
|
398
|
+
if (node.kind === "dir") onToggle(node)
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
case "Enter": {
|
|
402
|
+
if (!node) return
|
|
403
|
+
e.preventDefault()
|
|
404
|
+
onSelect(node)
|
|
405
|
+
if (node.kind === "dir") onToggle(node)
|
|
406
|
+
break
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Expand every ancestor of `id`, focus it and make it the selection. */
|
|
412
|
+
function reveal(id: string) {
|
|
413
|
+
const next = new Set(openIds.value)
|
|
414
|
+
for (const ancestor of ancestorIds(props.nodes, id)) next.add(ancestor)
|
|
415
|
+
setExpanded(next)
|
|
416
|
+
emit("update:selected", id)
|
|
417
|
+
// Wait a frame so the newly expanded rows exist before we query for one.
|
|
418
|
+
requestAnimationFrame(() => focusRow(id))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function expandAll() {
|
|
422
|
+
const next = new Set(openIds.value)
|
|
423
|
+
function walk(list: FileNode[]) {
|
|
424
|
+
for (const node of list) {
|
|
425
|
+
if (node.kind === "dir" && node.children?.length) {
|
|
426
|
+
next.add(node.id)
|
|
427
|
+
walk(node.children)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
walk(props.nodes)
|
|
432
|
+
setExpanded(next)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function collapseAll() {
|
|
436
|
+
setExpanded(new Set())
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
defineExpose({ reveal, expandAll, collapseAll, checkAll, clearChecked, summary })
|
|
440
|
+
</script>
|
|
441
|
+
|
|
442
|
+
<template>
|
|
443
|
+
<div v-if="loading" class="flex flex-col gap-1">
|
|
444
|
+
<div
|
|
445
|
+
v-for="n in skeletonRows"
|
|
446
|
+
:key="n"
|
|
447
|
+
class="h-7 animate-pulse rounded-control bg-surface-soft"
|
|
448
|
+
:style="{ marginLeft: `${(n % 3) * 14}px` }"
|
|
449
|
+
/>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<LpEmptyState
|
|
453
|
+
v-else-if="!roots.length"
|
|
454
|
+
icon="lucide:folder-search"
|
|
455
|
+
:title="emptyLabel"
|
|
456
|
+
:description="emptyDescription"
|
|
457
|
+
/>
|
|
458
|
+
|
|
459
|
+
<!-- h-full so the tree fills whatever box the caller gave it and the scroll
|
|
460
|
+
area below gets a bounded height; without it the rows just grow past the
|
|
461
|
+
container and nothing ever scrolls. -->
|
|
462
|
+
<div v-else class="flex h-full min-h-0 flex-col">
|
|
463
|
+
<LpScrollArea class="min-h-0 flex-1">
|
|
464
|
+
<ul
|
|
465
|
+
ref="root"
|
|
466
|
+
role="tree"
|
|
467
|
+
:aria-multiselectable="checkable"
|
|
468
|
+
class="flex min-w-0 flex-col"
|
|
469
|
+
@keydown="onKeydown"
|
|
470
|
+
>
|
|
471
|
+
<LpFileTreeNode
|
|
472
|
+
v-for="node in roots"
|
|
473
|
+
:key="node.id"
|
|
474
|
+
:node="node"
|
|
475
|
+
:depth="0"
|
|
476
|
+
:expanded="openIds"
|
|
477
|
+
:loading="loadingSet"
|
|
478
|
+
:selected-id="selected"
|
|
479
|
+
:focused-id="focusedId"
|
|
480
|
+
:sort="sort"
|
|
481
|
+
:icon-size="iconSize"
|
|
482
|
+
:checkable="checkable"
|
|
483
|
+
:checked="checkedIds"
|
|
484
|
+
:show-size="showSize"
|
|
485
|
+
:show-modified="showModified"
|
|
486
|
+
@select="onSelect"
|
|
487
|
+
@toggle="onToggle"
|
|
488
|
+
@focus="focusedId = $event.id"
|
|
489
|
+
@check="onCheck"
|
|
490
|
+
>
|
|
491
|
+
<template #row="slotProps">
|
|
492
|
+
<slot name="row" v-bind="slotProps" />
|
|
493
|
+
</template>
|
|
494
|
+
</LpFileTreeNode>
|
|
495
|
+
</ul>
|
|
496
|
+
</LpScrollArea>
|
|
497
|
+
|
|
498
|
+
<!-- Selection stats. The default is a plain status line; pass #summary to
|
|
499
|
+
render your own, or ignore both and read the `summary` event. -->
|
|
500
|
+
<div
|
|
501
|
+
v-if="checkable && ($slots.summary || summary.files || summary.dirs)"
|
|
502
|
+
class="shrink-0 border-t border-line px-2 py-1.5 text-xs text-muted"
|
|
503
|
+
>
|
|
504
|
+
<slot name="summary" v-bind="summary">
|
|
505
|
+
{{ summary.files }} {{ summary.files === 1 ? "file" : "files" }}<template
|
|
506
|
+
v-if="summary.dirs"
|
|
507
|
+
>, {{ summary.dirs }} {{ summary.dirs === 1 ? "folder" : "folders" }}</template>
|
|
508
|
+
<template v-if="summary.bytes !== undefined"> · {{ summary.sizeLabel }}</template>
|
|
509
|
+
</slot>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
</template>
|