@vp-tw/dirwell 0.0.0-stage → 0.1.0-alpha.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +182 -2
  4. package/THEME_PACKAGE_CONTRACT.md +64 -0
  5. package/THEMING.md +88 -0
  6. package/THIRD_PARTY_NOTICES.md +14 -0
  7. package/dist/bin.d.mts +1 -0
  8. package/dist/bin.mjs +306 -0
  9. package/dist/bin.mjs.map +1 -0
  10. package/dist/bun.d.mts +7 -0
  11. package/dist/bun.mjs +7 -0
  12. package/dist/bun.mjs.map +1 -0
  13. package/dist/config-BJG2Zc3Q.d.mts +34 -0
  14. package/dist/dev-server-Q7NC1wiB.mjs +132 -0
  15. package/dist/dev-server-Q7NC1wiB.mjs.map +1 -0
  16. package/dist/esbuild.d.mts +7 -0
  17. package/dist/esbuild.mjs +7 -0
  18. package/dist/esbuild.mjs.map +1 -0
  19. package/dist/farm.d.mts +7 -0
  20. package/dist/farm.mjs +7 -0
  21. package/dist/farm.mjs.map +1 -0
  22. package/dist/generator-CtX3iHhK.mjs +1783 -0
  23. package/dist/generator-CtX3iHhK.mjs.map +1 -0
  24. package/dist/index.d.mts +47 -0
  25. package/dist/index.mjs +74 -0
  26. package/dist/index.mjs.map +1 -0
  27. package/dist/model-CWjYM17j.d.mts +100 -0
  28. package/dist/rolldown.d.mts +7 -0
  29. package/dist/rolldown.mjs +7 -0
  30. package/dist/rolldown.mjs.map +1 -0
  31. package/dist/rollup.d.mts +7 -0
  32. package/dist/rollup.mjs +7 -0
  33. package/dist/rollup.mjs.map +1 -0
  34. package/dist/rsbuild.d.mts +7 -0
  35. package/dist/rsbuild.mjs +7 -0
  36. package/dist/rsbuild.mjs.map +1 -0
  37. package/dist/rspack.d.mts +7 -0
  38. package/dist/rspack.mjs +7 -0
  39. package/dist/rspack.mjs.map +1 -0
  40. package/dist/theme-components-CXzvdQY-.mjs +8 -0
  41. package/dist/theme-components-CXzvdQY-.mjs.map +1 -0
  42. package/dist/theme-components-DW0fv9_C.d.mts +141 -0
  43. package/dist/theme-components.d.mts +2 -0
  44. package/dist/theme-components.mjs +2 -0
  45. package/dist/theme-runtime.js +1128 -0
  46. package/dist/theme-worker.js +27 -0
  47. package/dist/unplugin-CKRUCTgW.d.mts +16 -0
  48. package/dist/unplugin-p6AXhL4F.mjs +421 -0
  49. package/dist/unplugin-p6AXhL4F.mjs.map +1 -0
  50. package/dist/unplugin.d.mts +2 -0
  51. package/dist/unplugin.mjs +2 -0
  52. package/dist/vite-JBsvRvuW.mjs +282 -0
  53. package/dist/vite-JBsvRvuW.mjs.map +1 -0
  54. package/dist/vite.d.mts +12 -0
  55. package/dist/vite.mjs +2 -0
  56. package/dist/vscode-icons/NOTICE.txt +16 -0
  57. package/dist/vscode-icons/default_file.svg +1 -0
  58. package/dist/vscode-icons/default_folder.svg +1 -0
  59. package/dist/vscode-icons/file_type_css.svg +1 -0
  60. package/dist/vscode-icons/file_type_html.svg +1 -0
  61. package/dist/vscode-icons/file_type_image.svg +1 -0
  62. package/dist/vscode-icons/file_type_js.svg +1 -0
  63. package/dist/vscode-icons/file_type_json.svg +1 -0
  64. package/dist/vscode-icons/file_type_markdown.svg +1 -0
  65. package/dist/vscode-icons/file_type_pdf2.svg +1 -0
  66. package/dist/vscode-icons/file_type_shell.svg +1 -0
  67. package/dist/vscode-icons/file_type_text.svg +1 -0
  68. package/dist/vscode-icons/file_type_typescript.svg +1 -0
  69. package/dist/vscode-icons/file_type_yaml.svg +1 -0
  70. package/dist/vscode-icons/file_type_zip.svg +1 -0
  71. package/dist/webpack.d.mts +7 -0
  72. package/dist/webpack.mjs +7 -0
  73. package/dist/webpack.mjs.map +1 -0
  74. package/package.json +133 -4
