vitepress-plugin-filelist 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,6 +92,27 @@ 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
+ /** Markdown / HTML preview: the source / rendered switch. */
106
+ viewMode: string;
107
+ viewSource: string;
108
+ viewRendered: string;
109
+ /** Above a rendered HTML file. */
110
+ htmlSandboxNote: string;
111
+ /** Stands in for a Markdown image with a relative path. `{alt}` */
112
+ imageOmitted: string;
113
+ /** Title of the dialog when a batch ZIP fails (the reason is the text). */
114
+ batchFailedTitle: string;
115
+ batchFailedHint: string;
95
116
  /** Single file handed to the browser's download manager. `{name}` */
96
117
  downloadStarted: string;
97
118
  /** Batch progress detail. `{done}` `{total}` sizes, `{files}` `{count}` file counts */
@@ -153,6 +174,11 @@ interface FileListProps {
153
174
  * everything. Data is still loaded in full; only rendering is limited.
154
175
  */
155
176
  pageSize?: number;
177
+ /**
178
+ * Deep search loads at most this many folders per click (“Continue” goes on).
179
+ * Defaults to `500`.
180
+ */
181
+ deepSearchLimit?: number;
156
182
  /**
157
183
  * Browsers that must build the ZIP in memory (Firefox, Safari) ask first above
158
184
  * this many MB. Defaults to 500 (200 on touch devices); `0` never asks.
@@ -189,6 +215,7 @@ declare const _default: import("vue").DefineComponent<FileListProps, {}, {}, {},
189
215
  title: string;
190
216
  rememberPassword: boolean;
191
217
  pageSize: number;
218
+ deepSearchLimit: number;
192
219
  locale: FileListLocale;
193
220
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
194
221
  declare global {
@@ -307,7 +334,7 @@ export declare function previewKind(name: string): PreviewKind;
307
334
  export declare function totalSize(items: Array<{
308
335
  size?: number;
309
336
  }>): number;
310
- export declare function compareNodes(a: SortableNode, b: SortableNode, key: SortKey, ascending?: boolean): number;
337
+ export declare function compareNodes(a: SortableNode, b: SortableNode, key: SortKey, ascending?: boolean, locale?: string): number;
311
338
  interface IframePreview {
312
339
  label: string;
313
340
  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
+ viewMode: "显示方式",
882
+ viewSource: "源码",
883
+ viewRendered: "渲染",
884
+ htmlSandboxNote: "已禁用脚本;网页引用的相对路径资源不会加载",
885
+ imageOmitted: "[图片:{alt}]",
886
+ batchFailedTitle: "打包失败",
887
+ batchFailedHint: "可以减少勾选的文件后重试。",
855
888
  downloadStarted: "已交给浏览器下载:{name}",
856
889
  batchProgress: "{done} / {total} · {files} / {count} 个文件",
857
890
  batchDone: "已打包 {name}({count} 个文件,{size})",
@@ -930,6 +963,22 @@ const fileListLocales = {
930
963
  forgetPassword: "Forget",
931
964
  unlockFolder: "Enter the password for {name}",
932
965
  networkHint: "Possibly blocked by CORS: check cors.allow_origins in OpenList, or ALLOW_URLS in OpenList-Worker.",
966
+ deepHint: "Only loaded folders are filtered",
967
+ deepSearch: "Deep search",
968
+ deepRunning: "Searching · {count} folders checked",
969
+ deepStop: "Stop",
970
+ deepDone: "Searched all {count} folders",
971
+ deepCapped: "{count} folders checked; some sub-folders remain",
972
+ deepContinue: "Continue",
973
+ deepSkipped: "{count} locked folders skipped",
974
+ deepFailed: "{count} folders could not be read",
975
+ viewMode: "View",
976
+ viewSource: "Source",
977
+ viewRendered: "Rendered",
978
+ htmlSandboxNote: "Scripts are disabled; resources at relative paths do not load",
979
+ imageOmitted: "[Image: {alt}]",
980
+ batchFailedTitle: "Could not create the ZIP",
981
+ batchFailedHint: "Try again with fewer files selected.",
933
982
  downloadStarted: "Downloading {name} in your browser",
934
983
  batchProgress: "{done} / {total} · {files} / {count} files",
935
984
  batchDone: "Saved {name} ({count} files, {size})",
@@ -956,6 +1005,45 @@ function resolveLabels(locale = "zh-CN", overrides) {
956
1005
  ...overrides
957
1006
  };
958
1007
  }
1008
+ // src/client/markdown.ts
1009
+ /**
1010
+ * Markdown → safe HTML for the preview dialog. Loaded on demand, so pages that
1011
+ * never render Markdown do not download markdown-it or DOMPurify.
1012
+ *
1013
+ * - Raw HTML in the file is allowed, then sanitised (no scripts, handlers, iframes...)
1014
+ * - Only absolute http(s) / mailto links stay clickable, opening in a new tab;
1015
+ * relative links (to other files in the share) become plain text
1016
+ * - Images with a relative source are replaced by their alt text, because the
1017
+ * share's relative paths do not resolve on the documentation site
1018
+ */
1019
+ async function renderMarkdown(source, imageLabel) {
1020
+ const [{ default: MarkdownIt }, { default: DOMPurify }] = await Promise.all([import("markdown-it"), import("dompurify")]);
1021
+ const probe = DOMPurify.isSupported ? DOMPurify.sanitize("<img src=\"x\" onerror=\"1\"><script>1<\/script>") : "<script";
1022
+ if (/<script|onerror/i.test(probe)) throw new Error("HTML sanitiser unavailable");
1023
+ const html = new MarkdownIt({
1024
+ html: true,
1025
+ linkify: true
1026
+ }).render(source);
1027
+ const template = document.createElement("template");
1028
+ template.innerHTML = DOMPurify.sanitize(html);
1029
+ const fragment = template.content;
1030
+ for (const link of fragment.querySelectorAll("a")) {
1031
+ const href = link.getAttribute("href") ?? "";
1032
+ if (/^(https?:|mailto:)/i.test(href)) {
1033
+ link.setAttribute("target", "_blank");
1034
+ link.setAttribute("rel", "noopener noreferrer");
1035
+ } else link.replaceWith(...link.childNodes);
1036
+ }
1037
+ for (const image of fragment.querySelectorAll("img")) {
1038
+ const src = image.getAttribute("src") ?? "";
1039
+ if (/^(https?:|data:image\/)/i.test(src)) continue;
1040
+ const placeholder = document.createElement("span");
1041
+ placeholder.className = "fl-md-image";
1042
+ placeholder.textContent = imageLabel(image.getAttribute("alt") || src.split("/").pop() || "");
1043
+ image.replaceWith(placeholder);
1044
+ }
1045
+ return template.innerHTML;
1046
+ }
959
1047
  // src/client/PreviewDialog.vue
960
1048
  const _sfc_main$1 = /*@__PURE__*/ defineComponent({
961
1049
  __name: "PreviewDialog",
@@ -990,7 +1078,9 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
990
1078
  /**
991
1079
  * File preview in a native <dialog>, without leaving the page.
992
1080
  *
993
- * image / video / audio: native elements; text: the first `maxTextBytes`
1081
+ * image / video / audio: native elements; text: the first `maxTextBytes`,
1082
+ * with Markdown and HTML switchable between source (default) and rendered
1083
+ * (Markdown sanitised in VitePress' doc style, HTML in a script-less sandbox);
994
1084
  * through a Range request; pdf: the first matching OpenList `iframe_previews`
995
1085
  * viewer, else the browser's reader through a Blob. Anything else (Office
996
1086
  * included), failures, oversized PDFs and demo mode: file info + download.
@@ -1008,8 +1098,18 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1008
1098
  const blobUrl = ref("");
1009
1099
  const rotation = ref(0);
1010
1100
  const actualSize = ref(false);
1101
+ /** Markdown / HTML: show the source or render it. Kept while the page is open. */
1102
+ const view = ref("source");
1103
+ const renderedHtml = ref("");
1011
1104
  let controller;
1012
1105
  let loadSeq = 0;
1106
+ /** Text files that can also be rendered. */
1107
+ const renderAs = computed(() => {
1108
+ const name = props.file?.name ?? "";
1109
+ if (/\.(md|markdown)$/i.test(name)) return "markdown";
1110
+ if (/\.html?$/i.test(name)) return "html";
1111
+ return null;
1112
+ });
1013
1113
  const tooLarge = computed(() => kind.value === "pdf" && !props.viewer && (props.file?.size ?? 0) > props.maxPdfBytes);
1014
1114
  const mode = computed(() => {
1015
1115
  if (props.demo) return "demo";
@@ -1019,6 +1119,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1019
1119
  return kind.value;
1020
1120
  });
1021
1121
  const busy = computed(() => !props.demo && kind.value !== "none" && !props.error && !bodyFailed.value && (props.resolving || loadingBody.value || !props.src && !tooLarge.value));
1122
+ const canRender = computed(() => !!renderAs.value && mode.value === "text" && !busy.value);
1022
1123
  const infoMessage = computed(() => {
1023
1124
  const file = props.file;
1024
1125
  if (!file) return "";
@@ -1039,6 +1140,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1039
1140
  loadingBody.value = false;
1040
1141
  bodyFailed.value = false;
1041
1142
  textContent.value = "";
1143
+ renderedHtml.value = "";
1042
1144
  truncated.value = false;
1043
1145
  rotation.value = 0;
1044
1146
  actualSize.value = false;
@@ -1120,6 +1222,20 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1120
1222
  props.error,
1121
1223
  props.demo
1122
1224
  ], () => void loadBody(), { immediate: true });
1225
+ watch(() => [
1226
+ view.value,
1227
+ renderAs.value,
1228
+ textContent.value
1229
+ ], async ([current, as, text]) => {
1230
+ if (current !== "rendered" || as !== "markdown" || !text) return;
1231
+ const seq = loadSeq;
1232
+ try {
1233
+ const html = await renderMarkdown(text, (alt) => t("imageOmitted", { alt }));
1234
+ if (seq === loadSeq) renderedHtml.value = html;
1235
+ } catch {
1236
+ if (seq === loadSeq) view.value = "source";
1237
+ }
1238
+ });
1123
1239
  watch(() => props.open, (open) => {
1124
1240
  const el = dialog.value;
1125
1241
  if (!el) return;
@@ -1141,7 +1257,7 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1141
1257
  }, _attrs))}>`);
1142
1258
  if (__props.open && __props.file) {
1143
1259
  _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>`);
