vitepress-plugin-filelist 0.6.2 → 0.7.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.
@@ -92,6 +92,28 @@ interface FileListLabels {
92
92
  unlockFolder: string;
93
93
  /** @deprecated Network failures now append `corsHint`. */
94
94
  networkHint: string;
95
+ /** Deep search: the row at the end of a filtered list. `{count}` where noted */
96
+ deepHint: string;
97
+ deepSearch: string;
98
+ deepRunning: string;
99
+ deepStop: string;
100
+ deepDone: string;
101
+ deepCapped: string;
102
+ deepContinue: string;
103
+ deepSkipped: string;
104
+ deepFailed: string;
105
+ /** Beside a locked folder shown in deep search results. */
106
+ deepLocked: string;
107
+ /** The × in the filter box. */
108
+ clearFilter: string;
109
+ /** Markdown / HTML preview: the source / rendered switch. */
110
+ viewMode: string;
111
+ viewSource: string;
112
+ viewRendered: string;
113
+ /** Above a rendered HTML file. */
114
+ htmlSandboxNote: string;
115
+ /** Stands in for a Markdown image with a relative path. `{alt}` */
116
+ imageOmitted: string;
95
117
  /** Title of the dialog when a batch ZIP fails (the reason is the text). */
96
118
  batchFailedTitle: string;
97
119
  batchFailedHint: string;
@@ -156,6 +178,16 @@ interface FileListProps {
156
178
  * everything. Data is still loaded in full; only rendering is limited.
157
179
  */
158
180
  pageSize?: number;
181
+ /**
182
+ * Deep search loads at most this many folders per click (“Continue” goes on).
183
+ * Defaults to `500`.
184
+ */
185
+ deepSearchLimit?: number;
186
+ /**
187
+ * `manual` (default): “Try deep search” starts deep search mode.
188
+ * `auto`: typing a keyword starts it at once (for small mounts).
189
+ */
190
+ deepSearch?: 'manual' | 'auto';
159
191
  /**
160
192
  * Browsers that must build the ZIP in memory (Firefox, Safari) ask first above
161
193
  * this many MB. Defaults to 500 (200 on touch devices); `0` never asks.
@@ -192,6 +224,8 @@ declare const _default: import("vue").DefineComponent<FileListProps, {}, {}, {},
192
224
  title: string;
193
225
  rememberPassword: boolean;
194
226
  pageSize: number;
227
+ deepSearchLimit: number;
228
+ deepSearch: "manual" | "auto";
195
229
  locale: FileListLocale;
196
230
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
197
231
  declare global {
@@ -310,7 +344,7 @@ export declare function previewKind(name: string): PreviewKind;
310
344
  export declare function totalSize(items: Array<{
311
345
  size?: number;
312
346
  }>): number;
313
- export declare function compareNodes(a: SortableNode, b: SortableNode, key: SortKey, ascending?: boolean): number;
347
+ export declare function compareNodes(a: SortableNode, b: SortableNode, key: SortKey, ascending?: boolean, locale?: string): number;
314
348
  interface IframePreview {
315
349
  label: string;
316
350
  scheme: string;
@@ -179,12 +179,29 @@ function previewKind(name) {
179
179
  function totalSize(items) {
180
180
  return items.reduce((sum, item) => sum + (Number.isFinite(item.size) && (item.size ?? 0) > 0 ? item.size : 0), 0);
181
181
  }
182
- function compareNodes(a, b, key, ascending = true) {
182
+ const collators = /* @__PURE__ */ new Map();
183
+ /**
184
+ * Name order for a UI locale: pinyin for Chinese, alphabetical for English,
185
+ * with numbers in natural order (“2” before “10”) either way.
186
+ */
187
+ function collatorFor(locale) {
188
+ const tag = locale.toLowerCase().startsWith("zh") ? "zh-Hans-CN" : "en";
189
+ let collator = collators.get(tag);
190
+ if (!collator) {
191
+ collator = new Intl.Collator(tag, {
192
+ numeric: true,
193
+ sensitivity: "base"
194
+ });
195
+ collators.set(tag, collator);
196
+ }
197
+ return collator;
198
+ }
199
+ function compareNodes(a, b, key, ascending = true, locale = "zh-CN") {
183
200
  if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
184
201
  let result = 0;
185
202
  if (key === "size") result = a.size - b.size;
186
203
  else if (key === "modified") result = new Date(a.modified).getTime() - new Date(b.modified).getTime();
187
- else result = a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true });
204
+ else result = collatorFor(locale).compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
188
205
  return ascending ? result : -result;
189
206
  }