@@ -0,0 +1,1128 @@
1
+ export function fuzzyScore(query, value) {
2
+ const needle = query.trim().toLocaleLowerCase();
3
+ const haystack = value.toLocaleLowerCase();
4
+ if (needle === "") return 0;
5
+ let score = 0;
6
+ let searchFrom = 0;
7
+ let previous = -2;
8
+ for (const character of needle) {
9
+ const index = haystack.indexOf(character, searchFrom);
10
+ if (index === -1) return null;
11
+ if (index === previous + 1) score += 8;
12
+ if (index === 0 || /[\s/_.-]/.test(haystack[index - 1] ?? "")) score += 5;
13
+ score += Math.max(0, 4 - index * 0.05);
14
+ previous = index;
15
+ searchFrom = index + 1;
16
+ }
17
+ return score - haystack.length * 0.01;
18
+ }
19
+
20
+ export function entryType(entry) {
21
+ const link = entry.dataset?.link === "true" || entry.link === true || entry.isLink === true;
22
+ const kind = entry.dataset?.kind ?? entry.kind;
23
+ return link ? "link" : kind === "directory" ? "directory" : "file";
24
+ }
25
+
26
+ export function utcTimestamp(value) {
27
+ if (
28
+ typeof value !== "string" ||
29
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
30
+ )
31
+ return null;
32
+ const instant = new Date(value);
33
+ if (Number.isNaN(instant.getTime())) return null;
34
+ const datetime = instant.toISOString();
35
+ return { datetime, label: `${datetime.slice(0, 16).replace("T", " ")} UTC` };
36
+ }
37
+
38
+ export function localTimestampLabel(value) {
39
+ const timestamp = utcTimestamp(value);
40
+ if (timestamp === null) return null;
41
+ const date = new Date(timestamp.datetime);
42
+ const twoDigits = (number) => String(number).padStart(2, "0");
43
+ const offset = -date.getTimezoneOffset();
44
+ const sign = offset < 0 ? "-" : "+";
45
+ return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())} UTC${sign}${twoDigits(Math.floor(Math.abs(offset) / 60))}:${twoDigits(Math.abs(offset) % 60)}`;
46
+ }
47
+
48
+ export function localTimestampDetails(value) {
49
+ const timestamp = utcTimestamp(value);
50
+ if (timestamp === null) return null;
51
+ const date = new Date(timestamp.datetime);
52
+ const full = localTimestampLabel(timestamp.datetime);
53
+ const seconds = `:${String(date.getSeconds()).padStart(2, "0")}${date.getMilliseconds() ? `.${String(date.getMilliseconds()).padStart(3, "0")}` : ""}`;
54
+ return {
55
+ datetime: timestamp.datetime,
56
+ label: full.split(" UTC")[0],
57
+ local: full.replace(" UTC", `${seconds} UTC`),
58
+ zone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
59
+ };
60
+ }
61
+
62
+ function timestampControl(time) {
63
+ const full = localTimestampLabel(time.dateTime);
64
+ if (full === null) return time;
65
+ time.textContent = full.split(" UTC")[0];
66
+ const button = document.createElement("button");
67
+ button.type = "button";
68
+ button.className = "timestamp-button";
69
+ button.dataset.timestamp = time.dateTime;
70
+ button.setAttribute("aria-label", `${full}. Show exact modified time.`);
71
+ button.setAttribute("aria-haspopup", "dialog");
72
+ button.setAttribute("aria-expanded", "false");
73
+ if (time.isConnected) time.replaceWith(button);
74
+ button.append(time);
75
+ return button;
76
+ }
77
+
78
+ let timestampPanelId = 0;
79
+ function installTimestampDetails(root, globalDialog) {
80
+ let panel = null;
81
+ let trigger = null;
82
+ let anchorPosition = null;
83
+ const observer = new MutationObserver(() => {
84
+ if (panel && (!trigger?.isConnected || !trigger.getClientRects().length)) close();
85
+ });
86
+ function close(restore = true) {
87
+ observer.disconnect();
88
+ panel?.remove();
89
+ panel = null;
90
+ trigger?.setAttribute("aria-expanded", "false");
91
+ trigger?.removeAttribute("aria-controls");
92
+ if (restore && trigger?.isConnected) trigger.focus({ preventScroll: true });
93
+ trigger = null;
94
+ }
95
+ function open(button) {
96
+ const details = localTimestampDetails(button.dataset.timestamp);
97
+ if (details === null) return;
98
+ if (trigger === button) {
99
+ close();
100
+ return;
101
+ }
102
+ close(false);
103
+ trigger = button;
104
+ panel = document.createElement("div");
105
+ panel.className = "timestamp-details";
106
+ panel.id = `timestamp-details-${++timestampPanelId}`;
107
+ panel.setAttribute("role", "dialog");
108
+ panel.setAttribute("aria-label", "Exact modified time");
109
+ panel.innerHTML =
110
+ '<div class="timestamp-details-head"><strong>Exact modified time</strong><button type="button" class="dialog-close" aria-label="Close exact time">Close</button></div><dl><dt data-time-zone></dt><dd data-time-local></dd><dt>UTC</dt><dd data-time-utc></dd></dl>';
111
+ panel.querySelector("[data-time-zone]").textContent = `Local · ${details.zone}`;
112
+ panel.querySelector("[data-time-local]").textContent = details.local;
113
+ panel.querySelector("[data-time-utc]").textContent = details.datetime;
114
+ panel.querySelector("button").addEventListener("click", () => close());
115
+ (button.closest("dialog") ?? root).append(panel);
116
+ button.setAttribute("aria-controls", panel.id);
117
+ button.setAttribute("aria-expanded", "true");
118
+ const anchor = button.getBoundingClientRect();
119
+ anchorPosition = { top: anchor.top, left: anchor.left };
120
+ const bounds = panel.getBoundingClientRect();
121
+ panel.style.left = `${Math.max(8, Math.min(anchor.left, innerWidth - bounds.width - 8))}px`;
122
+ panel.style.top = `${Math.max(8, Math.min(anchor.bottom + 8, innerHeight - bounds.height - 8))}px`;
123
+ panel.querySelector("button").focus({ preventScroll: true });
124
+ observer.observe(root, {
125
+ childList: true,
126
+ subtree: true,
127
+ attributes: true,
128
+ attributeFilter: ["hidden", "open"],
129
+ });
130
+ if (globalDialog)
131
+ observer.observe(globalDialog, {
132
+ childList: true,
133
+ subtree: true,
134
+ attributes: true,
135
+ attributeFilter: ["hidden", "open"],
136
+ });
137
+ }
138
+ const clicked = (event) => {
139
+ const button = event.target.closest("[data-timestamp]");
140
+ if (button) open(button);
141
+ };
142
+ root.addEventListener("click", clicked);
143
+ globalDialog?.addEventListener("click", clicked);
144
+ document.addEventListener("click", (event) => {
145
+ if (panel && !panel.contains(event.target) && !trigger?.contains(event.target)) close(false);
146
+ });
147
+ document.addEventListener(
148
+ "keydown",
149
+ (event) => {
150
+ if (panel && event.key === "Escape") {
151
+ event.preventDefault();
152
+ event.stopPropagation();
153
+ close();
154
+ }
155
+ },
156
+ true,
157
+ );
158
+ document.addEventListener(
159
+ "scroll",
160
+ (event) => {
161
+ if (panel && !panel.contains(event.target)) {
162
+ const position = trigger?.getBoundingClientRect();
163
+ // A queued scroll from revealing the trigger does not move an already placed panel.
164
+ if (
165
+ !position ||
166
+ Math.abs(position.top - anchorPosition.top) > 0.5 ||
167
+ Math.abs(position.left - anchorPosition.left) > 0.5
168
+ )
169
+ close();
170
+ }
171
+ },
172
+ true,
173
+ );
174
+ window.addEventListener("resize", () => {
175
+ if (panel) close();
176
+ });
177
+ }
178
+
179
+ const localeCollator = new Intl.Collator(undefined, { sensitivity: "base" });
180
+ const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
181
+
182
+ function compareName(left, right, mode) {
183
+ if (mode === "unicode") {
184
+ const leftPoints = [...left];
185
+ const rightPoints = [...right];
186
+ const length = Math.min(leftPoints.length, rightPoints.length);
187
+ for (let index = 0; index < length; index += 1) {
188
+ const difference =
189
+ (leftPoints[index]?.codePointAt(0) ?? 0) - (rightPoints[index]?.codePointAt(0) ?? 0);
190
+ if (difference !== 0) return difference;
191
+ }
192
+ return leftPoints.length - rightPoints.length;
193
+ }
194
+ return (mode === "natural" ? naturalCollator : localeCollator).compare(left, right);
195
+ }
196
+
197
+ function isSearchRecord(record) {
198
+ return (
199
+ typeof record === "object" &&
200
+ record !== null &&
201
+ typeof record.name === "string" &&
202
+ typeof record.path === "string" &&
203
+ typeof record.kind === "string" &&
204
+ typeof record.modifiedAt === "string" &&
205
+ typeof record.size === "number" &&
206
+ typeof record.isLink === "boolean" &&
207
+ typeof record.exitsExplorer === "boolean" &&
208
+ (record.href === null || typeof record.href === "string") &&
209
+ (record.target === null || typeof record.target === "string") &&
210
+ (record.targetKind === null || typeof record.targetKind === "string") &&
211
+ (record.targetSize === null || typeof record.targetSize === "number") &&
212
+ typeof record.isTargetUnavailable === "boolean" &&
213
+ typeof record.isCycle === "boolean"
214
+ );
215
+ }
216
+
217
+ export function compareEntryValues(left, right, sort) {
218
+ if (sort.directoriesFirst && left.directory !== right.directory) {
219
+ return left.directory ? -1 : 1;
220
+ }
221
+ let result;
222
+ if (sort.field === "modified") result = left.modified - right.modified;
223
+ else if (sort.field === "size") result = left.size - right.size;
224
+ else result = compareName(left.name, right.name, sort.nameMode);
225
+ if (result === 0) result = compareName(left.name, right.name, sort.nameMode);
226
+ return sort.direction === "asc" ? result : -result;
227
+ }
228
+
229
+ function formatSize(bytes, directory) {
230
+ if (directory) return "directory";
231
+ const units = ["B", "KB", "MB", "GB", "TB"];
232
+ let value = bytes;
233
+ let unit = 0;
234
+ while (value >= 1024 && unit < units.length - 1) {
235
+ value /= 1024;
236
+ unit += 1;
237
+ }
238
+ return `${value.toFixed(unit === 0 || value >= 100 ? 0 : 1)} ${units[unit]}`;
239
+ }
240
+
241
+ function createGlobalEntry(record, indexUrl, order, icons, local = false) {
242
+ const directory = record.kind === "directory" || record.targetKind === "directory";
243
+ const isLink = record.isLink ?? record.kind === "symlink";
244
+ const label = local ? record.name : record.path;
245
+ const entry = document.createElement("li");
246
+ entry.className = "entry";
247
+ entry.dataset.entry = "";
248
+ entry.dataset.order = String(order);
249
+ entry.dataset.search =
250
+ `${record.name} ${local ? "" : record.path} ${record.target ?? ""}`.toLocaleLowerCase();
251
+ entry.dataset.name = record.name;
252
+ entry.dataset.size = String(record.kind === "directory" ? 0 : record.size);
253
+ const timestamp = utcTimestamp(record.modifiedAt);
254
+ entry.dataset.modified = String(timestamp === null ? 0 : Date.parse(timestamp.datetime));
255
+ entry.dataset.directory = String(directory);
256
+ entry.dataset.link = String(isLink);
257
+ entry.dataset.kind = directory
258
+ ? "directory"
259
+ : isLink && record.targetKind === null
260
+ ? "link"
261
+ : "file";
262
+
263
+ const identity = document.createElement("span");
264
+ identity.className = "identity";
265
+ const name = record.href === null ? document.createElement("span") : document.createElement("a");
266
+ name.className = record.href === null ? "name unavailable" : "name";
267
+ if (record.href !== null) {
268
+ name.href = new URL(record.href, indexUrl).href;
269
+ if (record.exitsExplorer) {
270
+ name.target = "_blank";
271
+ name.rel = "noopener";
272
+ }
273
+ }
274
+ const entryName = document.createElement("span");
275
+ entryName.className = "entry-name";
276
+ const extension = record.name.slice(record.name.lastIndexOf(".") + 1).toLowerCase();
277
+ const createIcon = (variant, className) => {
278
+ const icon = document.createElement("img");
279
+ icon.className = className;
280
+ icon.width = 20;
281
+ icon.height = 20;
282
+ icon.alt = "";
283
+ icon.loading = "lazy";
284
+ const fileIcon = Object.hasOwn(variant.byExtension, extension)
285
+ ? variant.byExtension[extension]
286
+ : variant.file;
287
+ icon.src = new URL(directory ? variant.folder : fileIcon, document.baseURI).href;
288
+ return icon;
289
+ };
290
+ const icon = createIcon(icons.light, `icon file-icon${icons.dark ? " file-icon--light" : ""}`);
291
+ const labelNode = document.createElement("span");
292
+ labelNode.textContent = `${label}${directory ? "/" : ""}`;
293
+ entryName.append(icon);
294
+ if (icons.dark) {
295
+ entryName.append(createIcon(icons.dark, "icon file-icon file-icon--dark"));
296
+ }
297
+ entryName.append(labelNode);
298
+ name.append(entryName);
299
+ identity.append(name);
300
+ if (record.target !== null) {
301
+ const target = document.createElement("span");
302
+ target.className = "target";
303
+ if (record.isTargetUnavailable) {
304
+ const status = document.createElement("span");
305
+ status.className = "target-status";
306
+ status.textContent = "Target unavailable";
307
+ const path = document.createElement("span");
308
+ path.className = "target-unavailable";
309
+ path.textContent = record.target;
310
+ target.append(status, " · ", path);
311
+ } else if (record.isCycle) {
312
+ const status = document.createElement("span");
313
+ status.className = "target-status";
314
+ status.textContent = "Cycle";
315
+ target.append(status, ` · ${record.target}`);
316
+ } else {
317
+ target.textContent = `→ ${record.target}`;
318
+ }
319
+ identity.append(target);
320
+ }
321
+ const kind = document.createElement("span");
322
+ kind.className = "kind";
323
+ if (isLink) {
324
+ const sizes = document.createElement("span");
325
+ sizes.className = "size-stack";
326
+ const linkSize = document.createElement("span");
327
+ linkSize.append("Link ");
328
+ const linkValue = document.createElement("b");
329
+ linkValue.textContent = formatSize(record.size, false);
330
+ linkSize.append(linkValue);
331
+ sizes.append(linkSize);
332
+ const targetSize =
333
+ !record.isTargetUnavailable && record.targetKind === "directory"
334
+ ? "folder"
335
+ : record.targetSize === null
336
+ ? null
337
+ : formatSize(record.targetSize, false);
338
+ if (targetSize !== null) {
339
+ const targetLine = document.createElement("span");
340
+ targetLine.append("Target ");
341
+ const targetValue = document.createElement("b");
342
+ targetValue.textContent = targetSize;
343
+ targetLine.append(targetValue);
344
+ sizes.append(targetLine);
345
+ }
346
+ kind.append(sizes);
347
+ } else {
348
+ kind.append(formatSize(record.size, directory));
349
+ }
350
+ let modified = document.createElement(timestamp === null ? "span" : "time");
351
+ if (timestamp !== null) modified.dateTime = timestamp.datetime;
352
+ modified.textContent = timestamp === null ? "Unknown" : timestamp.label;
353
+ if (timestamp !== null) modified = timestampControl(modified);
354
+ entry.append(identity, kind, modified);
355
+ return entry;
356
+ }
357
+
358
+ export class HeightTree {
359
+ constructor(length, estimate = 64) {
360
+ this.length = length;
361
+ this.estimate = estimate;
362
+ this.values = new Float64Array(length);
363
+ this.tree = new Float64Array(length + 1);
364
+ }
365
+
366
+ update(index, height) {
367
+ const previous = this.values[index] || this.estimate;
368
+ const delta = height - previous;
369
+ if (Math.abs(delta) < 0.5) return 0;
370
+ this.values[index] = height;
371
+ for (let cursor = index + 1; cursor <= this.length; cursor += cursor & -cursor) {
372
+ this.tree[cursor] += delta;
373
+ }
374
+ return delta;
375
+ }
376
+
377
+ prefix(count) {
378
+ let delta = 0;
379
+ for (let cursor = count; cursor > 0; cursor -= cursor & -cursor) delta += this.tree[cursor];
380
+ return count * this.estimate + delta;
381
+ }
382
+
383
+ indexAt(offset) {
384
+ let low = 0;
385
+ let high = this.length;
386
+ while (low < high) {
387
+ const middle = Math.floor((low + high + 1) / 2);
388
+ if (this.prefix(middle) <= offset) low = middle;
389
+ else high = middle - 1;
390
+ }
391
+ return Math.min(low, Math.max(0, this.length - 1));
392
+ }
393
+ }
394
+
395
+ function createVirtualList(list, icons) {
396
+ let parent = list.querySelector("[data-parent]");
397
+ let records = [];
398
+ let heights = new HeightTree(0);
399
+ let range = [-1, -1];
400
+ let frame = 0;
401
+ let activeIndex = -1;
402
+ let top = null;
403
+ let bottom = null;
404
+ const rowRecords = new WeakMap();
405
+ const spacer = (height) => {
406
+ const node = document.createElement("li");
407
+ node.className = "virtual-spacer";
408
+ node.setAttribute("aria-hidden", "true");
409
+ node.style.height = `${height}px`;
410
+ return node;
411
+ };
412
+ const observer = new ResizeObserver((entries) => {
413
+ const listTop = list.getBoundingClientRect().top + scrollY + (parent?.offsetHeight ?? 0);
414
+ const anchor = heights.indexAt(Math.max(0, scrollY - listTop));
415
+ let shift = 0;
416
+ for (const entry of entries) {
417
+ const index = Number(entry.target.dataset.virtualIndex);
418
+ const delta = heights.update(
419
+ index,
420
+ entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height,
421
+ );
422
+ if (index < anchor) shift += delta;
423
+ }
424
+ if (shift !== 0) window.scrollBy(0, shift);
425
+ schedule();
426
+ });
427
+
428
+ function render() {
429
+ frame = 0;
430
+ if (records.length === 0) {
431
+ parent = parent?.cloneNode(true) ?? null;
432
+ list.replaceChildren(...(parent ? [parent] : []));
433
+ range = [-1, -1];
434
+ return;
435
+ }
436
+ const listTop = list.getBoundingClientRect().top + scrollY + (parent?.offsetHeight ?? 0);
437
+ const start = Math.max(0, heights.indexAt(Math.max(0, scrollY - listTop)) - 8);
438
+ const end = Math.min(
439
+ records.length,
440
+ heights.indexAt(Math.max(0, scrollY + innerHeight - listTop)) + 9,
441
+ );
442
+ if (range[0] !== start || range[1] !== end) {
443
+ const focusedIndex = Number(
444
+ document.activeElement?.closest("[data-virtual-index]")?.dataset.virtualIndex,
445
+ );
446
+ const timestampFocused = document.activeElement?.matches("[data-timestamp]");
447
+ const existingRows = new Map(
448
+ [...list.querySelectorAll("[data-virtual-index]")].map((row) => [
449
+ Number(row.dataset.virtualIndex),
450
+ row,
451
+ ]),
452
+ );
453
+ observer.disconnect();
454
+ const fragment = document.createDocumentFragment();
455
+ const nextParent = parent?.cloneNode(true) ?? null;
456
+ const nextTop = spacer(heights.prefix(start));
457
+ const nextBottom = spacer(heights.prefix(records.length) - heights.prefix(end));
458
+ if (nextParent) fragment.append(nextParent);
459
+ fragment.append(nextTop);
460
+ for (let index = start; index < end; index += 1) {
461
+ const candidate = existingRows.get(index);
462
+ const row =
463
+ candidate && rowRecords.get(candidate) === records[index]
464
+ ? candidate
465
+ : createGlobalEntry(records[index], document.baseURI, index, icons, true);
466
+ rowRecords.set(row, records[index]);
467
+ row.dataset.virtualIndex = String(index);
468
+ row.setAttribute("aria-posinset", String(index + 1));
469
+ row.setAttribute("aria-setsize", String(records.length));
470
+ if (index === activeIndex) row.dataset.active = "true";
471
+ fragment.append(row);
472
+ }
473
+ fragment.append(nextBottom);
474
+ list.replaceChildren(fragment);
475
+ parent = nextParent;
476
+ top = nextTop;
477
+ bottom = nextBottom;
478
+ if (Number.isInteger(focusedIndex) && focusedIndex >= start && focusedIndex < end) {
479
+ list
480
+ .querySelector(
481
+ `[data-virtual-index="${focusedIndex}"] ${timestampFocused ? "[data-timestamp]" : "a"}`,
482
+ )
483
+ ?.focus({ preventScroll: true });
484
+ }
485
+ for (const row of list.querySelectorAll("[data-virtual-index]")) observer.observe(row);
486
+ range = [start, end];
487
+ }
488
+ top.style.height = `${heights.prefix(start)}px`;
489
+ bottom.style.height = `${heights.prefix(records.length) - heights.prefix(end)}px`;
490
+ }
491
+
492
+ function schedule() {
493
+ if (frame === 0) frame = requestAnimationFrame(render);
494
+ }
495
+
496
+ window.addEventListener("scroll", schedule, { passive: true });
497
+ window.addEventListener("resize", () => {
498
+ const listTop = list.getBoundingClientRect().top + scrollY + (parent?.offsetHeight ?? 0);
499
+ const anchor = heights.indexAt(Math.max(0, scrollY - listTop));
500
+ const offset = scrollY - listTop - heights.prefix(anchor);
501
+ heights = new HeightTree(records.length);
502
+ range = [-1, -1];
503
+ render();
504
+ window.scrollTo(0, listTop + heights.prefix(anchor) + offset);
505
+ schedule();
506
+ });
507
+
508
+ return {
509
+ setRecords(next) {
510
+ const focusedIndex = Number(
511
+ document.activeElement?.closest("[data-virtual-index]")?.dataset.virtualIndex,
512
+ );
513
+ const timestampFocused = document.activeElement?.matches("[data-timestamp]");
514
+ const focusedRecord = Number.isInteger(focusedIndex) ? records[focusedIndex] : undefined;
515
+ if (focusedRecord) document.activeElement.blur();
516
+ records = next;
517
+ heights = new HeightTree(next.length);
518
+ range = [-1, -1];
519
+ activeIndex = focusedRecord ? next.indexOf(focusedRecord) : -1;
520
+ render();
521
+ if (activeIndex >= 0) this.focus(activeIndex, 1, timestampFocused ? "[data-timestamp]" : "a");
522
+ },
523
+ focus(index, direction = 1, control = "a") {
524
+ if (records.length === 0) return;
525
+ let next = Math.max(0, Math.min(index, records.length - 1));
526
+ while (control === "a" && next >= 0 && next < records.length && records[next].href === null) {
527
+ next += direction;
528
+ }
529
+ if (next < 0 || next >= records.length) return;
530
+ activeIndex = next;
531
+ const listTop = list.getBoundingClientRect().top + scrollY + (parent?.offsetHeight ?? 0);
532
+ const stickyInset =
533
+ Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0;
534
+ window.scrollTo(0, listTop + heights.prefix(activeIndex) - stickyInset);
535
+ render();
536
+ const link = list.querySelector(`[data-virtual-index="${activeIndex}"] ${control}`);
537
+ for (const row of list.querySelectorAll("[data-virtual-index]")) {
538
+ if (Number(row.dataset.virtualIndex) === activeIndex) row.dataset.active = "true";
539
+ else delete row.dataset.active;
540
+ }
541
+ link?.focus({ preventScroll: true });
542
+ },
543
+ get activeIndex() {
544
+ return activeIndex;
545
+ },
546
+ get length() {
547
+ return records.length;
548
+ },
549
+ };
550
+ }
551
+
552
+ function initializeExplorer(root) {
553
+ const config = JSON.parse(root.dataset.config ?? "{}");
554
+ for (const time of root.querySelectorAll("time[datetime]")) {
555
+ timestampControl(time);
556
+ }
557
+ const stickyHeader = root.querySelector("header");
558
+ const stickyColumns = root.querySelector(".entry-head");
559
+ if (stickyHeader) {
560
+ const updateStickyHeights = () => {
561
+ const headerHeight = stickyHeader.getBoundingClientRect().height;
562
+ const headerTooTall = headerHeight > Math.min(innerHeight * 0.25, 192);
563
+ root.toggleAttribute("data-sticky-header-disabled", headerTooTall);
564
+ document.documentElement.style.setProperty(
565
+ "--dw-sticky-header-height",
566
+ `${headerTooTall ? 0 : headerHeight}px`,
567
+ );
568
+ if (stickyColumns) {
569
+ document.documentElement.style.setProperty(
570
+ "--dw-sticky-columns-height",
571
+ `${stickyColumns.getBoundingClientRect().height}px`,
572
+ );
573
+ }
574
+ };
575
+ if (typeof ResizeObserver !== "undefined") {
576
+ const observer = new ResizeObserver(updateStickyHeights);
577
+ observer.observe(stickyHeader);
578
+ if (stickyColumns) observer.observe(stickyColumns);
579
+ }
580
+ window.addEventListener("resize", updateStickyHeights, { passive: true });
581
+ updateStickyHeights();
582
+ }
583
+ const list = root.querySelector("[data-entry-list]");
584
+ const localEntries = [...root.querySelectorAll("[data-entry]")];
585
+ const input = root.querySelector("[data-search-input]");
586
+ const globalDialog = document.querySelector("[data-global-dialog]");
587
+ installTimestampDetails(root, globalDialog);
588
+ const modifiedHeading = root.querySelector(".entry-head .sort-heading:last-child");
589
+ if (modifiedHeading) {
590
+ const zone = document.createElement("span");
591
+ zone.className = "timestamp-zone";
592
+ zone.textContent = "local";
593
+ modifiedHeading.append(zone);
594
+ }
595
+ const globalInput = globalDialog?.querySelector("[data-global-input]");
596
+ const globalTypeFilters = globalDialog?.querySelector("[data-type-filters]");
597
+ const globalResults = globalDialog?.querySelector("[data-global-results]");
598
+ const globalStatus = globalDialog?.querySelector("[data-global-status]");
599
+ const typeFilters = root.querySelector("[data-type-filters]");
600
+ const count = root.querySelector("[data-visible-count]");
601
+ const countLabel = root.querySelector("[data-count-label]");
602
+ const empty = root.querySelector("[data-empty]");
603
+ const status = root.querySelector("[data-live-status]");
604
+ const folderLoading = root.querySelector("[data-folder-loading]");
605
+ const sortField = root.querySelector("[data-sort-field]");
606
+ const nameMode = root.querySelector("[data-name-mode]");
607
+ const sortDirection = root.querySelector("[data-sort-direction]");
608
+ const directoriesFirst = root.querySelector("[data-directories-first]");
609
+ const nameModeControl = root.querySelector("[data-name-mode-control]");
610
+ let virtualRows = null;
611
+ let virtualList = null;
612
+ let virtualWorker = null;
613
+ let virtualGeneration = 0;
614
+ let virtualTimer = 0;
615
+ let visibleVirtualRows = [];
616
+ let manifestPromise = null;
617
+ const shardCache = new Map();
618
+ let globalGeneration = 0;
619
+ let composing = false;
620
+ const selectedTypes = (control) =>
621
+ new Set(
622
+ [...control.querySelectorAll("[data-type-filter]:checked")].map((input) => input.value),
623
+ );
624
+ let localTypes = typeFilters
625
+ ? selectedTypes(typeFilters)
626
+ : new Set(["directory", "file", "link"]);
627
+ let globalTypes = globalTypeFilters
628
+ ? selectedTypes(globalTypeFilters)
629
+ : new Set(["directory", "file", "link"]);
630
+
631
+ const storage = {
632
+ get(area, key) {
633
+ try {
634
+ return area.getItem(key);
635
+ } catch {
636
+ return null;
637
+ }
638
+ },
639
+ set(area, key, value) {
640
+ try {
641
+ area.setItem(key, value);
642
+ } catch {
643
+ // Storage is optional in private or sandboxed browsing contexts.
644
+ }
645
+ },
646
+ };
647
+ const searchStorageKey = `dirwell-search:${location.pathname}`;
648
+ const sortStorageKey = "dirwell-sort";
649
+ const storedSort = storage.get(localStorage, sortStorageKey);
650
+ let sort = { ...config.sort };
651
+ if (storedSort !== null) {
652
+ try {
653
+ const parsed = JSON.parse(storedSort);
654
+ if (["name", "modified", "size"].includes(parsed.field)) sort.field = parsed.field;
655
+ if (["unicode", "locale", "natural"].includes(parsed.nameMode)) {
656
+ sort.nameMode = parsed.nameMode;
657
+ }
658
+ if (["asc", "desc"].includes(parsed.direction)) sort.direction = parsed.direction;
659
+ if (typeof parsed.directoriesFirst === "boolean") {
660
+ sort.directoriesFirst = parsed.directoriesFirst;
661
+ }
662
+ } catch {
663
+ // Ignore stale or invalid preferences.
664
+ }
665
+ }
666
+
667
+ const entryValue = (entry) => ({
668
+ directory: entry.dataset.directory === "true",
669
+ modified: Number(entry.dataset.modified),
670
+ name: entry.dataset.name ?? "",
671
+ size: Number(entry.dataset.size),
672
+ });
673
+
674
+ const matchesFilter = (entry) => localTypes.has(entryType(entry));
675
+
676
+ const updateEmpty = (visible) => {
677
+ if (empty) {
678
+ empty.hidden = visible !== 0;
679
+ empty.textContent =
680
+ localTypes.size === 0 ? "Select a file type to show entries." : "No matching entries.";
681
+ }
682
+ };
683
+
684
+ const visibleLinks = () =>
685
+ localEntries
686
+ .filter((entry) => !entry.hidden)
687
+ .map((entry) => entry.querySelector("a"))
688
+ .filter(Boolean);
689
+
690
+ const updateSortControls = () => {
691
+ if (sortField) sortField.value = sort.field;
692
+ if (nameMode) nameMode.value = sort.nameMode;
693
+ if (sortDirection) sortDirection.value = sort.direction;
694
+ if (directoriesFirst) directoriesFirst.checked = sort.directoriesFirst;
695
+ if (nameModeControl) nameModeControl.hidden = sort.field !== "name";
696
+ for (const heading of root.querySelectorAll("[data-sort-heading]")) {
697
+ const active = heading.dataset.sortHeading === sort.field;
698
+ heading.setAttribute("aria-pressed", String(active));
699
+ heading.dataset.direction = active ? sort.direction : "";
700
+ heading.setAttribute(
701
+ "aria-label",
702
+ active
703
+ ? `Sort by ${heading.dataset.sortHeading}, ${sort.direction === "asc" ? "ascending" : "descending"}`
704
+ : `Sort by ${heading.dataset.sortHeading}`,
705
+ );
706
+ }
707
+ };
708
+
709
+ const updateResults = () => {
710
+ if ((!config.fuzzySearch && !config.sorting && !config.entriesHref) || composing) return;
711
+ if (config.entriesHref && virtualRows === null) return;
712
+ const query = input?.value ?? "";
713
+ if (virtualRows !== null) {
714
+ if (virtualWorker) {
715
+ const generation = ++virtualGeneration;
716
+ clearTimeout(virtualTimer);
717
+ root.setAttribute("aria-busy", "true");
718
+ virtualTimer = setTimeout(
719
+ () =>
720
+ virtualWorker?.postMessage({
721
+ type: "query",
722
+ generation,
723
+ query: query.trim(),
724
+ types: [...localTypes],
725
+ sort,
726
+ }),
727
+ 70,
728
+ );
729
+ return;
730
+ }
731
+ const matches = [];
732
+ for (let order = 0; order < virtualRows.length; order += 1) {
733
+ const row = virtualRows[order];
734
+ if (!matchesFilter(row)) continue;
735
+ const score = fuzzyScore(query, row.search);
736
+ if (query.trim() !== "" && score === null) continue;
737
+ matches.push({ row, order, score });
738
+ }
739
+ visibleVirtualRows = matches
740
+ .sort((left, right) => {
741
+ if (query.trim() !== "" && left.score !== right.score)
742
+ return (right.score ?? 0) - (left.score ?? 0);
743
+ return compareEntryValues(left.row, right.row, sort) || left.order - right.order;
744
+ })
745
+ .map(({ row }) => row);
746
+ virtualList.setRecords(visibleVirtualRows);
747
+ const visible = visibleVirtualRows.length;
748
+ if (count) count.textContent = String(visible);
749
+ if (countLabel) countLabel.textContent = visible === 1 ? "entry" : "entries";
750
+ updateEmpty(visible);
751
+ if (status) status.textContent = `${visible} ${visible === 1 ? "entry" : "entries"} shown`;
752
+ folderLoading?.remove();
753
+ root.removeAttribute("aria-busy");
754
+ return;
755
+ }
756
+ const matches = localEntries
757
+ .map((entry) => ({
758
+ entry,
759
+ order: Number(entry.dataset.order),
760
+ score: fuzzyScore(query, entry.dataset.search ?? ""),
761
+ }))
762
+ .filter(({ entry, score }) => matchesFilter(entry) && (query.trim() === "" || score !== null))
763
+ .sort((left, right) => {
764
+ if (query.trim() !== "" && left.score !== right.score) {
765
+ return (right.score ?? 0) - (left.score ?? 0);
766
+ }
767
+ return (
768
+ compareEntryValues(entryValue(left.entry), entryValue(right.entry), sort) ||
769
+ left.order - right.order
770
+ );
771
+ });
772
+ const matched = new Set(matches.map(({ entry }) => entry));
773
+ for (const entry of localEntries) entry.hidden = !matched.has(entry);
774
+ for (const { entry } of matches) list?.append(entry);
775
+ const visible = matches.length;
776
+ if (count) count.textContent = String(visible);
777
+ if (countLabel) countLabel.textContent = visible === 1 ? "entry" : "entries";
778
+ updateEmpty(visible);
779
+ if (status) status.textContent = `${visible} ${visible === 1 ? "entry" : "entries"} shown`;
780
+ };
781
+
782
+ const readJson = async (url) => {
783
+ const response = await fetch(url);
784
+ if (!response.ok) throw new Error(`Search index returned ${response.status}`);
785
+ return response.json();
786
+ };
787
+ const indexUrl = new URL(config.searchIndexHref, document.baseURI);
788
+ const loadManifest = async () => {
789
+ if (manifestPromise === null) {
790
+ manifestPromise = readJson(indexUrl)
791
+ .then((manifest) => {
792
+ if (
793
+ manifest?.version !== 3 ||
794
+ !Array.isArray(manifest.shards) ||
795
+ !manifest.shards.every((name) => /^search-\d{5}\.json$/.test(name))
796
+ ) {
797
+ throw new Error("Search index has an unsupported format");
798
+ }
799
+ return manifest;
800
+ })
801
+ .catch((error) => {
802
+ manifestPromise = null;
803
+ throw error;
804
+ });
805
+ }
806
+ return manifestPromise;
807
+ };
808
+ const loadShard = async (name) => {
809
+ if (!shardCache.has(name)) {
810
+ const url = new URL(name, indexUrl);
811
+ shardCache.set(
812
+ name,
813
+ readJson(url)
814
+ .then((data) => {
815
+ if (!Array.isArray(data?.entries))
816
+ throw new Error("Search shard has an unsupported format");
817
+ return data.entries.filter(isSearchRecord);
818
+ })
819
+ .catch((error) => {
820
+ shardCache.delete(name);
821
+ throw error;
822
+ }),
823
+ );
824
+ }
825
+ return shardCache.get(name);
826
+ };
827
+ const matchesGlobalFilter = (record) => globalTypes.has(entryType(record));
828
+ let globalTimer = 0;
829
+ const searchGlobal = async () => {
830
+ const generation = ++globalGeneration;
831
+ const query = globalInput?.value.trim() ?? "";
832
+ if (query === "") {
833
+ globalResults?.replaceChildren();
834
+ if (globalStatus) globalStatus.textContent = "Enter a search to begin.";
835
+ return;
836
+ }
837
+ if (globalTypes.size === 0) {
838
+ globalResults?.replaceChildren();
839
+ if (globalStatus) globalStatus.textContent = "Select a file type to search.";
840
+ return;
841
+ }
842
+ if (globalStatus) globalStatus.textContent = "Searching published files…";
843
+ const best = [];
844
+ let found = 0;
845
+ try {
846
+ const manifest = await loadManifest();
847
+ for (let offset = 0; offset < manifest.shards.length; offset += 4) {
848
+ const batch = await Promise.all(manifest.shards.slice(offset, offset + 4).map(loadShard));
849
+ if (generation !== globalGeneration || !globalDialog?.open) return;
850
+ for (const entries of batch) {
851
+ for (const record of entries) {
852
+ if (!matchesGlobalFilter(record)) continue;
853
+ const score = fuzzyScore(query, `${record.name} ${record.path} ${record.target ?? ""}`);
854
+ if (score === null) continue;
855
+ found += 1;
856
+ best.push({ record, score });
857
+ }
858
+ }
859
+ best.sort(
860
+ (left, right) =>
861
+ right.score - left.score || left.record.path.localeCompare(right.record.path),
862
+ );
863
+ best.length = Math.min(best.length, 100);
864
+ globalResults?.replaceChildren(
865
+ ...best.map(({ record }, index) =>
866
+ createGlobalEntry(record, indexUrl, index, config.icons),
867
+ ),
868
+ );
869
+ if (globalStatus)
870
+ globalStatus.textContent = `${found} matches · ${Math.min(offset + 4, manifest.shards.length)} of ${manifest.shards.length} index parts searched${found > 100 ? " · showing best 100" : ""}`;
871
+ await new Promise((resolve) => setTimeout(resolve, 0));
872
+ }
873
+ if (found === 0 && globalStatus)
874
+ globalStatus.textContent = "No matches. Try a shorter name or path.";
875
+ } catch (error) {
876
+ if (generation !== globalGeneration) return;
877
+ if (globalStatus)
878
+ globalStatus.textContent = `${error instanceof Error ? error.message : "Search failed"}. Try again.`;
879
+ }
880
+ };
881
+ root.querySelector("[data-global-open]")?.addEventListener("click", () => {
882
+ globalDialog?.showModal();
883
+ globalInput?.focus();
884
+ });
885
+ globalDialog
886
+ ?.querySelector("[data-global-close]")
887
+ ?.addEventListener("click", () => globalDialog.close());
888
+ globalDialog?.addEventListener("close", () => {
889
+ globalGeneration += 1;
890
+ });
891
+ globalInput?.addEventListener("compositionstart", () => {
892
+ globalInput.dataset.composing = "true";
893
+ });
894
+ globalInput?.addEventListener("compositionend", () => {
895
+ delete globalInput.dataset.composing;
896
+ searchGlobal();
897
+ });
898
+ globalInput?.addEventListener("input", () => {
899
+ if (globalInput.dataset.composing) return;
900
+ clearTimeout(globalTimer);
901
+ globalTimer = setTimeout(searchGlobal, 120);
902
+ });
903
+ globalTypeFilters?.addEventListener("change", () => {
904
+ globalTypes = selectedTypes(globalTypeFilters);
905
+ searchGlobal();
906
+ });
907
+
908
+ const updateFilter = () => {
909
+ localTypes = selectedTypes(typeFilters);
910
+ updateResults();
911
+ };
912
+
913
+ if (config.fuzzySearch && input) {
914
+ input.value = storage.get(sessionStorage, searchStorageKey) ?? "";
915
+ input.addEventListener("compositionstart", () => {
916
+ composing = true;
917
+ });
918
+ input.addEventListener("compositionend", () => {
919
+ composing = false;
920
+ storage.set(sessionStorage, searchStorageKey, input.value);
921
+ updateResults();
922
+ });
923
+ input.addEventListener("input", () => {
924
+ if (!composing) storage.set(sessionStorage, searchStorageKey, input.value);
925
+ updateResults();
926
+ });
927
+ typeFilters?.addEventListener("change", updateFilter);
928
+ }
929
+
930
+ if (config.sorting) {
931
+ const setSort = (patch) => {
932
+ sort = { ...sort, ...patch };
933
+ storage.set(localStorage, sortStorageKey, JSON.stringify(sort));
934
+ updateSortControls();
935
+ updateResults();
936
+ };
937
+ sortField?.addEventListener("change", () => setSort({ field: sortField.value }));
938
+ nameMode?.addEventListener("change", () => setSort({ nameMode: nameMode.value }));
939
+ sortDirection?.addEventListener("change", () => setSort({ direction: sortDirection.value }));
940
+ directoriesFirst?.addEventListener("change", () =>
941
+ setSort({ directoriesFirst: directoriesFirst.checked }),
942
+ );
943
+ for (const heading of root.querySelectorAll("[data-sort-heading]")) {
944
+ heading.addEventListener("click", () => {
945
+ const field = heading.dataset.sortHeading;
946
+ setSort({
947
+ direction: sort.field === field && sort.direction === "asc" ? "desc" : "asc",
948
+ field,
949
+ });
950
+ });
951
+ }
952
+ updateSortControls();
953
+ }
954
+
955
+ updateResults();
956
+
957
+ if (config.entriesHref && list) {
958
+ root.setAttribute("aria-busy", "true");
959
+ if (status) status.textContent = "Loading this folder";
960
+ readJson(new URL(config.entriesHref, document.baseURI))
961
+ .then((data) => {
962
+ if (data?.version !== 2 || !Array.isArray(data.rows))
963
+ throw new Error("Folder data has an unsupported format");
964
+ virtualRows = data.rows.map((row) => ({
965
+ ...row,
966
+ directory: row.kind === "directory" || row.targetKind === "directory",
967
+ link: row.kind === "symlink",
968
+ isLink: row.kind === "symlink",
969
+ size: row.kind === "directory" ? 0 : row.size,
970
+ kind:
971
+ row.kind === "directory" || row.targetKind === "directory"
972
+ ? "directory"
973
+ : row.kind === "symlink" && row.targetKind === null
974
+ ? "link"
975
+ : "file",
976
+ search: `${row.name} ${row.target ?? ""}`.toLocaleLowerCase(),
977
+ modified: Date.parse(row.modifiedAt) || 0,
978
+ }));
979
+ virtualList = createVirtualList(list, config.icons);
980
+ if (config.workerHref && typeof Worker !== "undefined") {
981
+ try {
982
+ virtualWorker = new Worker(new URL(config.workerHref, document.baseURI), {
983
+ type: "module",
984
+ });
985
+ virtualWorker.addEventListener("message", ({ data: result }) => {
986
+ if (result.generation !== virtualGeneration) return;
987
+ visibleVirtualRows = Array.from(result.indices, (index) => virtualRows[index]);
988
+ virtualList.setRecords(visibleVirtualRows);
989
+ const visible = visibleVirtualRows.length;
990
+ if (count) count.textContent = String(visible);
991
+ if (countLabel) countLabel.textContent = visible === 1 ? "entry" : "entries";
992
+ updateEmpty(visible);
993
+ if (status)
994
+ status.textContent = `${visible} ${visible === 1 ? "entry" : "entries"} shown`;
995
+ folderLoading?.remove();
996
+ root.removeAttribute("aria-busy");
997
+ });
998
+ virtualWorker.addEventListener("error", () => {
999
+ virtualWorker?.terminate();
1000
+ virtualWorker = null;
1001
+ updateResults();
1002
+ });
1003
+ virtualWorker.postMessage({
1004
+ type: "init",
1005
+ rows: virtualRows.map(({ name, search, size, modified, directory, link, kind }) => ({
1006
+ name,
1007
+ search,
1008
+ size,
1009
+ modified,
1010
+ directory,
1011
+ link,
1012
+ kind,
1013
+ })),
1014
+ });
1015
+ } catch {
1016
+ virtualWorker = null;
1017
+ }
1018
+ }
1019
+ updateResults();
1020
+ })
1021
+ .catch((error) => {
1022
+ folderLoading?.remove();
1023
+ if (empty) {
1024
+ empty.hidden = false;
1025
+ empty.textContent = `${error instanceof Error ? error.message : "Folder data failed"}. Reload to try again.`;
1026
+ }
1027
+ })
1028
+ .finally(() => {
1029
+ if (!virtualWorker) root.removeAttribute("aria-busy");
1030
+ });
1031
+ }
1032
+
1033
+ if (config.colorScheme) {
1034
+ const schemeSelect = root.querySelector("[data-color-scheme]");
1035
+ const saved = storage.get(localStorage, "dirwell-theme");
1036
+ const initial = ["system", "light", "dark"].includes(saved) ? saved : "system";
1037
+ const setTheme = (theme) => {
1038
+ document.documentElement.dataset.theme = theme;
1039
+ storage.set(localStorage, "dirwell-theme", theme);
1040
+ if (schemeSelect) schemeSelect.value = theme;
1041
+ };
1042
+ setTheme(initial);
1043
+ schemeSelect?.addEventListener("change", () => setTheme(schemeSelect.value));
1044
+ }
1045
+
1046
+ if (config.keyboardNavigation) {
1047
+ document.addEventListener("keydown", (event) => {
1048
+ if (
1049
+ event.isComposing ||
1050
+ event.keyCode === 229 ||
1051
+ event.metaKey ||
1052
+ event.ctrlKey ||
1053
+ event.altKey
1054
+ ) {
1055
+ return;
1056
+ }
1057
+ if (globalDialog?.open) return;
1058
+ if (event.target.closest(".timestamp-details,[data-timestamp]")) return;
1059
+ const editable = event.target.matches("input,textarea,select,[contenteditable='true']");
1060
+ if (event.key === "/" && !editable && input) {
1061
+ event.preventDefault();
1062
+ input.focus();
1063
+ return;
1064
+ }
1065
+ if (
1066
+ event.key === "Escape" &&
1067
+ input &&
1068
+ (event.target === input || (!editable && input.value !== ""))
1069
+ ) {
1070
+ event.preventDefault();
1071
+ input.value = "";
1072
+ storage.set(sessionStorage, searchStorageKey, "");
1073
+ updateResults();
1074
+ input.blur();
1075
+ return;
1076
+ }
1077
+ if (editable) return;
1078
+ if (event.key === "Backspace") {
1079
+ event.preventDefault();
1080
+ if (root.dataset.parentHref) location.assign(root.dataset.parentHref);
1081
+ return;
1082
+ }
1083
+ if (virtualList) {
1084
+ let index = null;
1085
+ let direction = 1;
1086
+ if (event.key === "ArrowDown" || event.key === "j") index = virtualList.activeIndex + 1;
1087
+ if (event.key === "ArrowUp" || event.key === "k") {
1088
+ index = Math.max(virtualList.activeIndex - 1, 0);
1089
+ direction = virtualList.activeIndex === -1 ? 1 : -1;
1090
+ }
1091
+ if (event.key === "Home") index = 0;
1092
+ if (event.key === "End") {
1093
+ index = virtualList.length - 1;
1094
+ direction = -1;
1095
+ }
1096
+ if (index !== null) {
1097
+ event.preventDefault();
1098
+ virtualList.focus(index, direction);
1099
+ }
1100
+ return;
1101
+ }
1102
+ const links = visibleLinks();
1103
+ if (links.length === 0) return;
1104
+ const current = links.indexOf(document.activeElement);
1105
+ let next = null;
1106
+ if (event.key === "ArrowDown" || event.key === "j") {
1107
+ next = links[Math.min(current + 1, links.length - 1)];
1108
+ }
1109
+ if (event.key === "ArrowUp" || event.key === "k") {
1110
+ next = links[Math.max(current - 1, 0)];
1111
+ }
1112
+ if (event.key === "Home") next = links[0];
1113
+ if (event.key === "End") next = links.at(-1);
1114
+ if (next) {
1115
+ event.preventDefault();
1116
+ next.focus();
1117
+ next.closest("[data-entry]")?.setAttribute("data-active", "true");
1118
+ for (const entry of localEntries) {
1119
+ if (!entry.contains(next)) entry.removeAttribute("data-active");
1120
+ }
1121
+ }
1122
+ });
1123
+ }
1124
+ }
1125
+
1126
+ if (typeof document !== "undefined") {
1127
+ for (const root of document.querySelectorAll("[data-explorer]")) initializeExplorer(root);
1128
+ }