1144
- if (__props.total > 1 || mode.value === "image" && !busy.value) {
1260
+ if (__props.total > 1 || mode.value === "image" && !busy.value || canRender.value) {
1145
1261
  _push(`<div class="fl-preview-tools fl-preview-nav">`);
1146
1262
  if (__props.total > 1) {
1147
1263
  _push(`<!--[--><button class="fl-preview-tool" type="button"${ssrRenderAttr("aria-label", t("previous"))}${ssrRenderAttr("title", t("previous"))}>`);
@@ -1169,6 +1285,13 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1169
1285
  }, null, _parent));
1170
1286
  _push(`</button><!--]-->`);
1171
1287
  } else _push(`<!---->`);
1288
+ if (canRender.value) {
1289
+ _push(`<span class="fl-preview-view" role="group"${ssrRenderAttr("aria-label", t("viewMode"))}><!--[-->`);
1290
+ ssrRenderList(["source", "rendered"], (option) => {
1291
+ _push(`<button class="fl-preview-view-btn" type="button"${ssrRenderAttr("aria-pressed", view.value === option)}>${ssrInterpolate(t(option === "source" ? "viewSource" : "viewRendered"))}</button>`);
1292
+ });
1293
+ _push(`<!--]--></span>`);
1294
+ } else _push(`<!---->`);
1172
1295
  _push(`</div>`);