190
207
  /**
@@ -852,6 +869,22 @@ const fileListLocales = {
852
869
  forgetPassword: "忘记",
853
870
  unlockFolder: "输入 {name} 的密码",
854
871
  networkHint: "可能被跨域拦截:请检查 OpenList 的 cors.allow_origins,或 OpenList-Worker 的 ALLOW_URLS。",
872
+ deepHint: "只筛选了已加载的内容,",
873
+ deepSearch: "尝试深度搜索",
874
+ deepRunning: "正在深度搜索 · 已查找 {count} 个文件夹",
875
+ deepStop: "停止",
876
+ deepDone: "已搜索全部 {count} 个文件夹",
877
+ deepCapped: "已查找 {count} 个文件夹,还有未搜索的子目录",
878
+ deepContinue: "继续",
879
+ deepSkipped: "跳过 {count} 个加密文件夹",
880
+ deepFailed: "{count} 个文件夹读取失败",
881
+ deepLocked: "未搜索(需要密码)",
882
+ clearFilter: "清除筛选",
883
+ viewMode: "显示方式",
884
+ viewSource: "源码",
885
+ viewRendered: "渲染",
886
+ htmlSandboxNote: "已禁用脚本;网页引用的相对路径资源不会加载",
887
+ imageOmitted: "[图片:{alt}]",
855
888
  batchFailedTitle: "打包失败",
856
889
  batchFailedHint: "可以减少勾选的文件后重试。",
857
890
  downloadStarted: "已交给浏览器下载:{name}",
@@ -932,6 +965,22 @@ const fileListLocales = {
932
965
  forgetPassword: "Forget",
933
966
  unlockFolder: "Enter the password for {name}",
934
967
  networkHint: "Possibly blocked by CORS: check cors.allow_origins in OpenList, or ALLOW_URLS in OpenList-Worker.",
968
+ deepHint: "Only loaded folders are filtered. ",
969
+ deepSearch: "Try deep search",
970
+ deepRunning: "Searching · {count} folders checked",
971
+ deepStop: "Stop",
972
+ deepDone: "Searched all {count} folders",
973
+ deepCapped: "{count} folders checked; some sub-folders remain",
974
+ deepContinue: "Continue",
975
+ deepSkipped: "{count} locked folders skipped",
976
+ deepFailed: "{count} folders could not be read",
977
+ deepLocked: "Not searched (password needed)",
978
+ clearFilter: "Clear filter",
979
+ viewMode: "View",
980
+ viewSource: "Source",
981
+ viewRendered: "Rendered",
982
+ htmlSandboxNote: "Scripts are disabled; resources at relative paths do not load",
983
+ imageOmitted: "[Image: {alt}]",
935
984
  batchFailedTitle: "Could not create the ZIP",
936
985
  batchFailedHint: "Try again with fewer files selected.",
937
986
  downloadStarted: "Downloading {name} in your browser",
@@ -960,6 +1009,45 @@ function resolveLabels(locale = "zh-CN", overrides) {
960
1009
  ...overrides
961
1010
  };
962
1011
  }
1012
+ // src/client/markdown.ts
1013
+ /**
1014
+ * Markdown → safe HTML for the preview dialog. Loaded on demand, so pages that
1015
+ * never render Markdown do not download markdown-it or DOMPurify.
1016
+ *
1017
+ * - Raw HTML in the file is allowed, then sanitised (no scripts, handlers, iframes...)
1018
+ * - Only absolute http(s) / mailto links stay clickable, opening in a new tab;
1019
+ * relative links (to other files in the share) become plain text
1020
+ * - Images with a relative source are replaced by their alt text, because the
1021
+ * share's relative paths do not resolve on the documentation site
1022
+ */
1023
+ async function renderMarkdown(source, imageLabel) {
1024
+ const [{ default: MarkdownIt }, { default: DOMPurify }] = await Promise.all([import("markdown-it"), import("dompurify")]);
1025
+ const probe = DOMPurify.isSupported ? DOMPurify.sanitize("<img src=\"x\" onerror=\"1\"><script>1<\/script>") : "<script";
1026
+ if (/<script|onerror/i.test(probe)) throw new Error("HTML sanitiser unavailable");
1027
+ const html = new MarkdownIt({
1028
+ html: true,
1029
+ linkify: true
1030
+ }).render(source);
1031
+ const template = document.createElement("template");
1032
+ template.innerHTML = DOMPurify.sanitize(html);
1033
+ const fragment = template.content;
1034
+ for (const link of fragment.querySelectorAll("a")) {
1035
+ const href = link.getAttribute("href") ?? "";
1036
+ if (/^(https?:|mailto:)/i.test(href)) {
1037
+ link.setAttribute("target", "_blank");
1038
+ link.setAttribute("rel", "noopener noreferrer");
1039
+ } else link.replaceWith(...link.childNodes);
1040
+ }
1041
+ for (const image of fragment.querySelectorAll("img")) {
1042
+ const src = image.getAttribute("src") ?? "";
1043
+ if (/^(https?:|data:image\/)/i.test(src)) continue;
1044
+ const placeholder = document.createElement("span");
1045
+ placeholder.className = "fl-md-image";
1046
+ placeholder.textContent = imageLabel(image.getAttribute("alt") || src.split("/").pop() || "");
1047
+ image.replaceWith(placeholder);
1048
+ }
1049
+ return template.innerHTML;
1050
+ }
963
1051
  // src/client/PreviewDialog.vue
