@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,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* One row of LpFileTree plus its recursively rendered children. Split out so the
|
|
4
|
+
* tree can recurse without LpFileTree importing itself, and so the row's
|
|
5
|
+
* indent/expand/selection logic lives in one place. Not exported on its own; an
|
|
6
|
+
* LpFileTree detail.
|
|
7
|
+
*
|
|
8
|
+
* Roving tabindex: exactly one row in the whole tree is focusable at a time
|
|
9
|
+
* (the one matching `focusedId`), which is what a11y expects of role="tree" —
|
|
10
|
+
* Tab enters the tree once, arrows move within it. The parent owns that state.
|
|
11
|
+
*/
|
|
12
|
+
import { computed } from "vue"
|
|
13
|
+
import {
|
|
14
|
+
checkStateOf,
|
|
15
|
+
fileIcon,
|
|
16
|
+
formatModified,
|
|
17
|
+
formatSize,
|
|
18
|
+
sortNodes,
|
|
19
|
+
subtreeSize,
|
|
20
|
+
type FileNode,
|
|
21
|
+
} from "./fileTree"
|
|
22
|
+
import LpCheckbox from "./LpCheckbox.vue"
|
|
23
|
+
import LpContextMenu from "./LpContextMenu.vue"
|
|
24
|
+
import LpIcon from "./LpIcon.vue"
|
|
25
|
+
|
|
26
|
+
const props = defineProps<{
|
|
27
|
+
node: FileNode
|
|
28
|
+
/** Nesting depth, 0 at the root — drives the indent guide and aria-level. */
|
|
29
|
+
depth: number
|
|
30
|
+
expanded: Set<string>
|
|
31
|
+
loading: Set<string>
|
|
32
|
+
selectedId?: string
|
|
33
|
+
focusedId?: string
|
|
34
|
+
sort: boolean
|
|
35
|
+
/** Rendered size of the row's icon, in px. */
|
|
36
|
+
iconSize: number
|
|
37
|
+
/** Render a tri-state checkbox per row (multi-select mode). */
|
|
38
|
+
checkable: boolean
|
|
39
|
+
checked: Set<string>
|
|
40
|
+
showSize: boolean
|
|
41
|
+
showModified: boolean
|
|
42
|
+
}>()
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{
|
|
45
|
+
(e: "select", node: FileNode): void
|
|
46
|
+
(e: "toggle", node: FileNode): void
|
|
47
|
+
(e: "focus", node: FileNode): void
|
|
48
|
+
(e: "check", node: FileNode, value: boolean): void
|
|
49
|
+
}>()
|
|
50
|
+
|
|
51
|
+
// Declared explicitly: the component renders itself, so an inferred slot type
|
|
52
|
+
// would reference its own initializer (TS7022).
|
|
53
|
+
defineSlots<{
|
|
54
|
+
row(props: { node: FileNode; depth: number; expanded: boolean; selected: boolean }): unknown
|
|
55
|
+
}>()
|
|
56
|
+
|
|
57
|
+
const isDir = computed(() => props.node.kind === "dir")
|
|
58
|
+
const isOpen = computed(() => isDir.value && props.expanded.has(props.node.id))
|
|
59
|
+
const isLoading = computed(() => props.loading.has(props.node.id))
|
|
60
|
+
const isSelected = computed(() => props.selectedId === props.node.id)
|
|
61
|
+
const isFocused = computed(() => props.focusedId === props.node.id)
|
|
62
|
+
|
|
63
|
+
// A directory with no `children` array hasn't been loaded yet — we can't know if
|
|
64
|
+
// it's empty, so it stays expandable and the caller fills it on `expand`.
|
|
65
|
+
const hasChildren = computed(() => isDir.value && (props.node.children?.length ?? 0) > 0)
|
|
66
|
+
const canExpand = computed(() => isDir.value && (props.node.children === undefined || hasChildren.value))
|
|
67
|
+
|
|
68
|
+
const children = computed(() => {
|
|
69
|
+
const list = props.node.children ?? []
|
|
70
|
+
return props.sort ? sortNodes(list) : list
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const checkState = computed(() =>
|
|
74
|
+
props.checkable ? checkStateOf(props.node, props.checked) : "unchecked",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// A directory shows the rolled-up size of what's under it, so a backup picker
|
|
78
|
+
// can answer "how much is this folder" without expanding it.
|
|
79
|
+
const sizeLabel = computed(() => {
|
|
80
|
+
if (!props.showSize) return ""
|
|
81
|
+
const bytes = subtreeSize(props.node)
|
|
82
|
+
return bytes === undefined ? "" : formatSize(bytes)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const modifiedLabel = computed(() =>
|
|
86
|
+
props.showModified && props.node.modified !== undefined
|
|
87
|
+
? formatModified(props.node.modified)
|
|
88
|
+
: "",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Per-row stagger for the reveal animation. Capped so a large directory doesn't
|
|
93
|
+
* spend a second unfolding — past the cap every remaining row comes in together.
|
|
94
|
+
*/
|
|
95
|
+
function rowDelay(index: number): string {
|
|
96
|
+
return `${Math.min(index * 18, 180)}ms`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Respect the OS setting — skip the height animation entirely. */
|
|
100
|
+
function reducedMotion(): boolean {
|
|
101
|
+
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Expand/collapse the branch by animating from 0 to its measured height. The
|
|
105
|
+
// Web Animations API is used directly (rather than a CSS class) because the
|
|
106
|
+
// target height isn't known until the rows are in the DOM.
|
|
107
|
+
function onBranchEnter(el: Element, done: () => void) {
|
|
108
|
+
if (reducedMotion()) return done()
|
|
109
|
+
const height = (el as HTMLElement).scrollHeight
|
|
110
|
+
el.animate(
|
|
111
|
+
[
|
|
112
|
+
{ height: "0px", opacity: 0 },
|
|
113
|
+
{ height: `${height}px`, opacity: 1 },
|
|
114
|
+
],
|
|
115
|
+
{ duration: 220, easing: "cubic-bezier(0.2, 0, 0, 1)" },
|
|
116
|
+
).onfinish = () => done()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function onBranchLeave(el: Element, done: () => void) {
|
|
120
|
+
if (reducedMotion()) return done()
|
|
121
|
+
const height = (el as HTMLElement).scrollHeight
|
|
122
|
+
el.animate(
|
|
123
|
+
[
|
|
124
|
+
{ height: `${height}px`, opacity: 1 },
|
|
125
|
+
{ height: "0px", opacity: 0 },
|
|
126
|
+
],
|
|
127
|
+
{ duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)" },
|
|
128
|
+
).onfinish = () => done()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onActivate() {
|
|
132
|
+
if (props.node.disabled) return
|
|
133
|
+
emit("focus", props.node)
|
|
134
|
+
// A directory row toggles; a file row selects. Directories still emit select
|
|
135
|
+
// so callers can drive a details panel off the highlighted entry.
|
|
136
|
+
emit("select", props.node)
|
|
137
|
+
if (isDir.value) emit("toggle", props.node)
|
|
138
|
+
}
|
|
139
|
+
</script>
|
|
140
|
+
|
|
141
|
+
<template>
|
|
142
|
+
<li
|
|
143
|
+
role="treeitem"
|
|
144
|
+
class="min-w-0"
|
|
145
|
+
:aria-level="depth + 1"
|
|
146
|
+
:aria-selected="isSelected"
|
|
147
|
+
:aria-expanded="canExpand ? isOpen : undefined"
|
|
148
|
+
:aria-disabled="node.disabled || undefined"
|
|
149
|
+
>
|
|
150
|
+
<!-- Passthrough when the node has no menu, so the row keeps the native one. -->
|
|
151
|
+
<LpContextMenu :items="node.menu ?? []">
|
|
152
|
+
<div
|
|
153
|
+
:data-node-id="node.id"
|
|
154
|
+
:tabindex="isFocused ? 0 : -1"
|
|
155
|
+
class="group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
|
|
156
|
+
:class="[
|
|
157
|
+
node.disabled
|
|
158
|
+
? 'cursor-not-allowed text-muted/50'
|
|
159
|
+
: isSelected
|
|
160
|
+
? 'bg-brand-soft text-ink'
|
|
161
|
+
: 'text-muted hover:bg-surface-soft hover:text-ink',
|
|
162
|
+
]"
|
|
163
|
+
:style="{ paddingLeft: `${depth * 14 + 6}px` }"
|
|
164
|
+
@click.stop="onActivate"
|
|
165
|
+
>
|
|
166
|
+
<!-- Chevron sits in a fixed-width slot so names line up whether or not
|
|
167
|
+
the row can expand. Leaves get the same gutter, left empty. -->
|
|
168
|
+
<span class="flex size-4 shrink-0 items-center justify-center">
|
|
169
|
+
<LpIcon
|
|
170
|
+
v-if="isLoading"
|
|
171
|
+
name="lucide:loader-circle"
|
|
172
|
+
:size="13"
|
|
173
|
+
class="animate-spin text-muted"
|
|
174
|
+
/>
|
|
175
|
+
<LpIcon
|
|
176
|
+
v-else-if="canExpand"
|
|
177
|
+
name="lucide:chevron-right"
|
|
178
|
+
:size="14"
|
|
179
|
+
class="text-muted transition-transform duration-[var(--duration-fast)]"
|
|
180
|
+
:class="isOpen ? 'rotate-90' : ''"
|
|
181
|
+
/>
|
|
182
|
+
</span>
|
|
183
|
+
|
|
184
|
+
<!-- Checkbox is its own hit target: ticking a folder must not also
|
|
185
|
+
expand it (and vice versa), so the click stops here. -->
|
|
186
|
+
<LpCheckbox
|
|
187
|
+
v-if="checkable"
|
|
188
|
+
:model-value="checkState === 'checked'"
|
|
189
|
+
:indeterminate="checkState === 'indeterminate'"
|
|
190
|
+
:disabled="node.disabled"
|
|
191
|
+
class="shrink-0"
|
|
192
|
+
:aria-label="node.name"
|
|
193
|
+
@click.stop
|
|
194
|
+
@update:model-value="emit('check', node, $event)"
|
|
195
|
+
/>
|
|
196
|
+
|
|
197
|
+
<LpIcon
|
|
198
|
+
:name="fileIcon(node, isOpen)"
|
|
199
|
+
:size="iconSize"
|
|
200
|
+
class="shrink-0"
|
|
201
|
+
:class="node.disabled ? '' : isDir ? 'text-brand' : 'text-muted'"
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
<span class="min-w-0 flex-1 truncate">
|
|
205
|
+
<slot name="row" :node="node" :depth="depth" :expanded="isOpen" :selected="isSelected">
|
|
206
|
+
{{ node.name }}
|
|
207
|
+
</slot>
|
|
208
|
+
</span>
|
|
209
|
+
|
|
210
|
+
<!-- Metadata columns: tabular figures so sizes line up down the tree. -->
|
|
211
|
+
<span v-if="node.meta" class="shrink-0 text-xs text-muted">{{ node.meta }}</span>
|
|
212
|
+
<span
|
|
213
|
+
v-if="modifiedLabel"
|
|
214
|
+
class="shrink-0 text-xs tabular-nums text-muted/80"
|
|
215
|
+
>
|
|
216
|
+
{{ modifiedLabel }}
|
|
217
|
+
</span>
|
|
218
|
+
<span
|
|
219
|
+
v-if="sizeLabel"
|
|
220
|
+
class="w-16 shrink-0 text-right text-xs tabular-nums text-muted"
|
|
221
|
+
>
|
|
222
|
+
{{ sizeLabel }}
|
|
223
|
+
</span>
|
|
224
|
+
</div>
|
|
225
|
+
</LpContextMenu>
|
|
226
|
+
|
|
227
|
+
<!-- Height is only known at runtime, so the expand/collapse is measured in
|
|
228
|
+
JS hooks rather than a keyframe. Rows inside stagger themselves in. -->
|
|
229
|
+
<Transition
|
|
230
|
+
:css="false"
|
|
231
|
+
@enter="onBranchEnter"
|
|
232
|
+
@leave="onBranchLeave"
|
|
233
|
+
>
|
|
234
|
+
<ul v-if="isOpen && hasChildren" role="group" class="flex min-w-0 flex-col overflow-hidden">
|
|
235
|
+
<LpFileTreeNode
|
|
236
|
+
v-for="(child, i) in children"
|
|
237
|
+
:key="child.id"
|
|
238
|
+
:style="{ '--row-delay': rowDelay(i) }"
|
|
239
|
+
class="animate-[tree-row-in_var(--duration-medium)_var(--ease-emphasized)_both] [animation-delay:var(--row-delay)] motion-reduce:animate-none"
|
|
240
|
+
:node="child"
|
|
241
|
+
:depth="depth + 1"
|
|
242
|
+
:expanded="expanded"
|
|
243
|
+
:loading="loading"
|
|
244
|
+
:selected-id="selectedId"
|
|
245
|
+
:focused-id="focusedId"
|
|
246
|
+
:sort="sort"
|
|
247
|
+
:icon-size="iconSize"
|
|
248
|
+
:checkable="checkable"
|
|
249
|
+
:checked="checked"
|
|
250
|
+
:show-size="showSize"
|
|
251
|
+
:show-modified="showModified"
|
|
252
|
+
@select="emit('select', $event)"
|
|
253
|
+
@toggle="emit('toggle', $event)"
|
|
254
|
+
@focus="emit('focus', $event)"
|
|
255
|
+
@check="(n, v) => emit('check', n, v)"
|
|
256
|
+
>
|
|
257
|
+
<template #row="slotProps">
|
|
258
|
+
<slot name="row" v-bind="slotProps" />
|
|
259
|
+
</template>
|
|
260
|
+
</LpFileTreeNode>
|
|
261
|
+
</ul>
|
|
262
|
+
</Transition>
|
|
263
|
+
</li>
|
|
264
|
+
</template>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared file-tree types and helpers. Lives in its own module so LpFileTree and
|
|
3
|
+
* its recursive LpFileTreeNode body can both import them without a
|
|
4
|
+
* component-to-component cycle. Re-exported from LpFileTree.vue.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
8
|
+
|
|
9
|
+
export interface FileNode {
|
|
10
|
+
/** Stable identity — use the full path when you have one. */
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
/** Directories can hold `children`; files are leaves. */
|
|
14
|
+
kind: "file" | "dir"
|
|
15
|
+
/** Size in bytes. Shown right-aligned when `showSize` is on. */
|
|
16
|
+
size?: number
|
|
17
|
+
/** Last-modified time — Date, epoch ms, or a pre-formatted string. */
|
|
18
|
+
modified?: Date | number | string
|
|
19
|
+
/** Extra right-aligned text (badge-ish), e.g. "read-only". */
|
|
20
|
+
meta?: string
|
|
21
|
+
/**
|
|
22
|
+
* Omit on a directory to mark its children as not-yet-loaded: the row shows a
|
|
23
|
+
* chevron and emits `expand` on first open so the caller can fetch them.
|
|
24
|
+
* An empty array means "loaded, and genuinely empty".
|
|
25
|
+
*/
|
|
26
|
+
children?: FileNode[]
|
|
27
|
+
/** Override the type-derived icon. */
|
|
28
|
+
icon?: string
|
|
29
|
+
/** Dimmed row (e.g. a git-ignored or unreadable entry). Still expandable. */
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
/** Right-click menu for this entry. */
|
|
32
|
+
menu?: ContextMenuItemDef[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Directory icons are state-driven; files fall back to an extension map. */
|
|
36
|
+
const EXT_ICONS: Record<string, string> = {
|
|
37
|
+
json: "lucide:file-json",
|
|
38
|
+
jsonc: "lucide:file-json",
|
|
39
|
+
yaml: "lucide:file-cog",
|
|
40
|
+
yml: "lucide:file-cog",
|
|
41
|
+
toml: "lucide:file-cog",
|
|
42
|
+
ini: "lucide:file-cog",
|
|
43
|
+
conf: "lucide:file-cog",
|
|
44
|
+
env: "lucide:file-key",
|
|
45
|
+
ts: "lucide:file-code",
|
|
46
|
+
tsx: "lucide:file-code",
|
|
47
|
+
js: "lucide:file-code",
|
|
48
|
+
mjs: "lucide:file-code",
|
|
49
|
+
cjs: "lucide:file-code",
|
|
50
|
+
vue: "lucide:file-code",
|
|
51
|
+
py: "lucide:file-code",
|
|
52
|
+
rs: "lucide:file-code",
|
|
53
|
+
go: "lucide:file-code",
|
|
54
|
+
sh: "lucide:file-terminal",
|
|
55
|
+
zsh: "lucide:file-terminal",
|
|
56
|
+
bash: "lucide:file-terminal",
|
|
57
|
+
md: "lucide:file-text",
|
|
58
|
+
mdx: "lucide:file-text",
|
|
59
|
+
txt: "lucide:file-text",
|
|
60
|
+
log: "lucide:scroll-text",
|
|
61
|
+
csv: "lucide:file-spreadsheet",
|
|
62
|
+
sql: "lucide:database",
|
|
63
|
+
png: "lucide:file-image",
|
|
64
|
+
jpg: "lucide:file-image",
|
|
65
|
+
jpeg: "lucide:file-image",
|
|
66
|
+
gif: "lucide:file-image",
|
|
67
|
+
svg: "lucide:file-image",
|
|
68
|
+
webp: "lucide:file-image",
|
|
69
|
+
ico: "lucide:file-image",
|
|
70
|
+
zip: "lucide:file-archive",
|
|
71
|
+
gz: "lucide:file-archive",
|
|
72
|
+
tar: "lucide:file-archive",
|
|
73
|
+
tgz: "lucide:file-archive",
|
|
74
|
+
xz: "lucide:file-archive",
|
|
75
|
+
pdf: "lucide:file-type",
|
|
76
|
+
lock: "lucide:file-lock",
|
|
77
|
+
pem: "lucide:file-key",
|
|
78
|
+
key: "lucide:file-key",
|
|
79
|
+
crt: "lucide:file-key",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Dotfiles that read as config regardless of extension. */
|
|
83
|
+
const NAME_ICONS: Record<string, string> = {
|
|
84
|
+
".env": "lucide:file-key",
|
|
85
|
+
".gitignore": "lucide:git-branch",
|
|
86
|
+
".dockerignore": "lucide:container",
|
|
87
|
+
dockerfile: "lucide:container",
|
|
88
|
+
"docker-compose.yml": "lucide:container",
|
|
89
|
+
"docker-compose.yaml": "lucide:container",
|
|
90
|
+
makefile: "lucide:file-cog",
|
|
91
|
+
"package.json": "lucide:package",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Icon for a node: explicit override → dir state → name → extension → generic. */
|
|
95
|
+
export function fileIcon(node: FileNode, expanded = false): string {
|
|
96
|
+
if (node.icon) return node.icon
|
|
97
|
+
if (node.kind === "dir") return expanded ? "lucide:folder-open" : "lucide:folder"
|
|
98
|
+
|
|
99
|
+
const lower = node.name.toLowerCase()
|
|
100
|
+
if (NAME_ICONS[lower]) return NAME_ICONS[lower]
|
|
101
|
+
// Leading dot doesn't start an extension (".env" is a name, not an ext).
|
|
102
|
+
const dot = lower.lastIndexOf(".")
|
|
103
|
+
const ext = dot > 0 ? lower.slice(dot + 1) : ""
|
|
104
|
+
return EXT_ICONS[ext] ?? "lucide:file"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Directories first, then case-insensitive by name — the file-manager order. */
|
|
108
|
+
export function sortNodes(nodes: FileNode[]): FileNode[] {
|
|
109
|
+
return [...nodes].sort((a, b) => {
|
|
110
|
+
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
|
111
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Human-readable byte size — binary units, matching what file managers show. */
|
|
116
|
+
export function formatSize(bytes: number): string {
|
|
117
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "—"
|
|
118
|
+
if (bytes < 1024) return `${bytes} B`
|
|
119
|
+
const units = ["KB", "MB", "GB", "TB", "PB"]
|
|
120
|
+
let value = bytes / 1024
|
|
121
|
+
let i = 0
|
|
122
|
+
while (value >= 1024 && i < units.length - 1) {
|
|
123
|
+
value /= 1024
|
|
124
|
+
i++
|
|
125
|
+
}
|
|
126
|
+
// One decimal below 10 (2.4 MB), none above (240 MB) — keeps the column tidy.
|
|
127
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Short local date-time for the modified column; passes strings through. */
|
|
131
|
+
export function formatModified(value: Date | number | string): string {
|
|
132
|
+
if (typeof value === "string") return value
|
|
133
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
134
|
+
if (Number.isNaN(date.getTime())) return "—"
|
|
135
|
+
return date.toLocaleString(undefined, {
|
|
136
|
+
day: "2-digit",
|
|
137
|
+
month: "short",
|
|
138
|
+
hour: "2-digit",
|
|
139
|
+
minute: "2-digit",
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Total size of a subtree: a directory's own `size` when the caller computed one
|
|
145
|
+
* (the common case for a server-side listing), otherwise the sum of its loaded
|
|
146
|
+
* descendants. Returns undefined when nothing along the way carries a size.
|
|
147
|
+
*/
|
|
148
|
+
export function subtreeSize(node: FileNode): number | undefined {
|
|
149
|
+
if (node.kind === "file") return node.size
|
|
150
|
+
if (node.size !== undefined) return node.size
|
|
151
|
+
if (!node.children?.length) return undefined
|
|
152
|
+
let total = 0
|
|
153
|
+
let seen = false
|
|
154
|
+
for (const child of node.children) {
|
|
155
|
+
const size = subtreeSize(child)
|
|
156
|
+
if (size !== undefined) {
|
|
157
|
+
total += size
|
|
158
|
+
seen = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return seen ? total : undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every id in a subtree including the root — the unit a checkbox toggles. */
|
|
165
|
+
export function subtreeIds(node: FileNode): string[] {
|
|
166
|
+
const out = [node.id]
|
|
167
|
+
for (const child of node.children ?? []) out.push(...subtreeIds(child))
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type CheckState = "checked" | "unchecked" | "indeterminate"
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A node's checkbox state derived from the checked-id set.
|
|
175
|
+
*
|
|
176
|
+
* A directory reflects its loaded children: all checked → checked, some →
|
|
177
|
+
* indeterminate. A directory whose children aren't loaded yet (or which has
|
|
178
|
+
* none) falls back to its own membership, so an unexpanded folder can still be
|
|
179
|
+
* ticked as a whole — which is exactly what a backup picker needs.
|
|
180
|
+
*/
|
|
181
|
+
export function checkStateOf(node: FileNode, checked: ReadonlySet<string>): CheckState {
|
|
182
|
+
if (node.kind === "file" || !node.children?.length) {
|
|
183
|
+
return checked.has(node.id) ? "checked" : "unchecked"
|
|
184
|
+
}
|
|
185
|
+
let all = true
|
|
186
|
+
let any = false
|
|
187
|
+
for (const child of node.children) {
|
|
188
|
+
const state = checkStateOf(child, checked)
|
|
189
|
+
if (state === "checked") any = true
|
|
190
|
+
else if (state === "indeterminate") {
|
|
191
|
+
any = true
|
|
192
|
+
all = false
|
|
193
|
+
} else all = false
|
|
194
|
+
}
|
|
195
|
+
if (all) return "checked"
|
|
196
|
+
return any ? "indeterminate" : "unchecked"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
|
|
200
|
+
export function ancestorIds(nodes: FileNode[], id: string): string[] {
|
|
201
|
+
const path: string[] = []
|
|
202
|
+
function walk(list: FileNode[], trail: string[]): boolean {
|
|
203
|
+
for (const node of list) {
|
|
204
|
+
if (node.id === id) {
|
|
205
|
+
path.push(...trail)
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
if (node.children && walk(node.children, [...trail, node.id])) return true
|
|
209
|
+
}
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
walk(nodes, [])
|
|
213
|
+
return path
|
|
214
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,19 @@ export { default as LpDrawer } from "./components/LpDrawer.vue"
|
|
|
25
25
|
export { default as LpDropdownMenu } from "./components/LpDropdownMenu.vue"
|
|
26
26
|
export type { MenuItem } from "./components/LpDropdownMenu.vue"
|
|
27
27
|
export { default as LpEmptyState } from "./components/LpEmptyState.vue"
|
|
28
|
+
export { default as LpFileTree } from "./components/LpFileTree.vue"
|
|
29
|
+
export type { FileTreeSummary } from "./components/LpFileTree.vue"
|
|
30
|
+
export type { CheckState, FileNode } from "./components/fileTree"
|
|
31
|
+
export {
|
|
32
|
+
ancestorIds,
|
|
33
|
+
checkStateOf,
|
|
34
|
+
fileIcon,
|
|
35
|
+
formatModified,
|
|
36
|
+
formatSize,
|
|
37
|
+
sortNodes,
|
|
38
|
+
subtreeIds,
|
|
39
|
+
subtreeSize,
|
|
40
|
+
} from "./components/fileTree"
|
|
28
41
|
export { default as LpFormField } from "./components/LpFormField.vue"
|
|
29
42
|
export { default as LpIcon } from "./components/LpIcon.vue"
|
|
30
43
|
export { default as LpInfraNode } from "./components/LpInfraNode.vue"
|
package/src/tokens/tokens.css
CHANGED
|
@@ -279,6 +279,21 @@
|
|
|
279
279
|
transform: translateY(0);
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
|
+
/*
|
|
283
|
+
* Tree/list row reveal: a row fades in while sliding from its parent's indent,
|
|
284
|
+
* so an expanding branch reads as unfolding outward rather than popping. Used by
|
|
285
|
+
* LpFileTree, which staggers it per row via --row-delay.
|
|
286
|
+
*/
|
|
287
|
+
@keyframes tree-row-in {
|
|
288
|
+
from {
|
|
289
|
+
opacity: 0;
|
|
290
|
+
transform: translateX(-6px);
|
|
291
|
+
}
|
|
292
|
+
to {
|
|
293
|
+
opacity: 1;
|
|
294
|
+
transform: translateX(0);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
282
297
|
|
|
283
298
|
/*
|
|
284
299
|
* Skin painter. A raised panel opts in with .lp-skin-panel and gets the active
|
|
@@ -319,10 +334,13 @@
|
|
|
319
334
|
* the glow is the wider of the two, so reserving for it covers both.
|
|
320
335
|
*
|
|
321
336
|
* Put this on the element that LAYS OUT the cards (the <ul>, the grid), not on
|
|
322
|
-
* the clipping parent: it
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
337
|
+
* the clipping parent: it insets the cards by exactly the distance the glow
|
|
338
|
+
* travels, so the glow lands on that padding instead of on the clip boundary.
|
|
339
|
+
*
|
|
340
|
+
* Deliberately plain padding, with NO negative margin to cancel it: the whole
|
|
341
|
+
* point is to keep the glow inside the clip, and pulling the box back out with
|
|
342
|
+
* margins would push it straight back under the blade. The cards therefore sit
|
|
343
|
+
* a little in from the edge — that inset IS the fix, not a side effect.
|
|
326
344
|
*
|
|
327
345
|
* Bottom gets more room than the top because the glow is offset downward, and
|
|
328
346
|
* the top gets the lift added back, since a hovered card rises into it.
|
|
@@ -330,8 +348,6 @@
|
|
|
330
348
|
.lp-glow-room {
|
|
331
349
|
padding: calc(var(--lp-glow-spread) + var(--lp-card-lift)) var(--lp-glow-spread)
|
|
332
350
|
var(--lp-glow-spread-bottom);
|
|
333
|
-
margin: calc(-1 * (var(--lp-glow-spread) + var(--lp-card-lift))) calc(-1 * var(--lp-glow-spread))
|
|
334
|
-
calc(-1 * var(--lp-glow-spread-bottom));
|
|
335
351
|
}
|
|
336
352
|
|
|
337
353
|
/* ── circular theme reveal (View Transitions) ────
|