@leavepulse/ui 0.15.0 → 0.15.1

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.
Files changed (41) hide show
  1. package/dist/chunks/{LpAppShell-BLKZDLh8.js → LpAppShell-CV194os6.js} +2 -2
  2. package/dist/chunks/{LpAutocomplete-B9uoCs4t.js → LpAutocomplete-DIgXmtCA.js} +13 -5
  3. package/dist/chunks/{LpConfirmDialog-BA45MvRL.js → LpConfirmDialog-WHkymac5.js} +1 -1
  4. package/dist/chunks/{LpDrawer-6n5Hd-iF.js → LpDrawer-BHzCy-Aa.js} +1 -1
  5. package/dist/chunks/{LpFileTree-D0DEpKof.js → LpFileTree-BQz8Rfgz.js} +49 -32
  6. package/dist/chunks/{LpFileTreeNode-3e71ywJV.js → LpFileTreeNode-DFg4PhOK.js} +10 -7
  7. package/dist/chunks/{LpLogViewer-BBgaY38i.js → LpLogViewer-BvzRZPjs.js} +2 -1
  8. package/dist/chunks/{LpModal-B9CVVC5U.js → LpModal-DpBHQpbU.js} +1 -1
  9. package/dist/chunks/{LpNotificationBell-B9VHks_D.js → LpNotificationBell-BtfRoL2i.js} +1 -1
  10. package/dist/chunks/{LpScrollArea-BtnZCCBT.js → LpScrollArea-De9DLpa1.js} +2 -2
  11. package/dist/chunks/{LpSidebar-tZ2z-PnM.js → LpSidebar-B11IDRQi.js} +2 -2
  12. package/dist/chunks/{LpSidebarNav-D-YvtwsH.js → LpSidebarNav-TIUNHZX-.js} +1 -1
  13. package/dist/chunks/{LpTable-CzHVcr3v.js → LpTable-CKGaUbEM.js} +1 -1
  14. package/dist/components/LpAppShell.vue.js +1 -1
  15. package/dist/components/LpAutocomplete.vue.js +1 -1
  16. package/dist/components/LpCommandPalette.vue.js +1 -1
  17. package/dist/components/LpConfirmDialog.vue.js +1 -1
  18. package/dist/components/LpDrawer.vue.js +1 -1
  19. package/dist/components/LpFileTree.vue.js +1 -1
  20. package/dist/components/LpFileTreeNode.vue.d.ts +3 -1
  21. package/dist/components/LpFileTreeNode.vue.js +1 -1
  22. package/dist/components/LpLogViewer.vue.js +1 -1
  23. package/dist/components/LpModal.vue.js +1 -1
  24. package/dist/components/LpNotificationBell.vue.js +1 -1
  25. package/dist/components/LpScrollArea.vue.d.ts +4 -2
  26. package/dist/components/LpScrollArea.vue.js +1 -1
  27. package/dist/components/LpSidebar.vue.js +1 -1
  28. package/dist/components/LpSidebarNav.vue.js +1 -1
  29. package/dist/components/LpTable.vue.js +1 -1
  30. package/dist/components/fileTree.d.ts +16 -0
  31. package/dist/components/fileTree.js +49 -0
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +13 -12
  34. package/package.json +1 -1
  35. package/src/components/LpAutocomplete.vue +19 -2
  36. package/src/components/LpFileTree.vue +67 -35
  37. package/src/components/LpFileTreeNode.vue +16 -9
  38. package/src/components/LpLogViewer.vue +4 -0
  39. package/src/components/LpScrollArea.vue +16 -11
  40. package/src/components/fileTree.ts +75 -0
  41. package/src/index.ts +2 -1
@@ -1,8 +1,8 @@
1
1
  import { defineComponent, computed, watch, openBlock, createElementBlock, createVNode, createSlots, withCtx, renderSlot, normalizeProps, guardReactiveProps, createElementVNode, normalizeClass, toDisplayString, createCommentVNode, createBlock } from "vue";
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
3
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
4
- import { _ as _sfc_main$4 } from "./LpScrollArea-BtnZCCBT.js";
5
- import { _ as _sfc_main$1 } from "./LpSidebar-tZ2z-PnM.js";
4
+ import { _ as _sfc_main$4 } from "./LpScrollArea-De9DLpa1.js";
5
+ import { _ as _sfc_main$1 } from "./LpSidebar-B11IDRQi.js";
6
6
  const _hoisted_1 = { class: "flex h-dvh overflow-hidden bg-surface text-ink" };
7
7
  const _hoisted_2 = { class: "flex min-w-0 flex-1 flex-col" };
8
8
  const _hoisted_3 = { class: "flex h-16 shrink-0 items-center gap-3 border-b border-line bg-surface px-4 md:px-6" };
@@ -50,9 +50,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
50
50
  (o) => typeof o === "string" ? { value: o } : o
51
51
  )
52
52
  );