1173
1296
  } else _push(`<!---->`);
1174
1297
  _push(`<div class="fl-preview-tools fl-preview-actions">`);
@@ -1191,10 +1314,17 @@ const _sfc_main$1 = /*@__PURE__*/ defineComponent({
1191
1314
  else if (mode.value === "video") _push(`<video class="fl-preview-video"${ssrRenderAttr("src", __props.src)} controls playsinline preload="metadata"></video>`);
1192
1315
  else if (mode.value === "audio") _push(`<audio class="fl-preview-audio"${ssrRenderAttr("src", __props.src)} controls preload="metadata"></audio>`);
1193
1316
  else if (mode.value === "text") {
1194
- _push(`<div class="fl-preview-text">`);
1317
+ _push(`<div class="${ssrRenderClass([{ "fl-preview-rendered": renderAs.value && view.value === "rendered" }, "fl-preview-text"])}">`);
1195
1318
  if (truncated.value) _push(`<p class="fl-preview-note">${ssrInterpolate(t("textTruncated", { size: unref(formatSize)(__props.maxTextBytes) }))}</p>`);
1196
1319
  else _push(`<!---->`);
1197
- _push(`<pre>${ssrInterpolate(textContent.value)}</pre></div>`);
1320
+ if (renderAs.value === "markdown" && view.value === "rendered") {
1321
+ _push(`<!--[--><!-- sanitised by renderMarkdown (DOMPurify) -->`);
1322
+ if (renderedHtml.value) _push(`<div class="vp-doc fl-md">${renderedHtml.value ?? ""}</div>`);
1323
+ else _push(`<div class="fl-preview-status" role="status">${ssrInterpolate(t("loading"))}</div>`);
1324
+ _push(`<!--]-->`);
1325
+ } 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><!--]-->`);
1326
+ else _push(`<pre>${ssrInterpolate(textContent.value)}</pre>`);
1327
+ _push(`</div>`);
1198
1328
  } else if (mode.value === "pdf") _push(`<iframe class="fl-preview-frame"${ssrRenderAttr("src", __props.viewer || blobUrl.value)}${ssrRenderAttr("title", __props.file.name)}></iframe>`);
1199
1329
  else {
1200
1330
  _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>`);
@@ -1243,9 +1373,9 @@ function toNodes(items, parent, password) {
1243
1373
  zipPath: [...parent.zipPath, item.name]
1244
1374
  }));