964
1052
  const _sfc_main$1 = /*@__PURE__*/ defineComponent({
965
1053
  __name: "PreviewDialog",
@@ -994,7 +1082,9 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
994
1082
  /**
995
1083
  * File preview in a native <dialog>, without leaving the page.
996
1084
  *
997
- * image / video / audio: native elements; text: the first `maxTextBytes`
1085
+ * image / video / audio: native elements; text: the first `maxTextBytes`,
1086
+ * with Markdown and HTML switchable between source (default) and rendered
1087
+ * (Markdown sanitised in VitePress' doc style, HTML in a script-less sandbox);
998
1088
  * through a Range request; pdf: the first matching OpenList `iframe_previews`
999
1089
  * viewer, else the browser's reader through a Blob. Anything else (Office
1000
1090
  * included), failures, oversized PDFs and demo mode: file info + download.
@@ -1012,8 +1102,18 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1012
1102
  const blobUrl = ref("");
1013
1103
  const rotation = ref(0);
1014
1104
  const actualSize = ref(false);
1105
+ /** Markdown / HTML: show the source or render it. Kept while the page is open. */
1106
+ const view = ref("source");
1107
+ const renderedHtml = ref("");
1015
1108
  let controller;
1016
1109
  let loadSeq = 0;
1110
+ /** Text files that can also be rendered. */
1111
+ const renderAs = computed(() => {
1112
+ const name = props.file?.name ?? "";
1113
+ if (/\.(md|markdown)$/i.test(name)) return "markdown";
1114
+ if (/\.html?$/i.test(name)) return "html";
1115
+ return null;
1116
+ });
1017
1117
  const tooLarge = computed(() => kind.value === "pdf" && !props.viewer && (props.file?.size ?? 0) > props.maxPdfBytes);
1018
1118
  const mode = computed(() => {
1019
1119
  if (props.demo) return "demo";
@@ -1023,6 +1123,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1023
1123
  return kind.value;
1024
1124
  });
1025
1125
  const busy = computed(() => !props.demo && kind.value !== "none" && !props.error && !bodyFailed.value && (props.resolving || loadingBody.value || !props.src && !tooLarge.value));
1126
+ const canRender = computed(() => !!renderAs.value && mode.value === "text" && !busy.value);
1026
1127
  const infoMessage = computed(() => {
1027
1128
  const file = props.file;
1028
1129
  if (!file) return "";
@@ -1043,6 +1144,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1043
1144
  loadingBody.value = false;
1044
1145
  bodyFailed.value = false;
1045
1146
  textContent.value = "";
1147
+ renderedHtml.value = "";
1046
1148
  truncated.value = false;
1047
1149
  rotation.value = 0;
1048
1150
  actualSize.value = false;
@@ -1124,6 +1226,20 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1124
1226
  props.error,
1125
1227
  props.demo
1126
1228
  ], () => void loadBody(), { immediate: true });
1229
+ watch(() => [
1230
+ view.value,
1231
+ renderAs.value,
1232
+ textContent.value
1233
+ ], async ([current, as, text]) => {
1234
+ if (current !== "rendered" || as !== "markdown" || !text) return;
1235
+ const seq = loadSeq;
1236
+ try {
1237
+ const html = await renderMarkdown(text, (alt) => t("imageOmitted", { alt }));
1238
+ if (seq === loadSeq) renderedHtml.value = html;
1239
+ } catch {
1240
+ if (seq === loadSeq) view.value = "source";
1241
+ }
1242
+ });
1127
1243
  watch(() => props.open, (open) => {
1128
1244
  const el = dialog.value;
1129
1245
  if (!el) return;
@@ -1145,7 +1261,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1145
1261
  }, _attrs))}>`);
1146
1262
  if (__props.open && __props.file) {
1147
1263
  _push(`<div class="fl-preview-panel"><header class="fl-preview-head"><span class="fl-type"${ssrRenderAttr("data-kind", unref(fileKind)(__props.file.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(__props.file.name) || "FILE")}</span><div class="fl-preview-title"><span class="fl-preview-name">${ssrInterpolate(__props.file.name)}</span><span class="fl-preview-meta">${ssrInterpolate(unref(formatSize)(__props.file.size))} · ${ssrInterpolate(unref(formatDate)(__props.file.modified))}</span></div>`);
1148
- if (__props.total > 1 || mode.value === "image" && !busy.value) {
1264
+ if (__props.total > 1 || mode.value === "image" && !busy.value || canRender.value) {
1149
1265
  _push(`<div class="fl-preview-tools fl-preview-nav">`);
1150
1266
  if (__props.total > 1) {
1151
1267
  _push(`<!--[--><button class="fl-preview-tool" type="button"${ssrRenderAttr("aria-label", t("previous"))}${ssrRenderAttr("title", t("previous"))}>`);
@@ -1173,6 +1289,13 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1173
1289
  }, null, _parent));
1174
1290
  _push(`</button><!--]-->`);
1175
1291
  } else _push(`<!---->`);
1292
+ if (canRender.value) {
1293
+ _push(`<span class="fl-preview-view" role="group"${ssrRenderAttr("aria-label", t("viewMode"))}><!--[-->`);
1294
+ ssrRenderList(["source", "rendered"], (option) => {
1295
+ _push(`<button class="fl-preview-view-btn" type="button"${ssrRenderAttr("aria-pressed", view.value === option)}>${ssrInterpolate(t(option === "source" ? "viewSource" : "viewRendered"))}</button>`);
1296
+ });
1297
+ _push(`<!--]--></span>`);
1298
+ } else _push(`<!---->`);
1176
1299
  _push(`</div>`);
1177
1300
  } else _push(`<!---->`);
1178
1301
  _push(`<div class="fl-preview-tools fl-preview-actions">`);
@@ -1195,10 +1318,17 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1195
1318
  else if (mode.value === "video") _push(`<video class="fl-preview-video"${ssrRenderAttr("src", __props.src)} controls playsinline preload="metadata"></video>`);
1196
1319
  else if (mode.value === "audio") _push(`<audio class="fl-preview-audio"${ssrRenderAttr("src", __props.src)} controls preload="metadata"></audio>`);
1197
1320
  else if (mode.value === "text") {
1198
- _push(`<div class="fl-preview-text">`);
1321
+ _push(`<div class="${ssrRenderClass([{ "fl-preview-rendered": renderAs.value && view.value === "rendered" }, "fl-preview-text"])}">`);
1199
1322
  if (truncated.value) _push(`<p class="fl-preview-note">${ssrInterpolate(t("textTruncated", { size: unref(formatSize)(__props.maxTextBytes) }))}</p>`);
1200
1323
  else _push(`<!---->`);
1201
- _push(`<pre>${ssrInterpolate(textContent.value)}</pre></div>`);
1324
+ if (renderAs.value === "markdown" && view.value === "rendered") {
1325
+ _push(`<!--[--><!-- sanitised by renderMarkdown (DOMPurify) -->`);
1326
+ if (renderedHtml.value) _push(`<div class="vp-doc fl-md">${renderedHtml.value ?? ""}</div>`);
1327
+ else _push(`<div class="fl-preview-status" role="status">${ssrInterpolate(t("loading"))}</div>`);
1328
+ _push(`<!--]-->`);
1329
+ } else if (renderAs.value === "html" && view.value === "rendered") _push(`<!--[--><p class="fl-preview-note">${ssrInterpolate(t("htmlSandboxNote"))}</p><!-- empty sandbox: no scripts, no forms, no popups, an opaque origin --><iframe class="fl-preview-html" sandbox="" referrerpolicy="no-referrer"${ssrRenderAttr("srcdoc", textContent.value)}${ssrRenderAttr("title", __props.file.name)}></iframe><!--]-->`);
1330
+ else _push(`<pre>${ssrInterpolate(textContent.value)}</pre>`);
1331
+ _push(`</div>`);
1202
1332
  } else if (mode.value === "pdf") _push(`<iframe class="fl-preview-frame"${ssrRenderAttr("src", __props.viewer || blobUrl.value)}${ssrRenderAttr("title", __props.file.name)}></iframe>`);
1203
1333
  else {
1204
1334
  _push(`<div class="fl-preview-info"><span class="fl-type fl-preview-badge"${ssrRenderAttr("data-kind", unref(fileKind)(__props.file.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(__props.file.name) || "FILE")}</span><p class="fl-preview-message">${ssrInterpolate(infoMessage.value)}</p>`);
@@ -1247,9 +1377,9 @@ function toNodes(items, parent, password) {
1247
1377
  zipPath: [...parent.zipPath, item.name]
1248
1378
  }));
1249
1379
  }
1250
- function sortTree(nodes, key, ascending) {
1251
- nodes.sort((a, b) => compareNodes(a, b, key, ascending));
1252
- for (const node of nodes) if (node.children.length) sortTree(node.children, key, ascending);
1380
+ function sortTree(nodes, key, ascending, locale) {
1381
+ nodes.sort((a, b) => compareNodes(a, b, key, ascending, locale));
1382
+ for (const node of nodes) if (node.children.length) sortTree(node.children, key, ascending, locale);
1253
1383
  }
1254
1384
  /** Loaded files at or under a node. */
1255
1385
  function descendantFiles(node) {
@@ -1436,6 +1566,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1436
1566
  default: true
1437
1567
  },
1438
1568
  pageSize: { default: 200 },
1569
+ deepSearchLimit: { default: 500 },
1570
+ deepSearch: { default: "manual" },
1439
1571
  zipWarnSize: {},
1440
1572
  zipMaxSize: {},
1441
1573
  locale: { default: "zh-CN" },
@@ -1500,7 +1632,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1500
1632
  return isNetworkError(error) ? `${message} ${t("corsHint")}` : message;
1501
1633
  }
1502
1634
  function sortNodes(nodes) {
1503
- sortTree(nodes, sortKey.value, sortAsc.value);
1635
+ sortTree(nodes, sortKey.value, sortAsc.value, props.locale);
1504
1636
  }
1505
1637
  function focusUnlock(path) {
1506
1638
  nextTick(() => {
@@ -1532,7 +1664,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1532
1664
  * Load a folder's children. A password error locks the folder instead of
1533
1665
  * toasting; `prompt` also shows its same-row input (and focuses it).
1534
1666
  */
1535
- async function loadChildren(node, prompt = false) {
1667
+ async function loadChildren(node, prompt = false, quiet = false) {
1536
1668
  if (node.loaded) return true;
1537
1669
  if (node.lock) {
1538
1670
  if (prompt) startPrompt(node, prompt === "focus");
@@ -1548,7 +1680,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1548
1680
  node.expanded = false;
1549
1681
  node.lock = newLock(false, passwords.dropStale(rememberedAt) ? "stale" : "");
1550
1682
  if (prompt) startPrompt(node, prompt === "focus");
1551
- } else showToast(failureMessage("folderFailed", error));
1683
+ } else if (!quiet) showToast(failureMessage("folderFailed", error));
1552
1684
  return false;
1553
1685
  } finally {
1554
1686
  node.loading = false;
@@ -1595,6 +1727,21 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1595
1727
  zipPath: [item.is_dir ? name : item.name || sourceName(path)]
1596
1728
  };
1597
1729
  }
1730
+ /** Run at most `size` tasks at once. */
1731
+ function concurrency(size) {
1732
+ let active = 0;
1733
+ const waiting = [];
1734
+ return async (task) => {
1735
+ if (active >= size) await new Promise((resolve) => waiting.push(resolve));
1736
+ active += 1;
1737
+ try {
1738
+ return await task();
1739
+ } finally {
1740
+ active -= 1;
1741
+ waiting.shift()?.();
1742
+ }
1743
+ };
1744
+ }
1598
1745
  class RootLockedError extends Error {
1599
1746
  rememberedAt;
1600
1747
  constructor(rememberedAt) {
@@ -1606,7 +1753,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1606
1753
  * Build a fresh tree. `expandedPaths` re-opens those folders (refresh keeps
1607
1754
  * what was open); without it the configured `expanded` defaults apply.
1608
1755
  */
1609
- async function buildTree(expandedPaths) {
1756
+ async function buildTree(expandedPaths, loadedPaths) {
1610
1757
  let nodes;
1611
1758
  if (props.sources.length) nodes = await Promise.all(props.sources.map((source) => loadSourceRoot(source, expandedPaths ? expandedPaths.has(source.path) : source.expanded ?? props.expanded)));
1612
1759
  else {
@@ -1617,13 +1764,16 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1617
1764
  throw isPasswordError(error) ? new RootLockedError(rememberedAt) : error;
1618
1765
  }
1619
1766
  }
1767
+ const limit = concurrency(4);
1620
1768
  const open = async (list) => {
1621
- for (const node of list) {
1622
- const wanted = node.expanded || expandedPaths?.has(node.path);
1623
- if (!node.is_dir || node.lock || !wanted) continue;
1624
- node.expanded = await loadChildren(node, "show");
1625
- if (node.expanded && expandedPaths) await open(node.children);
1626
- }
1769
+ await Promise.all(list.map(async (node) => {
1770
+ const wanted = !!(node.expanded || expandedPaths?.has(node.path));
1771
+ const keep = !!loadedPaths?.has(node.path);
1772
+ if (!node.is_dir || node.lock || !(wanted || keep)) return;
1773
+ const ok = await limit(() => loadChildren(node, wanted ? "show" : false, !wanted));
1774
+ node.expanded = wanted && ok;
1775
+ if (ok && (expandedPaths || loadedPaths)) await open(node.children);
1776
+ }));
1627
1777
  };
1628
1778
  await open(nodes);
1629
1779
  return nodes;
@@ -1635,6 +1785,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1635
1785
  }
1636
1786
  /** First load, or a retry after an error: shows the loading state. */
1637
1787
  async function loadRoot() {
1788
+ stopDeepSearch();
1638
1789
  refreshSeq += 1;
1639
1790
  refreshing.value = false;
1640
1791
  loadingRoot.value = true;
@@ -1692,7 +1843,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1692
1843
  const rows = computed(() => {
1693
1844
  const out = [];
1694
1845
  const query = keyword.value.trim().toLowerCase();
1695
- const matches = (node) => !query || node.name.toLowerCase().includes(query) || node.is_dir && node.children.some(matches);
1846
+ const matches = (node) => !query || node.name.toLowerCase().includes(query) || node.is_dir && (node.children.some(matches) || deepMode.value && !!node.lock);
1696
1847
  const walk = (nodes, key, depth) => {
1697
1848
  const list = nodes.filter(matches);
1698
1849
  const limit = query ? list.length : Math.min(list.length, limitOf(key));
@@ -1711,6 +1862,163 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1711
1862
  return out;
1712
1863
  });
1713
1864
  const visible = computed(() => rows.value.filter((row) => !isMore(row)));
1865
+ /** A name split around the filter keyword, for highlighting. */
1866
+ function nameParts(name) {
1867
+ const query = keyword.value.trim().toLowerCase();
1868
+ if (!query) return [{
1869
+ text: name,
1870
+ hit: false
1871
+ }];
1872
+ const parts = [];
1873
+ const lower = name.toLowerCase();
1874
+ let from = 0;
1875
+ for (let at = lower.indexOf(query); at !== -1; at = lower.indexOf(query, from)) {
1876
+ if (at > from) parts.push({
1877
+ text: name.slice(from, at),
1878
+ hit: false
1879
+ });
1880
+ parts.push({
1881
+ text: name.slice(at, at + query.length),
1882
+ hit: true
1883
+ });
1884
+ from = at + query.length;
1885
+ }
1886
+ if (from < name.length) parts.push({
1887
+ text: name.slice(from),
1888
+ hit: false
1889
+ });
1890
+ return parts;
1891
+ }
1892
+ /**
1893
+ * The filter only sees loaded folders. Deep search loads the rest under the
1894
+ * mounted paths (four at a time, `deepSearchLimit` per run) so matches appear
1895
+ * in place, in their folders, like any other row. Locked folders are skipped.
1896
+ */
1897
+ const deep = reactive({
1898
+ running: false,
1899
+ searched: 0,
1900
+ failed: 0,
1901
+ capped: false,
1902
+ done: false
1903
+ });
1904
+ /**
1905
+ * Deep search mode: entered by “Try deep search” (or at once with
1906
+ * `deep-search="auto"`), left by clearing the filter. While on, new keywords,
1907
+ * refreshes and newly unlocked folders are searched without asking again.
1908
+ */
1909
+ const deepMode = ref(false);
1910
+ let deepSeq = 0;
1911
+ /** Folders that are known but not loaded yet (locked ones excluded). */
1912
+ function unloadedFolders() {
1913
+ const out = [];
1914
+ const walk = (nodes) => {
1915
+ for (const node of nodes) {
1916
+ if (!node.is_dir || node.lock) continue;
1917
+ if (!node.loaded) out.push(node);
1918
+ else walk(node.children);
1919
+ }
1920
+ };
1921
+ walk(roots.value);
1922
+ return out;
1923
+ }
1924
+ const hasUnloaded = computed(() => unloadedFolders().length > 0);
1925
+ const deepRow = computed(() => {
1926
+ if (!keyword.value.trim()) return null;
1927
+ if (deep.running) return "running";
1928
+ if (deep.capped) return "capped";
1929
+ if (!deepMode.value) return hasUnloaded.value ? "idle" : null;
1930
+ return deep.done ? "done" : null;
1931
+ });
1932
+ /** “Try deep search”, “Continue”, and the automatic runs of deep search mode. */
1933
+ async function deepSearch() {
1934
+ deepMode.value = true;
1935
+ const seq = ++deepSeq;
1936
+ const limit = Math.max(1, Math.floor(props.deepSearchLimit ?? 500));
1937
+ const runStart = deep.searched;
1938
+ Object.assign(deep, {
1939
+ running: true,
1940
+ capped: false,
1941
+ done: false
1942
+ });
1943
+ let wave = unloadedFolders();
1944
+ while (wave.length && seq === deepSeq) {
1945
+ const next = [];
1946
+ const queue = [...wave];
1947
+ const worker = async () => {
1948
+ for (let node = queue.shift(); node && seq === deepSeq; node = queue.shift()) {
1949
+ if (deep.searched - runStart >= limit) return;
1950
+ deep.searched += 1;
1951
+ if (!await loadChildren(node, false, true)) {
1952
+ if (!node.lock) deep.failed += 1;
1953
+ continue;
1954
+ }
1955
+ for (const child of node.children) if (child.is_dir && !child.loaded && !child.lock) next.push(child);
1956
+ }
1957
+ };
1958
+ await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
1959
+ if (deep.searched - runStart >= limit) break;
1960
+ wave = next;
1961
+ }
1962
+ if (seq !== deepSeq) return;
1963
+ deep.running = false;
1964
+ deep.capped = hasUnloaded.value && deep.searched - runStart >= limit;
1965
+ deep.done = !deep.capped;
1966
+ }
1967
+ /** Pause a run (internal: refresh, reload). */
1968
+ function stopDeepSearch() {
1969
+ deepSeq += 1;
1970
+ deep.running = false;
1971
+ }
1972
+ watch(keyword, (value) => {
1973
+ if (value.trim()) {
1974
+ if (props.deepSearch === "auto") deepMode.value = true;
1975
+ return;
1976
+ }
1977
+ stopDeepSearch();
1978
+ deepMode.value = false;
1979
+ Object.assign(deep, {
1980
+ searched: 0,
1981
+ failed: 0,
1982
+ capped: false,
1983
+ done: false
1984
+ });
1985
+ });
1986
+ watch(() => deepMode.value && hasUnloaded.value && !deep.running && !deep.capped && !refreshing.value, (ready) => {
1987
+ if (ready) deepSearch();
1988
+ });
1989
+ /**
1990
+ * Counted from the tree, so a refresh that re-reads the same folders shows the
1991
+ * same numbers: folders read (or found locked), and the locked ones.
1992
+ */
1993
+ const folderCounts = computed(() => {
1994
+ const counts = {
1995
+ checked: 0,
1996
+ locked: 0
1997
+ };
1998
+ const walk = (nodes) => {
1999
+ for (const node of nodes) {
2000
+ if (!node.is_dir) continue;
2001
+ if (node.lock) {
2002
+ counts.checked += 1;
2003
+ counts.locked += 1;
2004
+ } else if (node.loaded) {
2005
+ counts.checked += 1;
2006
+ walk(node.children);
2007
+ }
2008
+ }
2009
+ };
2010
+ walk(roots.value);
2011
+ return counts;
2012
+ });
2013
+ const lockedFolders = computed(() => folderCounts.value.locked);
2014
+ function lockedInSearch(node) {
2015
+ return deepMode.value && !!node.lock && !!keyword.value.trim();
2016
+ }
2017
+ const deepSummary = computed(() => [
2018
+ t(deepRow.value === "capped" ? "deepCapped" : "deepDone", { count: folderCounts.value.checked }),
2019
+ lockedFolders.value ? t("deepSkipped", { count: lockedFolders.value }) : "",
2020
+ deep.failed ? t("deepFailed", { count: deep.failed }) : ""
2021
+ ].filter(Boolean).join(" · "));
1714
2022
  const stats = computed(() => folderStats(roots.value, selected.value));
1715
2023
  const noStats = {
1716
2024
  files: 0,
@@ -1940,7 +2248,16 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1940
2248
  name: "search",
1941
2249
  size: 15
1942
2250
  }, null, _parent));
1943
- _push(`<input${ssrRenderAttr("value", keyword.value)} type="text"${ssrRenderAttr("placeholder", t("filter"))}${ssrRenderAttr("aria-label", t("filter"))}></label><button class="${ssrRenderClass([{ "fl-spinning": refreshing.value }, "fl-icon-btn fl-refresh"])}" type="button"${ssrIncludeBooleanAttr(refreshing.value || loadingRoot.value) ? " disabled" : ""}${ssrRenderAttr("aria-label", refreshing.value ? t("refreshing") : t("refresh"))}${ssrRenderAttr("title", refreshing.value ? t("refreshing") : t("refresh"))}>`);
2251
+ _push(`<input${ssrRenderAttr("value", keyword.value)} type="text"${ssrRenderAttr("placeholder", t("filter"))}${ssrRenderAttr("aria-label", t("filter"))}>`);
2252
+ if (keyword.value) {
2253
+ _push(`<button class="fl-search-clear" type="button"${ssrRenderAttr("aria-label", t("clearFilter"))}${ssrRenderAttr("title", t("clearFilter"))}>`);
2254
+ _push(ssrRenderComponent(_sfc_main$2, {
2255
+ name: "close",
2256
+ size: 12
2257
+ }, null, _parent));
2258
+ _push(`</button>`);
2259
+ } else _push(`<!---->`);
2260
+ _push(`</label><button class="${ssrRenderClass([{ "fl-spinning": refreshing.value }, "fl-icon-btn fl-refresh"])}" type="button"${ssrIncludeBooleanAttr(refreshing.value || loadingRoot.value) ? " disabled" : ""}${ssrRenderAttr("aria-label", refreshing.value ? t("refreshing") : t("refresh"))}${ssrRenderAttr("title", refreshing.value ? t("refreshing") : t("refresh"))}>`);
1944
2261
  _push(ssrRenderComponent(_sfc_main$2, {
1945
2262
  name: "refresh",
1946
2263
  size: 15
@@ -2031,11 +2348,21 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
2031
2348
  } else _push(`<span class="fl-arrow-placeholder" aria-hidden="true"></span>`);
2032
2349
  if (node.is_dir) _push(`<svg class="fl-folder-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>`);
2033
2350
  else _push(`<span class="fl-type"${ssrRenderAttr("data-kind", unref(fileKind)(node.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(node.name) || "FILE")}</span>`);
2034
- _push(`<span class="fl-label"><span class="${ssrRenderClass([{ "fl-name-dir": node.is_dir }, "fl-name"])}">${ssrInterpolate(node.name)}</span><span class="fl-meta">`);
2351
+ _push(`<span class="fl-label"><span class="${ssrRenderClass([{ "fl-name-dir": node.is_dir }, "fl-name"])}"><!--[-->`);
2352
+ ssrRenderList(nameParts(node.name), (part, at) => {
2353
+ _push(`<!--[-->`);
2354
+ if (part.hit) _push(`<mark class="fl-hit">${ssrInterpolate(part.text)}</mark>`);
2355
+ else _push(`<!--[-->${ssrInterpolate(part.text)}<!--]-->`);
2356
+ _push(`<!--]-->`);
2357
+ });
2358
+ _push(`<!--]--></span><span class="fl-meta">`);
2035
2359
  if (!node.is_dir) _push(`<!--[-->${ssrInterpolate(unref(formatSize)(node.size))} · ${ssrInterpolate(unref(formatDate)(node.modified))}<!--]-->`);
2036
2360
  else if (node.loaded) _push(`<!--[-->${ssrInterpolate(t("folderItems", { count: statsOf(node).files }))} · ${ssrInterpolate(unref(formatSize)(statsOf(node).bytes))}<!--]-->`);
2361
+ else if (lockedInSearch(node)) _push(`<span class="fl-locked-note">${ssrInterpolate(t("deepLocked"))}</span>`);
2037
2362
  else _push(`<!---->`);
2038
2363
  _push(`</span></span>`);
2364
+ if (lockedInSearch(node) && !node.lock?.prompting) _push(`<span class="fl-locked-note fl-locked-hint">${ssrInterpolate(t("deepLocked"))}</span>`);
2365
+ else _push(`<!---->`);
2039
2366
  if (node.is_dir && node.loaded) _push(`<span class="fl-count">${ssrInterpolate(t("folderItems", { count: statsOf(node).files }))}</span>`);
2040
2367
  else _push(`<!---->`);
2041
2368
  if (node.lock?.prompting && node.lock.error) _push(`<span${ssrRenderAttr("id", `${unref(uid)}-error-${index}`)} class="fl-unlock-message" role="alert">${ssrInterpolate(unlockErrorText(node.lock.error))}</span>`);
@@ -2103,7 +2430,19 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
2103
2430
  }
2104
2431
  _push(`<!--]-->`);
2105
2432
  });