53
+ const typing = ref(false);
53
54
  const filtered = computed(() => {
54
55
  const q = text.value.trim().toLowerCase();
55
- if (!props.filter || !q) return items.value;
56
+ if (!props.filter || !typing.value || !q) return items.value;
56
57
  return items.value.filter((o) => {
57
58
  const hay = `${o.label ?? o.value} ${o.description ?? ""}`.toLowerCase();
58
59
  return hay.includes(q);
@@ -72,23 +73,30 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
72
73
  md: "h-(--size-control-md) text-sm",
73
74
  lg: "h-(--size-control-lg) text-sm"
74
75
  };
76
+ function onInput(event) {
77
+ typing.value = true;
78
+ text.value = event.target.value;
79
+ }
75
80
  function onFocus() {
81
+ typing.value = false;
76
82
  focused.value = true;
77
83
  if (canOpen.value) open.value = true;
78
84
  }
79
85
  function choose(opt) {
86
+ typing.value = false;
80
87
  emit("update:modelValue", opt.value);
81
88
  emit("select", opt.value);
82
89
  open.value = false;
83
90
  }
84
91
  function clear() {
92
+ typing.value = false;
85
93
  emit("update:modelValue", "");
86
94
  open.value = false;
87
95
  }
88
96
  return (_ctx, _cache) => {
89
97
  return openBlock(), createBlock(unref(ComboboxRoot), {
90
98
  open: open.value,
91
- "onUpdate:open": _cache[3] || (_cache[3] = ($event) => open.value = $event),
99
+ "onUpdate:open": _cache[2] || (_cache[2] = ($event) => open.value = $event),
92
100
  "ignore-filter": true,
93
101
  "reset-search-term-on-blur": false,
94
102
  "reset-search-term-on-select": false,
@@ -118,9 +126,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
118
126
  class: "min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted",
119
127
  onBeforeinput: unref(onBeforeInput),
120
128
  onPaste: unref(onPaste),
121
- onInput: _cache[0] || (_cache[0] = ($event) => text.value = $event.target.value),
129
+ onInput,
122
130
  onFocus,
123
- onBlur: _cache[1] || (_cache[1] = ($event) => focused.value = false)
131
+ onBlur: _cache[0] || (_cache[0] = ($event) => focused.value = false)
124
132
  }, null, 8, ["model-value", "placeholder", "disabled", "aria-invalid", "onBeforeinput", "onPaste"]),
125
133
  __props.clearable && hasValue.value ? (openBlock(), createElementBlock("button", {
126
134
  key: 1,
@@ -144,7 +152,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
144
152
  position: "popper",
145
153
  "side-offset": 6,
146
154
  class: normalizeClass([unref(POPOVER_PANEL), "z-(--z-popover) max-h-72 w-(--reka-combobox-trigger-width) overflow-hidden"]),
147
- onOpenAutoFocus: _cache[2] || (_cache[2] = withModifiers(() => {
155
+ onOpenAutoFocus: _cache[1] || (_cache[1] = withModifiers(() => {
148
156
  }, ["prevent"]))
149
157
  }, {
150
158
  default: withCtx(() => [
@@ -1,6 +1,6 @@
1
1
  import { defineComponent, openBlock, createBlock, withCtx, renderSlot, createVNode, createTextVNode, toDisplayString } from "vue";
2
2
  import { _ as _sfc_main$2 } from "./LpButton-5VXXCjwF.js";
3
- import { _ as _sfc_main$1 } from "./LpModal-B9CVVC5U.js";
3
+ import { _ as _sfc_main$1 } from "./LpModal-DpBHQpbU.js";
4
4
  const _sfc_main = /* @__PURE__ */ defineComponent({
5
5
  __name: "LpConfirmDialog",
6
6
  props: {
@@ -3,7 +3,7 @@ import { usePointerSwipe } from "@vueuse/core";
3
3
  import { DrawerRoot, DrawerPortal, DrawerOverlay, DrawerContent, DrawerHandle, DrawerTitle, DrawerDescription, DrawerClose } from "vaul-vue";
4
4
  import { CLOSE_ICON } from "../components/dropdown.js";
5
5
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
6
- import { _ as _sfc_main$2 } from "./LpScrollArea-BtnZCCBT.js";
6
+ import { _ as _sfc_main$2 } from "./LpScrollArea-De9DLpa1.js";
7
7
  const _hoisted_1 = { class: "text-base font-semibold text-ink" };
8
8
  const _hoisted_2 = { class: "flex flex-col gap-1" };
9
9
  const _hoisted_3 = ["data-vaul-no-drag"];
@@ -1,8 +1,8 @@
1
1
  import { defineComponent, ref, watch, computed, openBlock, createElementBlock, Fragment, renderList, normalizeStyle, createBlock, createVNode, withCtx, createElementVNode, renderSlot, mergeProps, normalizeProps, guardReactiveProps, createTextVNode, toDisplayString, createCommentVNode } from "vue";
2
- import { subtreeIds, subtreeSize, formatSize, sortNodes, ancestorIds, checkStateOf } from "../components/fileTree.js";
2
+ import { formatSize, sortNodes, computeStats, subtreeIds, ancestorIds } from "../components/fileTree.js";
3
3
  import { _ as _sfc_main$1 } from "./LpEmptyState-CrfyRJUG.js";
4
- import { _ as _sfc_main$3 } from "./LpFileTreeNode-3e71ywJV.js";
5
- import { _ as _sfc_main$2 } from "./LpScrollArea-BtnZCCBT.js";
4
+ import { _ as _sfc_main$3 } from "./LpFileTreeNode-DFg4PhOK.js";
5
+ import { _ as _sfc_main$2 } from "./LpScrollArea-De9DLpa1.js";
6
6
  const _hoisted_1 = {
7
7
  key: 0,
8
8
  class: "flex flex-col gap-1"
@@ -53,43 +53,56 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
53
53
  if (ids) checkedIds.value = new Set(ids);
54
54
  }
55
55
  );
56
- const byId = computed(() => {
57
- const map = /* @__PURE__ */ new Map();
58
- function walk(list) {
56
+ const index = computed(() => {
57
+ const byId2 = /* @__PURE__ */ new Map();
58
+ const parentOfId = /* @__PURE__ */ new Map();
59
+ function walk(list, parent) {
59
60
  for (const node of list) {
60
- map.set(node.id, node);
61
- if (node.children) walk(node.children);
61
+ byId2.set(node.id, node);
62
+ if (parent !== void 0) parentOfId.set(node.id, parent);
63
+ if (node.children) walk(node.children, node.id);
62
64
  }
63
65
  }
64
66
  walk(props.nodes);
65
- return map;
67
+ return { byId: byId2, parentOfId };
66
68
  });
69
+ const byId = computed(() => index.value.byId);
67
70
  const summary = computed(() => {
68
71
  let files = 0;
69
72
  let dirs = 0;
70
73
  let bytes = 0;
71
74
  let sized = false;
72
- for (const id of checkedIds.value) {
73
- const node = byId.value.get(id);
74
- if (!node) continue;
75
- if (node.kind === "dir") {
76
- dirs++;
77
- const inside = node.children?.length ? subtreeIds(node).some((child) => child !== id && checkedIds.value.has(child)) : false;
78
- if (!inside) {
79
- const size = subtreeSize(node);
80
- if (size !== void 0) {
81
- bytes += size;
75
+ function walk(list) {
76
+ for (const node of list) {
77
+ const stat = stats.value.get(node.id);
78
+ if (!stat || stat.state === "unchecked") continue;
79
+ if (node.kind === "file") {
80
+ files++;
81
+ if (node.size !== void 0) {
82
+ bytes += node.size;
82
83
  sized = true;
83
84
  }
85
+ continue;
84
86
  }
85
- } else {
86
- files++;
87
- if (node.size !== void 0) {
88
- bytes += node.size;
87
+ dirs++;
88
+ if (stat.state === "checked" && stat.size !== void 0) {
89
+ bytes += stat.size;
89
90
  sized = true;
91
+ files += stat.total;
92
+ countDirs(node.children ?? []);
93
+ continue;
90
94
  }
95
+ walk(node.children ?? []);
91
96
  }
92
97
  }
98
+ function countDirs(list) {
99
+ for (const node of list) {
100
+ if (node.kind !== "dir") continue;
101
+ dirs++;
102
+ countDirs(node.children ?? []);
103
+ }
104
+ }
105
+ walk(props.nodes);
93
106
  return {
94
107
  files,
95
108
  dirs,
@@ -111,12 +124,15 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
111
124
  if (value) next.add(id);
112
125
  else next.delete(id);
113
126
  }
114
- for (const ancestorId of ancestorIds(props.nodes, node.id).reverse()) {
127
+ let ancestorId = index.value.parentOfId.get(node.id);
128
+ while (ancestorId !== void 0) {
115
129
  const ancestor = byId.value.get(ancestorId);
116
- if (!ancestor?.children?.length) continue;
117
- const all = ancestor.children.every((c) => c.disabled || next.has(c.id));
118
- if (all) next.add(ancestorId);
119
- else next.delete(ancestorId);
130
+ if (ancestor?.children?.length) {
131
+ const all = ancestor.children.every((c) => c.disabled || next.has(c.id));
132
+ if (all) next.add(ancestorId);
133
+ else next.delete(ancestorId);
134
+ }
135
+ ancestorId = index.value.parentOfId.get(ancestorId);
120
136
  }
121
137
  setChecked(next);
122
138
  }
@@ -130,6 +146,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
130
146
  }
131
147
  const requested = /* @__PURE__ */ new Set();
132
148
  const roots = computed(() => props.sort ? sortNodes(props.nodes) : props.nodes);
149
+ const stats = computed(() => computeStats(props.nodes, checkedIds.value));
133
150
  const visible = computed(() => {
134
151
  const out = [];
135
152
  function walk(list) {
@@ -204,8 +221,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
204
221
  if (node.kind === "file") emit("open", node);
205
222
  }
206
223
  function parentOf(id) {
207
- const trail = ancestorIds(props.nodes, id);
208
- const parentId = trail.at(-1);
224
+ const parentId = index.value.parentOfId.get(id);
209
225
  return parentId ? visible.value.find((n) => n.id === parentId) : void 0;
210
226
  }
211
227
  function move(delta) {
@@ -260,7 +276,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
260
276
  if (!node) return;
261
277
  e.preventDefault();
262
278
  if (props.checkable) {
263
- onCheck(node, checkStateOf(node, checkedIds.value) !== "checked");
279
+ onCheck(node, stats.value.get(node.id)?.state !== "checked");
264
280
  break;
265
281
  }
266
282
  onSelect(node);
@@ -338,6 +354,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
338
354
  "icon-size": __props.iconSize,
339
355
  checkable: __props.checkable,
340
356
  checked: checkedIds.value,
357
+ stats: stats.value,
341
358
  "show-size": __props.showSize,
342
359
  "show-modified": __props.showModified,
343
360
  onSelect,
@@ -349,7 +366,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
349
366
  renderSlot(_ctx.$slots, "row", mergeProps({ ref_for: true }, slotProps))
350
367
  ]),
351
368
  _: 3
352
- }, 8, ["node", "expanded", "loading", "selected-id", "focused-id", "sort", "icon-size", "checkable", "checked", "show-size", "show-modified"]);
369
+ }, 8, ["node", "expanded", "loading", "selected-id", "focused-id", "sort", "icon-size", "checkable", "checked", "stats", "show-size", "show-modified"]);
353
370
  }), 128))
354
371
  ], 40, _hoisted_3)
355
372
  ]),
@@ -1,19 +1,19 @@
1
1
  import { defineComponent, computed, resolveComponent, openBlock, createElementBlock, createVNode, withCtx, createElementVNode, withModifiers, normalizeStyle, normalizeClass, createBlock, createCommentVNode, unref, renderSlot, createTextVNode, toDisplayString, Transition, Fragment, renderList, mergeProps } from "vue";
2
- import { sortNodes, checkStateOf, subtreeSize, formatSize, checkedCount, formatModified, fileIcon } from "../components/fileTree.js";
2
+ import { sortNodes, formatSize, formatModified, fileIcon } from "../components/fileTree.js";
3
3
  import { _ as _sfc_main$3 } from "./LpCheckbox-BO0zuuPh.js";
4
4
  import { _ as _sfc_main$1 } from "./LpContextMenu-D0mzXqA7.js";
5
5
  import { _ as _sfc_main$2 } from "./LpIcon-CCnX5_2j.js";
6
6
  const _hoisted_1 = ["aria-level", "aria-selected", "aria-expanded", "aria-disabled"];
7
7
  const _hoisted_2 = ["data-node-id", "tabindex"];
8
8
  const _hoisted_3 = { class: "flex size-4 shrink-0 items-center justify-center" };
9
- const _hoisted_4 = { class: "min-w-0 flex-1 truncate" };
9
+ const _hoisted_4 = { class: "min-w-0 flex-1 shrink-[2] truncate" };
10
10
  const _hoisted_5 = {
11
11
  key: 1,
12
12
  class: "shrink-0 rounded-pill bg-brand-soft px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-brand"
13
13
  };
14
14
  const _hoisted_6 = {
15
15
  key: 2,
16
- class: "shrink-0 text-xs text-muted"
16
+ class: "min-w-0 shrink truncate text-xs text-muted"
17
17
  };
18
18
  const _hoisted_7 = {
19
19
  key: 3,
@@ -41,6 +41,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
41
41
  iconSize: {},
42
42
  checkable: { type: Boolean },
43
43
  checked: {},
44
+ stats: {},
44
45
  showSize: { type: Boolean },
45
46
  showModified: { type: Boolean }
46
47
  },
@@ -59,17 +60,18 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
59
60
  const list = props.node.children ?? [];
60
61
  return props.sort ? sortNodes(list) : list;
61
62
  });
63
+ const stat = computed(() => props.stats.get(props.node.id));
62
64
  const checkState = computed(
63
- () => props.checkable ? checkStateOf(props.node, props.checked) : "unchecked"
65
+ () => props.checkable ? stat.value?.state ?? "unchecked" : "unchecked"
64
66
  );
65
67
  const sizeLabel = computed(() => {
66
68
  if (!props.showSize) return "";
67
- const bytes = subtreeSize(props.node);
69
+ const bytes = stat.value?.size;
68
70
  return bytes === void 0 ? "" : formatSize(bytes);
69
71
  });
70
72
  const countLabel = computed(() => {
71
73
  if (!props.checkable || !isDir.value) return "";
72
- const { checked, total } = checkedCount(props.node, props.checked);
74
+ const { checked = 0, total = 0 } = stat.value ?? {};
73
75
  return total && checked && checked < total ? `${checked} / ${total}` : "";
74
76
  });
75
77
  const modifiedLabel = computed(
@@ -201,6 +203,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
201
203
  "icon-size": __props.iconSize,
202
204
  checkable: __props.checkable,
203
205
  checked: __props.checked,
206
+ stats: __props.stats,
204
207
  "show-size": __props.showSize,
205
208
  "show-modified": __props.showModified,
206
209
  onSelect: _cache[2] || (_cache[2] = ($event) => emit("select", $event)),
@@ -212,7 +215,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
212
215
  renderSlot(_ctx.$slots, "row", mergeProps({ ref_for: true }, slotProps))
213
216
  ]),
214
217
  _: 3
215
- }, 8, ["style", "node", "depth", "expanded", "loading", "selected-id", "focused-id", "sort", "icon-size", "checkable", "checked", "show-size", "show-modified"]);
218
+ }, 8, ["style", "node", "depth", "expanded", "loading", "selected-id", "focused-id", "sort", "icon-size", "checkable", "checked", "stats", "show-size", "show-modified"]);
216
219
  }), 128))
217
220
  ])) : createCommentVNode("", true)