1245
1375
  }
1246
- function sortTree(nodes, key, ascending) {
1247
- nodes.sort((a, b) => compareNodes(a, b, key, ascending));
1248
- for (const node of nodes) if (node.children.length) sortTree(node.children, key, ascending);
1376
+ function sortTree(nodes, key, ascending, locale) {
1377
+ nodes.sort((a, b) => compareNodes(a, b, key, ascending, locale));
1378
+ for (const node of nodes) if (node.children.length) sortTree(node.children, key, ascending, locale);
1249
1379
  }
1250
1380
  /** Loaded files at or under a node. */
1251
1381
  function descendantFiles(node) {
@@ -1432,6 +1562,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1432
1562
  default: true
1433
1563
  },
1434
1564
  pageSize: { default: 200 },
1565
+ deepSearchLimit: { default: 500 },
1435
1566
  zipWarnSize: {},
1436
1567
  zipMaxSize: {},
1437
1568
  locale: { default: "zh-CN" },
@@ -1496,7 +1627,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1496
1627
  return isNetworkError(error) ? `${message} ${t("corsHint")}` : message;
1497
1628
  }
1498
1629
  function sortNodes(nodes) {
1499
- sortTree(nodes, sortKey.value, sortAsc.value);
1630
+ sortTree(nodes, sortKey.value, sortAsc.value, props.locale);
1500
1631
  }