2106
- _push(`<!--]--><div class="fl-foot"><span>${ssrInterpolate(t("loaded", { count: allFiles.value.length }))} · ${ssrInterpolate(t("totalSize", { size: unref(formatSize)(unref(totalSize)(allFiles.value)) }))}</span></div>`);
2433
+ _push(`<!--]-->`);
2434
+ if (deepRow.value) {
2435
+ _push(`<div class="fl-deep" role="status"><p class="fl-deep-text">`);
2436
+ if (deepRow.value === "running") _push(`<!--[-->${ssrInterpolate(t("deepRunning", { count: folderCounts.value.checked }))} · <button class="fl-deep-link" type="button">${ssrInterpolate(t("deepStop"))}</button><!--]-->`);
2437
+ else if (deepRow.value === "idle") _push(`<!--[-->${ssrInterpolate(t("deepHint"))}<button class="fl-deep-link" type="button">${ssrInterpolate(t("deepSearch"))}</button><!--]-->`);
2438
+ else if (deepRow.value === "capped") _push(`<!--[-->${ssrInterpolate(deepSummary.value)} · <button class="fl-deep-link" type="button">${ssrInterpolate(t("deepContinue"))}</button><!--]-->`);
2439
+ else _push(`<!--[-->${ssrInterpolate(deepSummary.value)}<!--]-->`);
2440
+ _push(`</p>`);
2441
+ if (deepRow.value === "running") _push(`<span class="fl-deep-bar" aria-hidden="true"></span>`);
2442
+ else _push(`<!---->`);
2443
+ _push(`</div>`);
2444
+ } else _push(`<!---->`);
2445
+ _push(`<div class="fl-foot"><span>${ssrInterpolate(t("loaded", { count: allFiles.value.length }))} · ${ssrInterpolate(t("totalSize", { size: unref(formatSize)(unref(totalSize)(allFiles.value)) }))}</span></div>`);
2107
2446
  if (toast.value) _push(`<div class="fl-toast" role="status">${ssrInterpolate(toast.value)}</div>`);
2108
2447
  else _push(`<!---->`);
2109
2448
  _push(ssrRenderComponent(_sfc_main$3, {