218
221
  ]),
@@ -1,7 +1,7 @@
1
1
  import { defineComponent, computed, ref, watch, nextTick, onMounted, onBeforeUnmount, openBlock, createElementBlock, createVNode, normalizeStyle, withCtx, createElementVNode, toDisplayString, createCommentVNode, Fragment, createBlock, resolveDynamicComponent, TransitionGroup, mergeProps, renderList, normalizeClass, createTextVNode, Transition } from "vue";
2
2
  import { _ as _sfc_main$3 } from "./LpContextMenu-D0mzXqA7.js";
3
3
  import { _ as _sfc_main$2 } from "./LpIcon-CCnX5_2j.js";
4
- import { _ as _sfc_main$1 } from "./LpScrollArea-BtnZCCBT.js";
4
+ import { _ as _sfc_main$1 } from "./LpScrollArea-De9DLpa1.js";
5
5
  const _hoisted_1 = { class: "relative overflow-hidden rounded-card border border-line bg-surface font-mono text-xs leading-relaxed" };
6
6
  const _hoisted_2 = {
7
7
  key: 0,
@@ -354,6 +354,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
354
354
  ref_key: "scrollRef",
355
355
  ref: scrollRef,
356
356
  fade: "",
357
+ instant: "",
357
358
  "content-class": __props.wrap ? "" : "min-w-max",
358
359
  style: normalizeStyle({ height: __props.height }),
359
360
  onScroll
@@ -2,7 +2,7 @@ import { defineComponent, computed, useSlots, openBlock, createBlock, unref, wit
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose } from "reka-ui";
3
3
  import { CLOSE_ICON } from "../components/dropdown.js";
4
4
  import { _ as _sfc_main$1 } from "./LpIcon-CCnX5_2j.js";
5
- import { _ as _sfc_main$2 } from "./LpScrollArea-BtnZCCBT.js";
5
+ import { _ as _sfc_main$2 } from "./LpScrollArea-De9DLpa1.js";
6
6
  const _hoisted_1 = {
7
7
  key: 0,
8
8
  class: "flex shrink-0 items-start justify-between gap-4 p-5 pb-3"
@@ -5,7 +5,7 @@ import { _ as _sfc_main$4 } from "./LpContextMenu-D0mzXqA7.js";
5
5
  import { _ as _sfc_main$2 } from "./LpEmptyState-CrfyRJUG.js";
6
6
  import { _ as _sfc_main$5 } from "./LpIcon-CCnX5_2j.js";
7
7
  import { _ as _sfc_main$1 } from "./LpPopover-8268UmlC.js";
8
- import { _ as _sfc_main$3 } from "./LpScrollArea-BtnZCCBT.js";
8
+ import { _ as _sfc_main$3 } from "./LpScrollArea-De9DLpa1.js";
9
9
  const _hoisted_1 = { class: "flex items-center justify-between gap-2 px-3 py-2.5" };
10
10
  const _hoisted_2 = { class: "text-sm font-semibold" };
11
11
  const _hoisted_3 = {
@@ -5,7 +5,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
5
5
  __name: "LpScrollArea",
6
6
  props: {
7
7
  fade: { type: Boolean },
8
- smooth: { type: Boolean },
8
+ instant: { type: Boolean, default: false },
9
9
  contentClass: {},
10
10
  barInsetTop: {}
11
11
  },
@@ -47,7 +47,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
47
47
  ref_key: "viewportRef",
48
48
  ref: viewportRef,
49
49
  class: normalizeClass(["size-full min-w-0 [&>div]:!block [&>div]:!min-w-0", [
50
- __props.smooth ? "scroll-smooth motion-reduce:scroll-auto" : "",
50
+ __props.instant ? "" : "scroll-smooth motion-reduce:scroll-auto",
51
51
  __props.fade ? "[mask-image:linear-gradient(to_bottom,transparent_0,black_14px,black_calc(100%-14px),transparent_100%)]" : ""
52
52
  ]]),
53
53
  onScrollPassive: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("scroll", $event))
@@ -1,6 +1,6 @@
1
1
  import { defineComponent, computed, useId, openBlock, createElementBlock, Fragment, createElementVNode, mergeProps, normalizeClass, renderSlot, createCommentVNode, createVNode, createSlots, withCtx, normalizeProps, guardReactiveProps, createBlock } from "vue";
2
- import { _ as _sfc_main$2 } from "./LpDrawer-6n5Hd-iF.js";
3
- import { _ as _sfc_main$1 } from "./LpSidebarNav-D-YvtwsH.js";
2
+ import { _ as _sfc_main$2 } from "./LpDrawer-BHzCy-Aa.js";
3
+ import { _ as _sfc_main$1 } from "./LpSidebarNav-TIUNHZX-.js";
4
4
  const _hoisted_1 = {
5
5
  key: 1,
6
6
  class: "mt-2 flex shrink-0 flex-col gap-2"
@@ -4,7 +4,7 @@ import { usePillTransition } from "../composables/usePillTransition.js";
4
4
  import { _ as _sfc_main$4 } from "./LpBadge-CXzBPnwO.js";
5
5
  import { _ as _sfc_main$2 } from "./LpContextMenu-D0mzXqA7.js";
6
6
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
7
- import { _ as _sfc_main$1 } from "./LpScrollArea-BtnZCCBT.js";
7
+ import { _ as _sfc_main$1 } from "./LpScrollArea-De9DLpa1.js";
8
8
  const _hoisted_1 = {
9
9
  key: 0,
10
10
  class: "flex min-h-0 flex-1 flex-col gap-4"
@@ -2,7 +2,7 @@ import { defineComponent, computed, ref, watch, onBeforeUnmount, openBlock, crea
2
2
  import { _ as _sfc_main$2 } from "./LpCheckbox-BO0zuuPh.js";
3
3
  import { _ as _sfc_main$4 } from "./LpContextMenu-D0mzXqA7.js";
4
4
  import { _ as _sfc_main$3 } from "./LpIcon-CCnX5_2j.js";
5
- import { _ as _sfc_main$1 } from "./LpScrollArea-BtnZCCBT.js";
5
+ import { _ as _sfc_main$1 } from "./LpScrollArea-De9DLpa1.js";
6
6
  const _hoisted_1 = { class: "w-full border-collapse text-sm" };
7
7
  const _hoisted_2 = { class: "border-b border-line bg-surface-soft" };
8
8
  const _hoisted_3 = {
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAppShell-BLKZDLh8.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAppShell-CV194os6.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpAutocomplete-B9uoCs4t.js";
1
+ import { _ as _sfc_main } from "../chunks/LpAutocomplete-DIgXmtCA.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,7 +1,7 @@
1
1
  import { defineComponent, ref, computed, watch, nextTick, onMounted, onBeforeUnmount, openBlock, createBlock, unref, withCtx, createVNode, withModifiers, createTextVNode, createElementVNode, withDirectives, withKeys, vModelText, createElementBlock, toDisplayString, createCommentVNode, Fragment, renderList, normalizeClass } from "vue";
2
2
  import { DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription } from "reka-ui";
3
3
  import { _ as _sfc_main$1 } from "../chunks/LpIcon-CCnX5_2j.js";
4
- import { _ as _sfc_main$2 } from "../chunks/LpScrollArea-BtnZCCBT.js";
4
+ import { _ as _sfc_main$2 } from "../chunks/LpScrollArea-De9DLpa1.js";
5
5
  import '../assets/LpCommandPalette-D315B47-.css';const _hoisted_1 = { class: "flex shrink-0 items-center gap-2.5 border-b border-line px-4" };
6
6
  const _hoisted_2 = ["placeholder", "onKeydown"];
7
7
  const _hoisted_3 = {
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpConfirmDialog-BA45MvRL.js";
1
+ import { _ as _sfc_main } from "../chunks/LpConfirmDialog-WHkymac5.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpDrawer-6n5Hd-iF.js";
1
+ import { _ as _sfc_main } from "../chunks/LpDrawer-BHzCy-Aa.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpFileTree-D0DEpKof.js";
1
+ import { _ as _sfc_main } from "../chunks/LpFileTree-BQz8Rfgz.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { FileNode } from './fileTree';
1
+ import { FileNode, NodeStats } from './fileTree';
2
2
  type __VLS_Props = {
3
3
  node: FileNode;
4
4
  /** Nesting depth, 0 at the root — drives the indent guide and aria-level. */
@@ -13,6 +13,8 @@ type __VLS_Props = {
13
13
  /** Render a tri-state checkbox per row (multi-select mode). */
14
14
  checkable: boolean;
15
15
  checked: Set<string>;
16
+ /** Per-node state, derived once by the root for the whole tree. */
17
+ stats: Map<string, NodeStats>;
16
18
  showSize: boolean;
17
19
  showModified: boolean;
18
20
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpFileTreeNode-3e71ywJV.js";
1
+ import { _ as _sfc_main } from "../chunks/LpFileTreeNode-DFg4PhOK.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpLogViewer-BBgaY38i.js";
1
+ import { _ as _sfc_main } from "../chunks/LpLogViewer-BvzRZPjs.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpModal-B9CVVC5U.js";
1
+ import { _ as _sfc_main } from "../chunks/LpModal-DpBHQpbU.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpNotificationBell-B9VHks_D.js";
1
+ import { _ as _sfc_main } from "../chunks/LpNotificationBell-BtfRoL2i.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,6 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  fade?: boolean;
3
- smooth?: boolean;
3
+ instant?: boolean;
4
4
  contentClass?: string;
5
5
  barInsetTop?: string;
6
6
  };
@@ -14,7 +14,9 @@ declare const __VLS_base: import('vue').DefineComponent<__VLS_Props, {
14
14
  scroll: (event: Event) => any;
15
15
  }, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
16
16
  onScroll?: ((event: Event) => any) | undefined;
17
- }>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
17
+ }>, {
18
+ instant: boolean;
19
+ }, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, any>;
18
20
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
19
21
  declare const _default: typeof __VLS_export;
20
22
  export default _default;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpScrollArea-BtnZCCBT.js";
1
+ import { _ as _sfc_main } from "../chunks/LpScrollArea-De9DLpa1.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSidebar-tZ2z-PnM.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSidebar-B11IDRQi.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpSidebarNav-D-YvtwsH.js";
1
+ import { _ as _sfc_main } from "../chunks/LpSidebarNav-TIUNHZX-.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpTable-CzHVcr3v.js";
1
+ import { _ as _sfc_main } from "../chunks/LpTable-CKGaUbEM.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -59,5 +59,21 @@ export declare function checkedCount(node: FileNode, checked: ReadonlySet<string
59
59
  checked: number;
60
60
  total: number;
61
61
  };
62
+ /** Everything a row needs to render, precomputed once per node. */
63
+ export interface NodeStats {
64
+ state: CheckState;
65
+ /** Ticked files in the subtree, and how many there are in total. */
66
+ checked: number;
67
+ total: number;
68
+ /** Rolled-up subtree size, or undefined when nothing carries one. */
69
+ size?: number;
70
+ }
71
+ /**
72
+ * Derive every row's state in ONE post-order pass over the tree, keyed by node
73
+ * id. Calling `checkStateOf`/`subtreeSize`/`checkedCount` per row instead makes
74
+ * each row walk its own subtree, which is quadratic on a deep tree and shows up
75
+ * as a stutter the moment a directory holds a few thousand files.
76
+ */
77
+ export declare function computeStats(nodes: FileNode[], checked: ReadonlySet<string>): Map<string, NodeStats>;
62
78
  /** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
63
79
  export declare function ancestorIds(nodes: FileNode[], id: string): string[];
@@ -144,6 +144,54 @@ function checkedCount(node, checked) {
144
144
  walk(node.children ?? []);
145
145
  return { checked: count, total };
146
146
  }
147
+ function computeStats(nodes, checked) {
148
+ const stats = /* @__PURE__ */ new Map();
149
+ function visit(node) {
150
+ if (node.kind === "file" || !node.children?.length) {
151
+ const isChecked = checked.has(node.id);
152
+ const self2 = {
153
+ state: isChecked ? "checked" : "unchecked",
154
+ checked: node.kind === "file" && isChecked ? 1 : 0,
155
+ total: node.kind === "file" ? 1 : 0,
156
+ size: node.size
157
+ };
158
+ stats.set(node.id, self2);
159
+ return self2;
160
+ }
161
+ let all = true;
162
+ let any = false;
163
+ let count = 0;
164
+ let total = 0;
165
+ let bytes = 0;
166
+ let sized = node.size !== void 0;
167
+ for (const child of node.children) {
168
+ const childStats = visit(child);
169
+ if (childStats.state === "checked") any = true;
170
+ else if (childStats.state === "indeterminate") {
171
+ any = true;
172
+ all = false;
173
+ } else all = false;
174
+ count += childStats.checked;
175
+ total += childStats.total;
176
+ if (childStats.size !== void 0) {
177
+ bytes += childStats.size;
178
+ sized = true;
179
+ }
180
+ }
181
+ const self = {
182
+ state: all ? "checked" : any ? "indeterminate" : "unchecked",
183
+ checked: count,
184
+ total,
185
+ // An explicit size on the directory wins: a server-side listing usually
186
+ // knows the real figure without the children being loaded.
187
+ size: node.size ?? (sized ? bytes : void 0)
188
+ };
189
+ stats.set(node.id, self);
190
+ return self;
191
+ }
192
+ for (const node of nodes) visit(node);
193
+ return stats;
194
+ }
147
195
  function ancestorIds(nodes, id) {
148
196
  const path = [];
149
197
  function walk(list, trail) {
@@ -163,6 +211,7 @@ export {
163
211
  ancestorIds,
164
212
  checkStateOf,
165
213
  checkedCount,
214
+ computeStats,
166
215
  fileIcon,
167
216
  formatModified,
168
217
  formatSize,
package/dist/index.d.ts CHANGED
@@ -27,8 +27,8 @@ export type { MenuItem } from './components/LpDropdownMenu.vue';
27
27
  export { default as LpEmptyState } from './components/LpEmptyState.vue';
28
28
  export { default as LpFileTree } from './components/LpFileTree.vue';
29
29
  export type { FileTreeSummary } from './components/LpFileTree.vue';
30
- export type { CheckState, FileNode } from './components/fileTree';
31
- export { ancestorIds, checkedCount, checkStateOf, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize, } from './components/fileTree';
30
+ export type { CheckState, FileNode, NodeStats } from './components/fileTree';
31
+ export { ancestorIds, checkedCount, checkStateOf, computeStats, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize, } from './components/fileTree';
32
32
  export { default as LpFormField } from './components/LpFormField.vue';
33
33
  export { default as LpIcon } from './components/LpIcon.vue';
34
34
  export { default as LpInfraNode } from './components/LpInfraNode.vue';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
2
  import { _ as _2 } from "./chunks/LpAlert-BAYHDSS1.js";
3
- import { _ as _3 } from "./chunks/LpAppShell-BLKZDLh8.js";
4
- import { _ as _4 } from "./chunks/LpAutocomplete-B9uoCs4t.js";
3
+ import { _ as _3 } from "./chunks/LpAppShell-CV194os6.js";
4
+ import { _ as _4 } from "./chunks/LpAutocomplete-DIgXmtCA.js";
5
5
  import { _ as _5 } from "./chunks/LpAvatar-B1Ez0ZAx.js";
6
6
  import { _ as _6 } from "./chunks/LpBadge-CXzBPnwO.js";
7
7
  import { _ as _7 } from "./chunks/LpBreadcrumbs-B7Aqatzw.js";
@@ -11,24 +11,24 @@ import { _ as _10 } from "./chunks/LpCard-BmrTvWOe.js";
11
11
  import { _ as _11 } from "./chunks/LpCheckbox-BO0zuuPh.js";
12
12
  import { _ as _12 } from "./chunks/LpCodeBlock-CSiOSPs8.js";
13
13
  import { default as default2 } from "./components/LpCommandPalette.vue.js";
14
- import { _ as _13 } from "./chunks/LpConfirmDialog-BA45MvRL.js";
14
+ import { _ as _13 } from "./chunks/LpConfirmDialog-WHkymac5.js";
15
15
  import { _ as _14 } from "./chunks/LpContextMenu-D0mzXqA7.js";
16
16
  import { _ as _15 } from "./chunks/LpDatePicker-dY2q4dr4.js";
17
17
  import { _ as _16 } from "./chunks/LpDisclosure-DP6yfgfM.js";
18
18
  import { _ as _17 } from "./chunks/LpDivider-CUX46KR0.js";
19
- import { _ as _18 } from "./chunks/LpDrawer-6n5Hd-iF.js";
19
+ import { _ as _18 } from "./chunks/LpDrawer-BHzCy-Aa.js";
20
20
  import { _ as _19 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
21
21
  import { _ as _20 } from "./chunks/LpEmptyState-CrfyRJUG.js";
22
- import { _ as _21 } from "./chunks/LpFileTree-D0DEpKof.js";
23
- import { ancestorIds, checkStateOf, checkedCount, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize } from "./components/fileTree.js";
22
+ import { _ as _21 } from "./chunks/LpFileTree-BQz8Rfgz.js";
23
+ import { ancestorIds, checkStateOf, checkedCount, computeStats, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize } from "./components/fileTree.js";
24
24
  import { _ as _22 } from "./chunks/LpFormField-Bn-ZwC6z.js";
25
25
  import { _ as _23 } from "./chunks/LpIcon-CCnX5_2j.js";
26
26
  import { _ as _24 } from "./chunks/LpInfraNode-C_PHcthC.js";
27
27
  import { _ as _25 } from "./chunks/LpInput-BauZRyzm.js";
28
28
  import { _ as _26 } from "./chunks/LpLink-DHwOEp4e.js";
29
- import { _ as _27 } from "./chunks/LpLogViewer-BBgaY38i.js";
30
- import { _ as _28 } from "./chunks/LpModal-B9CVVC5U.js";
31
- import { _ as _29 } from "./chunks/LpNotificationBell-B9VHks_D.js";
29
+ import { _ as _27 } from "./chunks/LpLogViewer-BvzRZPjs.js";
30
+ import { _ as _28 } from "./chunks/LpModal-DpBHQpbU.js";
31
+ import { _ as _29 } from "./chunks/LpNotificationBell-BtfRoL2i.js";
32
32
  import { _ as _30 } from "./chunks/LpNumberField-C5ar-OwE.js";
33
33
  import { _ as _31 } from "./chunks/LpOtpInput-DqaT0Z75.js";
34
34
  import { _ as _32 } from "./chunks/LpPagination-CeGuNe_q.js";
@@ -39,17 +39,17 @@ import { _ as _35 } from "./chunks/LpPopover-8268UmlC.js";
39
39
  import { _ as _36 } from "./chunks/LpProgress-BhI0IbjC.js";
40
40
  import { _ as _37 } from "./chunks/LpRadio-CL-pI0_O.js";
41
41
  import { _ as _38 } from "./chunks/LpRadioGroup-B4yDTmi6.js";
42
- import { _ as _39 } from "./chunks/LpScrollArea-BtnZCCBT.js";
42
+ import { _ as _39 } from "./chunks/LpScrollArea-De9DLpa1.js";
43
43
  import { _ as _40 } from "./chunks/LpSegmented-BmpanyAo.js";
44
44
  import { _ as _41 } from "./chunks/LpSelect-DznhPEWX.js";
45
45
  import { _ as _42 } from "./chunks/LpServiceNode-Dc-Ut5RS.js";
46
- import { _ as _43 } from "./chunks/LpSidebar-tZ2z-PnM.js";
46
+ import { _ as _43 } from "./chunks/LpSidebar-B11IDRQi.js";
47
47
  import { _ as _44 } from "./chunks/LpSkeleton-eSFIo4kD.js";
48
48
  import { _ as _45 } from "./chunks/LpSlider-B1SF3pRV.js";
49
49
  import { _ as _46 } from "./chunks/LpStat-CdbEFOvn.js";
50
50
  import { _ as _47 } from "./chunks/LpStepper-5dFTrW21.js";
51
51
  import { _ as _48 } from "./chunks/LpSwitch-v6fnccSM.js";
52
- import { _ as _49 } from "./chunks/LpTable-CzHVcr3v.js";
52
+ import { _ as _49 } from "./chunks/LpTable-CKGaUbEM.js";
53
53
  import { _ as _50 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
54
54
  import { _ as _51 } from "./chunks/LpTabs-D7aTVPOb.js";
55
55
  import { _ as _52 } from "./chunks/LpTextarea-CRAkhemZ.js";
@@ -169,6 +169,7 @@ export {
169
169
  e as bootstrapTheme,
170
170
  checkStateOf,
171
171
  checkedCount,
172
+ computeStats,
172
173
  countBlocks,
173
174
  countLeaves,
174
175
  f as dark,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -97,9 +97,16 @@ const items = computed<AutocompleteOption[]>(() =>
97
97
  ),
98
98
  )
99
99
 
100
+ // The field's text IS the value here, so a chosen option would otherwise filter
101
+ // the list down to itself: reopening the field showed a single row, and the
102
+ // other options only came back after erasing what was already selected. The
103
+ // filter therefore applies to what the user TYPES, and stops applying as soon
104
+ // as a value is picked (or the field is reopened untouched).
105
+ const typing = ref(false)
106
+
100
107
  const filtered = computed(() => {
101
108
  const q = text.value.trim().toLowerCase()
102
- if (!props.filter || !q) return items.value
109
+ if (!props.filter || !typing.value || !q) return items.value
103
110
  return items.value.filter((o) => {
104
111
  const hay = `${o.label ?? o.value} ${o.description ?? ""}`.toLowerCase()
105
112
  return hay.includes(q)
@@ -136,18 +143,28 @@ const anchorSize = {
136
143
  lg: "h-(--size-control-lg) text-sm",
137
144
  }
138
145
 
146
+ function onInput(event: Event) {
147
+ typing.value = true
148
+ text.value = (event.target as HTMLInputElement).value
149
+ }
150
+
139
151
  function onFocus() {
152
+ // Arriving at a field that already holds a value shows every option, not just
153
+ // the one it currently holds.
154
+ typing.value = false
140
155
  focused.value = true
141
156
  if (canOpen.value) open.value = true
142
157
  }
143
158
 
144
159
  function choose(opt: AutocompleteOption) {
160
+ typing.value = false
145
161
  emit("update:modelValue", opt.value)
146
162
  emit("select", opt.value)
147
163
  open.value = false
148
164
  }
149
165
 
150
166
  function clear() {
167
+ typing.value = false
151
168
  emit("update:modelValue", "")
152
169
  open.value = false
153
170
  }
@@ -188,7 +205,7 @@ function clear() {
188
205
  class="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted"
189
206
  @beforeinput="onBeforeInput"
190
207
  @paste="onPaste"
191
- @input="text = ($event.target as HTMLInputElement).value"
208
+ @input="onInput"
192
209
  @focus="onFocus"
193
210
  @blur="focused = false"
194
211
  />
@@ -21,11 +21,10 @@
21
21
  import { computed, ref, watch } from "vue"
22
22
  import {
23
23
  ancestorIds,
24
- checkStateOf,
24
+ computeStats,
25
25
  formatSize,
26
26
  sortNodes,
27
27
  subtreeIds,
28
- subtreeSize,
29
28
  type FileNode,
30
29
  } from "./fileTree"
31
30
  import LpEmptyState from "./LpEmptyState.vue"
@@ -135,19 +134,27 @@ watch(
135
134
  },
136
135
  )
137
136
 
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[]) {
137
+ /**
138
+ * Index every node by id, plus each node's parent id, in one pass per data
139
+ * change. The parent map turns "reconcile my ancestors" into a walk up the
140
+ * chain instead of a search through the whole tree on every tick.
141
+ */
142
+ const index = computed(() => {
143
+ const byId = new Map<string, FileNode>()
144
+ const parentOfId = new Map<string, string>()
145
+ function walk(list: FileNode[], parent?: string) {
142
146
  for (const node of list) {
143
- map.set(node.id, node)
144
- if (node.children) walk(node.children)
147
+ byId.set(node.id, node)
148
+ if (parent !== undefined) parentOfId.set(node.id, parent)
149
+ if (node.children) walk(node.children, node.id)
145
150
  }
146
151
  }
147
152
  walk(props.nodes)
148
- return map
153
+ return { byId, parentOfId }
149
154
  })
150
155
 
156
+ const byId = computed(() => index.value.byId)
157
+
151
158
  /**
152
159
  * What the ticked set adds up to. Directories are counted but not sized — their
153
160
  * bytes arrive through the files inside them, so summing both would double-count.
@@ -159,30 +166,47 @@ const summary = computed<FileTreeSummary>(() => {
159
166
  let dirs = 0
160
167
  let bytes = 0
161
168
  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
169
+
170
+ // Walk the tree, not the ticked set: a fully ticked directory can be counted
171
+ // whole and its subtree skipped, which keeps this linear in what's selected
172
+ // rather than in the size of the instance.
173
+ function walk(list: FileNode[]) {
174
+ for (const node of list) {
175
+ const stat = stats.value.get(node.id)
176
+ if (!stat || stat.state === "unchecked") continue
177
+
178
+ if (node.kind === "file") {
179
+ files++
180
+ if (node.size !== undefined) {
181
+ bytes += node.size
175
182
  sized = true
176
183
  }
184
+ continue
177
185
  }
178
- } else {
179
- files++
180
- if (node.size !== undefined) {
181
- bytes += node.size
186
+
187
+ dirs++
188
+ if (stat.state === "checked" && stat.size !== undefined) {
189
+ // Whole directory: take its rolled-up size and don't descend.
190
+ bytes += stat.size
182
191
  sized = true
192
+ files += stat.total
193
+ countDirs(node.children ?? [])
194
+ continue
183
195
  }
196
+ walk(node.children ?? [])
197
+ }
198
+ }
199
+
200
+ // A fully ticked directory still contributes its nested folders to the count.
201
+ function countDirs(list: FileNode[]) {
202
+ for (const node of list) {
203
+ if (node.kind !== "dir") continue
204
+ dirs++
205
+ countDirs(node.children ?? [])
184
206
  }
185
207
  }
208
+
209
+ walk(props.nodes)
186
210
  return {
187
211
  files,
188
212
  dirs,
@@ -212,13 +236,17 @@ function onCheck(node: FileNode, value: boolean) {
212
236
  if (value) next.add(id)
213
237
  else next.delete(id)
214
238
  }
215
- // Walk ancestors outward, re-deriving each from its children.
216
- for (const ancestorId of ancestorIds(props.nodes, node.id).reverse()) {
239
+ // Walk straight up the parent chain, re-deriving each ancestor from its
240
+ // children nearest first, so each one sees the level below already settled.
241
+ let ancestorId = index.value.parentOfId.get(node.id)
242
+ while (ancestorId !== undefined) {
217
243
  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)
244
+ if (ancestor?.children?.length) {
245
+ const all = ancestor.children.every((c) => c.disabled || next.has(c.id))
246
+ if (all) next.add(ancestorId)
247
+ else next.delete(ancestorId)
248
+ }
249
+ ancestorId = index.value.parentOfId.get(ancestorId)
222
250
  }
223
251
  setChecked(next)
224
252
  }
@@ -240,6 +268,10 @@ const requested = new Set<string>()
240
268
 
241
269
  const roots = computed(() => (props.sort ? sortNodes(props.nodes) : props.nodes))
242
270
 
271
+ // One pass for the whole tree; every row reads its entry by id instead of
272
+ // walking its own subtree on each render.
273
+ const stats = computed(() => computeStats(props.nodes, checkedIds.value))
274
+
243
275
  /** Rows currently visible, in render order — the axis the arrow keys walk. */
244
276
  const visible = computed(() => {
245
277
  const out: FileNode[] = []
@@ -334,8 +366,7 @@ function onSelect(node: FileNode) {
334
366
 
335
367
  /** Nearest expanded ancestor of `id`, or undefined at the top level. */
336
368
  function parentOf(id: string): FileNode | undefined {
337
- const trail = ancestorIds(props.nodes, id)
338
- const parentId = trail.at(-1)
369
+ const parentId = index.value.parentOfId.get(id)
339
370
  return parentId ? visible.value.find((n) => n.id === parentId) : undefined
340
371
  }
341
372
 
@@ -397,7 +428,7 @@ function onKeydown(e: KeyboardEvent) {
397
428
  // In checkable mode Space is the tick — the standard tree behaviour for a
398
429
  // multi-select tree — and Enter stays "open/activate".
399
430
  if (props.checkable) {
400
- onCheck(node, checkStateOf(node, checkedIds.value) !== "checked")
431
+ onCheck(node, stats.value.get(node.id)?.state !== "checked")
401
432
  break
402
433
  }
403
434
  onSelect(node)
@@ -487,6 +518,7 @@ defineExpose({ reveal, expandAll, collapseAll, checkAll, clearChecked, summary }
487
518
  :icon-size="iconSize"
488
519
  :checkable="checkable"
489
520
  :checked="checkedIds"
521
+ :stats="stats"
490
522
  :show-size="showSize"
491
523
  :show-modified="showModified"
492
524
  @select="onSelect"
@@ -11,14 +11,12 @@
11
11
  */
12
12
  import { computed } from "vue"
13
13
  import {
14
- checkedCount,
15
- checkStateOf,
16
14
  fileIcon,
17
15
  formatModified,
18
16
  formatSize,
19
17
  sortNodes,
20
- subtreeSize,
21
18
  type FileNode,
19
+ type NodeStats,
22
20
  } from "./fileTree"
23
21
  import LpCheckbox from "./LpCheckbox.vue"
24
22
  import LpContextMenu from "./LpContextMenu.vue"
@@ -38,6 +36,8 @@ const props = defineProps<{
38
36
  /** Render a tri-state checkbox per row (multi-select mode). */
39
37
  checkable: boolean
40
38
  checked: Set<string>
39
+ /** Per-node state, derived once by the root for the whole tree. */
40
+ stats: Map<string, NodeStats>
41
41
  showSize: boolean
42
42
  showModified: boolean
43
43
  }>()
@@ -71,15 +71,17 @@ const children = computed(() => {
71
71
  return props.sort ? sortNodes(list) : list
72
72
  })
73
73
 
74
+ const stat = computed(() => props.stats.get(props.node.id))
75
+
74
76
  const checkState = computed(() =>
75
- props.checkable ? checkStateOf(props.node, props.checked) : "unchecked",
77
+ props.checkable ? (stat.value?.state ?? "unchecked") : "unchecked",
76
78
  )
77
79
 
78
80
  // A directory shows the rolled-up size of what's under it, so a backup picker
79
81
  // can answer "how much is this folder" without expanding it.
80
82
  const sizeLabel = computed(() => {
81
83
  if (!props.showSize) return ""
82
- const bytes = subtreeSize(props.node)
84
+ const bytes = stat.value?.size
83
85
  return bytes === undefined ? "" : formatSize(bytes)
84
86
  })
85
87
 
@@ -90,7 +92,7 @@ const sizeLabel = computed(() => {
90
92
  */
91
93
  const countLabel = computed(() => {
92
94
  if (!props.checkable || !isDir.value) return ""
93
- const { checked, total } = checkedCount(props.node, props.checked)
95
+ const { checked = 0, total = 0 } = stat.value ?? {}
94
96
  return total && checked && checked < total ? `${checked} / ${total}` : ""
95
97
  })
96
98
 
@@ -213,7 +215,7 @@ function onActivate() {
213
215
  :class="node.disabled ? '' : isDir ? 'text-brand' : 'text-muted'"
214
216
  />
215
217
 
216
- <span class="min-w-0 flex-1 truncate">
218
+ <span class="min-w-0 flex-1 shrink-[2] truncate">
217
219
  <slot name="row" :node="node" :depth="depth" :expanded="isOpen" :selected="isSelected">
218
220
  {{ node.name }}
219
221
  </slot>
@@ -228,8 +230,12 @@ function onActivate() {
228
230
  {{ countLabel }}
229
231
  </span>
230
232
 
231
- <!-- Metadata columns: tabular figures so sizes line up down the tree. -->
232
- <span v-if="node.meta" class="shrink-0 text-xs text-muted">{{ node.meta }}</span>
233
+ <!-- Metadata columns: tabular figures so sizes line up down the tree.
234
+ `meta` is free text, so it truncates rather than pushing the size
235
+ column off the row. -->
236
+ <span v-if="node.meta" class="min-w-0 shrink truncate text-xs text-muted">
237
+ {{ node.meta }}
238
+ </span>
233
239
  <span
234
240
  v-if="modifiedLabel"
235
241
  class="shrink-0 text-xs tabular-nums text-muted/80"
@@ -268,6 +274,7 @@ function onActivate() {
268
274
  :icon-size="iconSize"
269
275
  :checkable="checkable"
270
276
  :checked="checked"
277
+ :stats="stats"
271
278
  :show-size="showSize"
272
279
  :show-modified="showModified"
273
280
  @select="emit('select', $event)"
@@ -551,9 +551,13 @@ const transitionProps = {
551
551
  <div
552
552
  class="relative overflow-hidden rounded-card border border-line bg-surface font-mono text-xs leading-relaxed"
553
553
  >
554
+ <!-- `instant`: this viewer decides for itself when a jump animates (see
555
+ rideToBottom) and restores scrollTop directly after prepending older
556
+ lines. A CSS smooth scroll would override both. -->
554
557
  <LpScrollArea
555
558
  ref="scrollRef"
556
559
  fade
560
+ instant
557
561
  :content-class="wrap ? '' : 'min-w-max'"
558
562
  :style="{ height }"
559
563
  @scroll="onScroll"
@@ -20,16 +20,21 @@ import {
20
20
  // component root, since items live inside reka's viewport.
21
21
  // `barInsetTop` (any CSS length) pulls the vertical scrollbar down by that much,
22
22
  // so it doesn't run under a sticky header pinned at the top of the viewport.
23
- // `smooth` eases wheel and keyboard scrolling. Opt-in rather than default:
24
- // it also applies to `scrollTo({ behavior: "auto" })`, which consumers that
25
- // drive the scroll themselves (a log tail jumping to the newest line) rely on
26
- // being instant.
27
- const props = defineProps<{
28
- fade?: boolean
29
- smooth?: boolean
30
- contentClass?: string
31
- barInsetTop?: string
32
- }>()
23
+ // Wheel and keyboard scrolling ease by default. `instant` opts back out, and
24
+ // any consumer that DRIVES the scroll itself needs it: CSS scroll-behaviour
25
+ // also governs programmatic scrolling, overriding `scrollTo({behavior:"auto"})`
26
+ // and animating even a plain `scrollTop = n`. A log tail pinning itself to the
27
+ // newest line, or restoring a position after prepending rows, has to land
28
+ // instantly — see LpLogViewer.
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ fade?: boolean
32
+ instant?: boolean
33
+ contentClass?: string
34
+ barInsetTop?: string
35
+ }>(),
36
+ { instant: false },
37
+ )
33
38
 
34
39
  // Re-emit the native scroll event of the underlying viewport so consumers that
35
40
  // drive scroll-following (e.g. log tails) can track position. The listener is
@@ -91,7 +96,7 @@ const barFade =
91
96
  ref="viewportRef"
92
97
  class="size-full min-w-0 [&>div]:!block [&>div]:!min-w-0"
93
98
  :class="[
94
- smooth ? 'scroll-smooth motion-reduce:scroll-auto' : '',
99
+ instant ? '' : 'scroll-smooth motion-reduce:scroll-auto',
95
100
  fade ? '[mask-image:linear-gradient(to_bottom,transparent_0,black_14px,black_calc(100%-14px),transparent_100%)]' : '',
96
101
  ]"
97
102
  @scroll.passive="$emit('scroll', $event)"
@@ -221,6 +221,81 @@ export function checkedCount(
221
221
  return { checked: count, total }
222
222
  }
223
223
 
224
+ /** Everything a row needs to render, precomputed once per node. */
225
+ export interface NodeStats {
226
+ state: CheckState
227
+ /** Ticked files in the subtree, and how many there are in total. */
228
+ checked: number
229
+ total: number
230
+ /** Rolled-up subtree size, or undefined when nothing carries one. */
231
+ size?: number
232
+ }
233
+
234
+ /**
235
+ * Derive every row's state in ONE post-order pass over the tree, keyed by node
236
+ * id. Calling `checkStateOf`/`subtreeSize`/`checkedCount` per row instead makes
237
+ * each row walk its own subtree, which is quadratic on a deep tree and shows up
238
+ * as a stutter the moment a directory holds a few thousand files.
239
+ */
240
+ export function computeStats(
241
+ nodes: FileNode[],
242
+ checked: ReadonlySet<string>,
243
+ ): Map<string, NodeStats> {
244
+ const stats = new Map<string, NodeStats>()
245
+
246
+ function visit(node: FileNode): NodeStats {
247
+ if (node.kind === "file" || !node.children?.length) {
248
+ // A leaf, or a directory whose children aren't loaded: fall back to its
249
+ // own membership so an unexpanded folder can still be ticked whole.
250
+ const isChecked = checked.has(node.id)
251
+ const self: NodeStats = {
252
+ state: isChecked ? "checked" : "unchecked",
253
+ checked: node.kind === "file" && isChecked ? 1 : 0,
254
+ total: node.kind === "file" ? 1 : 0,
255
+ size: node.size,
256
+ }
257
+ stats.set(node.id, self)
258
+ return self
259
+ }
260
+
261
+ let all = true
262
+ let any = false
263
+ let count = 0
264
+ let total = 0
265
+ let bytes = 0
266
+ let sized = node.size !== undefined
267
+
268
+ for (const child of node.children) {
269
+ const childStats = visit(child)
270
+ if (childStats.state === "checked") any = true
271
+ else if (childStats.state === "indeterminate") {
272
+ any = true
273
+ all = false
274
+ } else all = false
275
+ count += childStats.checked
276
+ total += childStats.total
277
+ if (childStats.size !== undefined) {
278
+ bytes += childStats.size
279
+ sized = true
280
+ }
281
+ }
282
+
283
+ const self: NodeStats = {
284
+ state: all ? "checked" : any ? "indeterminate" : "unchecked",
285
+ checked: count,
286
+ total,
287
+ // An explicit size on the directory wins: a server-side listing usually
288
+ // knows the real figure without the children being loaded.
289
+ size: node.size ?? (sized ? bytes : undefined),
290
+ }
291
+ stats.set(node.id, self)
292
+ return self
293
+ }
294
+
295
+ for (const node of nodes) visit(node)
296
+ return stats
297
+ }
298
+
224
299
  /** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
225
300
  export function ancestorIds(nodes: FileNode[], id: string): string[] {
226
301
  const path: string[] = []
package/src/index.ts CHANGED
@@ -27,11 +27,12 @@ export type { MenuItem } from "./components/LpDropdownMenu.vue"
27
27
  export { default as LpEmptyState } from "./components/LpEmptyState.vue"
28
28
  export { default as LpFileTree } from "./components/LpFileTree.vue"
29
29
  export type { FileTreeSummary } from "./components/LpFileTree.vue"
30
- export type { CheckState, FileNode } from "./components/fileTree"
30
+ export type { CheckState, FileNode, NodeStats } from "./components/fileTree"
31
31
  export {
32
32
  ancestorIds,
33
33
  checkedCount,
34
34
  checkStateOf,
35
+ computeStats,
35
36
  fileIcon,
36
37
  formatModified,
37
38
  formatSize,