1501
1632
  function focusUnlock(path) {
1502
1633
  nextTick(() => {
@@ -1528,7 +1659,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1528
1659
  * Load a folder's children. A password error locks the folder instead of
1529
1660
  * toasting; `prompt` also shows its same-row input (and focuses it).
1530
1661
  */
1531
- async function loadChildren(node, prompt = false) {
1662
+ async function loadChildren(node, prompt = false, quiet = false) {
1532
1663
  if (node.loaded) return true;
1533
1664
  if (node.lock) {
1534
1665
  if (prompt) startPrompt(node, prompt === "focus");
@@ -1544,7 +1675,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1544
1675
  node.expanded = false;
1545
1676
  node.lock = newLock(false, passwords.dropStale(rememberedAt) ? "stale" : "");
1546
1677
  if (prompt) startPrompt(node, prompt === "focus");
1547
- } else showToast(failureMessage("folderFailed", error));
1678
+ } else if (!quiet) showToast(failureMessage("folderFailed", error));
1548
1679
  return false;
1549
1680
  } finally {
1550
1681
  node.loading = false;
@@ -1631,6 +1762,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1631
1762
  }
1632
1763
  /** First load, or a retry after an error: shows the loading state. */
1633
1764
  async function loadRoot() {
1765
+ stopDeepSearch();
1634
1766
  refreshSeq += 1;
1635
1767
  refreshing.value = false;
1636
1768
  loadingRoot.value = true;
@@ -1707,6 +1839,88 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1707
1839
  return out;
1708
1840
  });
1709
1841
  const visible = computed(() => rows.value.filter((row) => !isMore(row)));
1842
+ /** A name split around the filter keyword, for highlighting. */
1843
+ function nameParts(name) {
1844
+ const query = keyword.value.trim().toLowerCase();
1845
+ if (!query) return [{
1846
+ text: name,
1847
+ hit: false
1848
+ }];
1849
+ const parts = [];
1850
+ const lower = name.toLowerCase();
1851
+ let from = 0;
1852
+ for (let at = lower.indexOf(query); at !== -1; at = lower.indexOf(query, from)) {
1853
+ if (at > from) parts.push({
1854
+ text: name.slice(from, at),
1855
+ hit: false
1856
+ });
1857
+ parts.push({
1858
+ text: name.slice(at, at + query.length),
1859
+ hit: true
1860
+ });
1861
+ from = at + query.length;
1862
+ }
1863
+ if (from < name.length) parts.push({
1864
+ text: name.slice(from),
1865
+ hit: false
1866
+ });
1867
+ return parts;
1868
+ }
1869
+ /**
1870
+ * The filter only sees loaded folders. Deep search loads the rest under the
1871
+ * mounted paths (four at a time, `deepSearchLimit` per run) so matches appear
1872
+ * in place, in their folders, like any other row. Locked folders are skipped.
1873
+ */
1874
+ const deep = reactive({
1875
+ running: false,
1876
+ searched: 0,
1877
+ skipped: 0,
1878
+ failed: 0,
1879
+ capped: false,
1880
+ done: false
1881
+ });
1882
+ let deepSeq = 0;
1883
+ /** Folders that are known but not loaded yet (locked ones excluded). */
1884
+ function unloadedFolders() {
1885
+ const out = [];
1886
+ const walk = (nodes) => {
1887
+ for (const node of nodes) {
1888
+ if (!node.is_dir || node.lock) continue;
1889
+ if (!node.loaded) out.push(node);
1890
+ else walk(node.children);
1891
+ }
1892
+ };
1893
+ walk(roots.value);
1894
+ return out;
1895
+ }
1896
+ const hasUnloaded = computed(() => unloadedFolders().length > 0);
1897
+ const deepRow = computed(() => {
1898
+ if (!keyword.value.trim()) return null;
1899
+ if (deep.running) return "running";
1900
+ if (deep.capped) return "capped";
1901
+ if (hasUnloaded.value) return "idle";
1902
+ return deep.done ? "done" : null;
1903
+ });
1904
+ function stopDeepSearch() {
1905
+ deepSeq += 1;
1906
+ deep.running = false;
1907
+ }
1908
+ watch(keyword, (value) => {
1909
+ if (value.trim()) return;
1910
+ stopDeepSearch();
1911
+ Object.assign(deep, {
1912
+ searched: 0,
1913
+ skipped: 0,
1914
+ failed: 0,
1915
+ capped: false,
1916
+ done: false
1917
+ });
1918
+ });
1919
+ const deepSummary = computed(() => [
1920
+ deepRow.value === "capped" ? t("deepCapped", { count: deep.searched }) : t("deepDone", { count: deep.searched }),
1921
+ deep.skipped ? t("deepSkipped", { count: deep.skipped }) : "",
1922
+ deep.failed ? t("deepFailed", { count: deep.failed }) : ""
1923
+ ].filter(Boolean).join(" · "));
1710
1924
  const stats = computed(() => folderStats(roots.value, selected.value));
1711
1925
  const noStats = {
1712
1926
  files: 0,
@@ -1750,9 +1964,28 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1750
1964
  showToast(t("downloadFailed", { message: errorText(error) }));
1751
1965
  }
1752
1966
  }
1967
+ /** A ZIP question or notice: over the warning size, over the limit, or failed. */
1753
1968
  const zipNotice = ref(null);
1754
1969
  /** Only desktop browsers get “use Chrome or Edge”; on iPhone every browser is Safari. */
1755
1970
  const zipTip = computed(() => typeof window !== "undefined" && !window.matchMedia?.("(pointer: coarse)").matches ? t("zipStreamTip") : "");
1971
+ const zipDialog = computed(() => {
1972
+ const notice = zipNotice.value;
1973
+ if (!notice) return null;
1974
+ if (notice.kind === "failed") return {
1975
+ title: t("batchFailedTitle"),
1976
+ text: notice.message,
1977
+ tip: t("batchFailedHint"),
1978
+ cancel: t("gotIt")
1979
+ };
1980
+ const warn = notice.kind === "warn";
1981
+ return {
1982
+ title: t(warn ? "zipWarnTitle" : "zipBlockTitle", { size: formatSize(notice.size) }),
1983
+ text: t(warn ? "zipWarnText" : "zipBlockText", { limit: formatSize(notice.limit) }),
1984
+ tip: zipTip.value,
1985
+ confirm: warn ? t("zipContinue") : void 0,
1986
+ cancel: warn ? t("cancel") : t("gotIt")
1987
+ };
1988
+ });
1756
1989
  function zipName() {
1757
1990
  return zipFileName(props.title || t("defaultZip"));
1758
1991
  }
@@ -1783,7 +2016,11 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
1783
2016
  }));
1784
2017
  selected.value = /* @__PURE__ */ new Set();
1785
2018
  } catch (error) {
1786
- showToast(controller.signal.aborted ? t("batchCanceled") : t("batchFailed", { message: errorText(error) }));
2019
+ if (controller.signal.aborted) showToast(t("batchCanceled"));
2020
+ else zipNotice.value = {
2021
+ kind: "failed",
2022
+ message: errorText(error)
2023
+ };
1787
2024
  } finally {
1788
2025
  zipAbort = null;
1789
2026
  batchBusy.value = false;
@@ -2004,7 +2241,14 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
2004
2241
  } else _push(`<span class="fl-arrow-placeholder" aria-hidden="true"></span>`);
2005
2242
  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>`);
2006
2243
  else _push(`<span class="fl-type"${ssrRenderAttr("data-kind", unref(fileKind)(node.name))} aria-hidden="true">${ssrInterpolate(unref(extOf)(node.name) || "FILE")}</span>`);
2007
- _push(`<span class="fl-label"><span class="${ssrRenderClass([{ "fl-name-dir": node.is_dir }, "fl-name"])}">${ssrInterpolate(node.name)}</span><span class="fl-meta">`);
2244
+ _push(`<span class="fl-label"><span class="${ssrRenderClass([{ "fl-name-dir": node.is_dir }, "fl-name"])}"><!--[-->`);
2245
+ ssrRenderList(nameParts(node.name), (part, at) => {
2246
+ _push(`<!--[-->`);
2247
+ if (part.hit) _push(`<mark class="fl-hit">${ssrInterpolate(part.text)}</mark>`);
2248
+ else _push(`<!--[-->${ssrInterpolate(part.text)}<!--]-->`);
2249
+ _push(`<!--]-->`);
2250
+ });
2251
+ _push(`<!--]--></span><span class="fl-meta">`);
2008
2252
  if (!node.is_dir) _push(`<!--[-->${ssrInterpolate(unref(formatSize)(node.size))} · ${ssrInterpolate(unref(formatDate)(node.modified))}<!--]-->`);
2009
2253
  else if (node.loaded) _push(`<!--[-->${ssrInterpolate(t("folderItems", { count: statsOf(node).files }))} · ${ssrInterpolate(unref(formatSize)(statsOf(node).bytes))}<!--]-->`);
2010
2254
  else _push(`<!---->`);
@@ -2076,16 +2320,29 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
2076
2320
  }
2077
2321
  _push(`<!--]-->`);
2078
2322
  });
2079
- _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>`);
2323
+ _push(`<!--]-->`);
2324
+ if (deepRow.value) {
2325
+ _push(`<div class="fl-row fl-deep" role="status"><span class="fl-cell fl-cell-check"></span><span class="fl-cell fl-more-cell"><span class="fl-indent" aria-hidden="true"></span>`);
2326
+ if (deepRow.value === "running") _push(`<!--[--><span class="fl-more-count">${ssrInterpolate(t("deepRunning", { count: deep.searched }))}</span><button class="fl-more-btn fl-more-all" type="button">${ssrInterpolate(t("deepStop"))}</button><span class="fl-deep-bar" aria-hidden="true"></span><!--]-->`);
2327
+ else if (deepRow.value === "idle") _push(`<!--[--><span class="fl-more-count">${ssrInterpolate(deep.searched ? deepSummary.value : t("deepHint"))}</span><button class="fl-more-btn" type="button">${ssrInterpolate(t("deepSearch"))}</button><!--]-->`);
2328
+ else {
2329
+ _push(`<!--[--><span class="fl-more-count">${ssrInterpolate(deepSummary.value)}</span>`);
2330
+ if (deepRow.value === "capped") _push(`<button class="fl-more-btn" type="button">${ssrInterpolate(t("deepContinue"))}</button>`);
2331
+ else _push(`<!---->`);
2332
+ _push(`<!--]-->`);
2333
+ }
2334
+ _push(`</span></div>`);
2335
+ } else _push(`<!---->`);
2336
+ _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>`);
2080
2337
  if (toast.value) _push(`<div class="fl-toast" role="status">${ssrInterpolate(toast.value)}</div>`);
2081
2338
  else _push(`<!---->`);
2082
2339
  _push(ssrRenderComponent(_sfc_main$3, {
2083
- open: !!zipNotice.value,
2084
- title: zipNotice.value ? t(zipNotice.value.kind === "warn" ? "zipWarnTitle" : "zipBlockTitle", { size: unref(formatSize)(zipNotice.value.size) }) : "",
2085
- text: zipNotice.value ? t(zipNotice.value.kind === "warn" ? "zipWarnText" : "zipBlockText", { limit: unref(formatSize)(zipNotice.value.limit) }) : "",
2086
- tip: zipTip.value,
2087
- "confirm-label": zipNotice.value?.kind === "warn" ? t("zipContinue") : void 0,
2088
- "cancel-label": zipNotice.value?.kind === "warn" ? t("cancel") : t("gotIt"),
2340
+ open: !!zipDialog.value,
2341
+ title: zipDialog.value?.title ?? "",
2342
+ text: zipDialog.value?.text ?? "",
2343
+ tip: zipDialog.value?.tip,
2344
+ "confirm-label": zipDialog.value?.confirm,
2345
+ "cancel-label": zipDialog.value?.cancel ?? "",
2089
2346
  onConfirm: confirmZip,
2090
2347
  onClose: ($event) => zipNotice.value = null
2091
2348
  }, null, _parent));
@@ -787,11 +787,13 @@
787
787
  }
788
788
 
789
789
  .fl-toast {
790
- position: absolute;
791
- bottom: 14px;
790
+ /* the viewport, not the card: a long list would push the card's bottom off screen */
791
+ position: fixed;
792
+ bottom: calc(24px + env(safe-area-inset-bottom));
792
793
  left: 50%;
793
- z-index: 5;
794
- max-width: 92%;
794
+ z-index: 100;
795
+ box-sizing: border-box;
796
+ max-width: min(560px, calc(100vw - 32px));
795
797
  padding: 7px 14px;
796
798
  overflow: hidden;
797
799
  font-size: 13px;
@@ -822,7 +824,8 @@
822
824
 
823
825
  /* ---------------- long folders ---------------- */
824
826
 
825
- .fl-more {
827
+ .fl-more,
828
+ .fl-deep {
826
829
  background: var(--vp-c-bg-soft);
827
830
  }
828
831
 
@@ -866,6 +869,37 @@
866
869
  border-color: transparent;
867
870
  }
868
871
 
872
+ /* ---------------- filter and deep search ---------------- */
873
+
874
+ .fl-hit {
875
+ padding: 0 1px;
876
+ color: inherit;
877
+ background: var(--fl-hit-bg, rgb(250 204 21 / 32%));
878
+ border-radius: 3px;
879
+ }
880
+
881
+ .fl-deep {
882
+ position: relative;
883
+ }
884
+
885
+ .fl-deep-bar {
886
+ position: absolute;
887
+ bottom: -1px;
888
+ left: 0;
889
+ width: 30%;
890
+ height: 2px;
891
+ background: var(--vp-c-brand-1);
892
+ animation: fl-progress 1.2s ease-in-out infinite;
893
+ }
894
+
895
+ @media (prefers-reduced-motion: reduce) {
896
+ .fl-deep-bar {
897
+ width: 100%;
898
+ animation: none;
899
+ opacity: 0.6;
900
+ }
901
+ }
902
+
869
903
  /* ---------------- confirm dialog ---------------- */
870
904
 
871
905
  .fl-confirm {
@@ -1314,6 +1348,62 @@
1314
1348
  background: var(--vp-c-default-soft);
1315
1349
  }
1316
1350
 
1351
+ /* Markdown / HTML: source or rendered */
1352
+ .fl-preview-view {
1353
+ display: inline-flex;
1354
+ padding: 2px;
1355
+ margin-left: 6px;
1356
+ background: var(--vp-c-default-soft);
1357
+ border-radius: 8px;
1358
+ }
1359
+
1360
+ .fl-preview-view-btn {
1361
+ height: 28px;
1362
+ padding: 0 10px;
1363
+ font-size: 12px;
1364
+ color: var(--vp-c-text-2);
1365
+ cursor: pointer;
1366
+ background: transparent;
1367
+ border: 0;
1368
+ border-radius: 6px;
1369
+ }
1370
+
1371
+ .fl-preview-view-btn[aria-pressed='true'] {
1372
+ font-weight: 600;
1373
+ color: var(--vp-c-text-1);
1374
+ background: var(--vp-c-bg);
1375
+ box-shadow: 0 1px 2px rgb(0 0 0 / 12%);
1376
+ }
1377
+
1378
+ .fl-preview-rendered {
1379
+ display: flex;
1380
+ flex-direction: column;
1381
+ }
1382
+
1383
+ .fl-preview .fl-md {
1384
+ box-sizing: border-box;
1385
+ max-width: 860px;
1386
+ padding: 16px 24px 32px;
1387
+ margin: 0 auto;
1388
+ }
1389
+
1390
+ .fl-md-image {
1391
+ display: inline-block;
1392
+ padding: 2px 8px;
1393
+ font-size: 12px;
1394
+ color: var(--vp-c-text-2);
1395
+ border: 1px dashed var(--vp-c-divider);
1396
+ border-radius: 6px;
1397
+ }
1398
+
1399
+ .fl-preview-html {
1400
+ flex: 1 1 auto;
1401
+ width: 100%;
1402
+ min-height: 0;
1403
+ background: #fff;
1404
+ border: 0;
1405
+ }
1406
+
1317
1407
  .fl-preview-count {
1318
1408
  min-width: 44px;
1319
1409
  font-size: 12px;
@@ -1485,6 +1575,12 @@
1485
1575
  min-width: 36px;
1486
1576
  }
1487
1577
 
1578
+ .fl-preview-view-btn {
1579
+ height: 36px;
1580
+ padding: 0 14px;
1581
+ font-size: 14px;
1582
+ }
1583
+
1488
1584
  .fl-preview .fl-preview-head .fl-type {
1489
1585
  display: none;
1490
1586
  }