sol-components 2.1.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 (150) hide show
  1. package/README.md +7 -0
  2. package/core/activate.js +27 -0
  3. package/core/adopt.js +71 -0
  4. package/core/auth-core.js +73 -0
  5. package/core/auth-fetch.js +154 -0
  6. package/core/component-mount.js +110 -0
  7. package/core/defaults.js +48 -0
  8. package/core/define.js +15 -0
  9. package/core/display-target.js +166 -0
  10. package/core/edit-placements.js +28 -0
  11. package/core/editor-self.js +127 -0
  12. package/core/editor.js +162 -0
  13. package/core/events.js +27 -0
  14. package/core/extension-points.js +189 -0
  15. package/core/form-utils.js +210 -0
  16. package/core/from-query.js +138 -0
  17. package/core/from-rdf.js +52 -0
  18. package/core/here.js +33 -0
  19. package/core/include-core.js +73 -0
  20. package/core/inrupt-global.js +18 -0
  21. package/core/menu-consumer.js +41 -0
  22. package/core/menu-rdf.js +154 -0
  23. package/core/pod-ops.js +392 -0
  24. package/core/pod-registry.js +82 -0
  25. package/core/popup-proxy.js +255 -0
  26. package/core/rdf-core.js +280 -0
  27. package/core/rdf-render.js +136 -0
  28. package/core/rdf-utils.js +411 -0
  29. package/core/rdf.js +154 -0
  30. package/core/services.js +106 -0
  31. package/core/shape-to-form.js +741 -0
  32. package/core/sparql-safety.js +20 -0
  33. package/core/utils.js +196 -0
  34. package/dist/importmap-cdn.json +49 -0
  35. package/dist/importmap-local.json +49 -0
  36. package/dist/sol-loader.manifest.json +140 -0
  37. package/dist/vendor/@comunica-query-sparql.js +137851 -0
  38. package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
  39. package/dist/vendor/dompurify.js +1476 -0
  40. package/dist/vendor/ical.js.js +9739 -0
  41. package/dist/vendor/marked.js +85 -0
  42. package/dist/vendor/n3.js +14670 -0
  43. package/dist/vendor/rdf-validate-shacl.js +6970 -0
  44. package/dist/vendor/rdflib.js +35172 -0
  45. package/dist/vendor/solid-logic.js +6819 -0
  46. package/dist/vendor/solid-ui.js +21945 -0
  47. package/node/sol-form.js +133 -0
  48. package/node/sol-include.js +55 -0
  49. package/node/sol-login.js +632 -0
  50. package/node/sol-menu.js +639 -0
  51. package/node/sol-query.js +116 -0
  52. package/package.json +133 -0
  53. package/web/menu-from-rdf.js +23 -0
  54. package/web/scripts/prefs.js +25 -0
  55. package/web/sol-accordion.js +114 -0
  56. package/web/sol-basic.js +50 -0
  57. package/web/sol-breadcrumb.js +131 -0
  58. package/web/sol-button.js +244 -0
  59. package/web/sol-calendar.js +465 -0
  60. package/web/sol-default.js +118 -0
  61. package/web/sol-dropdown-button.js +222 -0
  62. package/web/sol-feed.js +1336 -0
  63. package/web/sol-form.js +949 -0
  64. package/web/sol-full.js +43 -0
  65. package/web/sol-gallery.js +303 -0
  66. package/web/sol-include.js +246 -0
  67. package/web/sol-live-edit.js +415 -0
  68. package/web/sol-login.js +856 -0
  69. package/web/sol-menu.js +593 -0
  70. package/web/sol-modal.js +377 -0
  71. package/web/sol-pod-extras.js +17 -0
  72. package/web/sol-pod-ops.js +680 -0
  73. package/web/sol-pod.js +1039 -0
  74. package/web/sol-query.js +546 -0
  75. package/web/sol-rolodex.js +95 -0
  76. package/web/sol-search.js +402 -0
  77. package/web/sol-settings.js +199 -0
  78. package/web/sol-solidos.js +93 -0
  79. package/web/sol-tabs.js +445 -0
  80. package/web/sol-time.js +194 -0
  81. package/web/sol-tree-edit.js +492 -0
  82. package/web/sol-wac.js +456 -0
  83. package/web/sol-weather.js +337 -0
  84. package/web/sol-window.js +142 -0
  85. package/web/styles/buttons-css.js +108 -0
  86. package/web/styles/help.css +242 -0
  87. package/web/styles/root.css +112 -0
  88. package/web/styles/sol-accordion-css.js +97 -0
  89. package/web/styles/sol-calendar-css.js +154 -0
  90. package/web/styles/sol-feed-css.js +475 -0
  91. package/web/styles/sol-form-css.js +471 -0
  92. package/web/styles/sol-gallery-css.js +181 -0
  93. package/web/styles/sol-include-css.js +95 -0
  94. package/web/styles/sol-live-edit-css.js +84 -0
  95. package/web/styles/sol-live-edit.css +101 -0
  96. package/web/styles/sol-login-css.js +116 -0
  97. package/web/styles/sol-menu-css.js +145 -0
  98. package/web/styles/sol-modal-css.js +134 -0
  99. package/web/styles/sol-pod-css.js +187 -0
  100. package/web/styles/sol-pod-modal-css.js +203 -0
  101. package/web/styles/sol-query-css.js +140 -0
  102. package/web/styles/sol-query-help.css +267 -0
  103. package/web/styles/sol-query-one-pager.css +67 -0
  104. package/web/styles/sol-search-css.js +157 -0
  105. package/web/styles/sol-solidos-css.js +7 -0
  106. package/web/styles/sol-tabs-css.js +114 -0
  107. package/web/styles/sol-time-css.js +30 -0
  108. package/web/styles/sol-wac-css.js +73 -0
  109. package/web/styles/sol-weather-css.js +59 -0
  110. package/web/styles/solid-logo.svg +9 -0
  111. package/web/styles/view-accordion-css.js +66 -0
  112. package/web/styles/view-anchorlist-css.js +22 -0
  113. package/web/styles/view-autocomplete-css.js +59 -0
  114. package/web/styles/view-rolodex-css.js +102 -0
  115. package/web/styles/view-select-css.js +21 -0
  116. package/web/utils/calendar-fetch.js +388 -0
  117. package/web/utils/code-mirror-editor.js +82 -0
  118. package/web/utils/commons-fetch.js +108 -0
  119. package/web/utils/feed-edit.js +159 -0
  120. package/web/utils/feed-edit.smoke.mjs +74 -0
  121. package/web/utils/feed-fetch.js +573 -0
  122. package/web/utils/live-edit-help/csv.js +64 -0
  123. package/web/utils/live-edit-help/graphviz.js +41 -0
  124. package/web/utils/live-edit-help/jsonld.js +55 -0
  125. package/web/utils/live-edit-help/markdown.js +52 -0
  126. package/web/utils/live-edit-help/mermaid.js +48 -0
  127. package/web/utils/live-edit-help/turtle.js +85 -0
  128. package/web/utils/rdf-config.js +125 -0
  129. package/web/utils/renderers/csv.js +124 -0
  130. package/web/utils/renderers/d3-force.js +82 -0
  131. package/web/utils/renderers/graphviz.js +13 -0
  132. package/web/utils/renderers/html.js +10 -0
  133. package/web/utils/renderers/jsonld.js +63 -0
  134. package/web/utils/renderers/markdown.js +19 -0
  135. package/web/utils/renderers/mermaid.js +54 -0
  136. package/web/utils/renderers/turtle.js +51 -0
  137. package/web/utils/sol-query-triple-patterns.js +151 -0
  138. package/web/utils/sol-query-ui.js +250 -0
  139. package/web/utils/sol-query-views.js +32 -0
  140. package/web/views/_helpers.js +34 -0
  141. package/web/views/accordion.js +133 -0
  142. package/web/views/anchorlist.js +59 -0
  143. package/web/views/auto-complete.js +183 -0
  144. package/web/views/dl.js +38 -0
  145. package/web/views/list.js +19 -0
  146. package/web/views/menu.js +56 -0
  147. package/web/views/rolodex.js +126 -0
  148. package/web/views/select.js +79 -0
  149. package/web/views/table.js +73 -0
  150. package/web/views/tabs.js +57 -0
package/web/sol-pod.js ADDED
@@ -0,0 +1,1039 @@
1
+ /**
2
+ * <sol-pod> — Solid pod file browser web component.
3
+ * Attributes: source (one pod storage URL, or a comma/space-separated list;
4
+ * if omitted, discovers from current origin)
5
+ * pod-click-action (Function|string — callback when an item is activated
6
+ * (gear icon, Enter, or double-click); if omitted, opens
7
+ * the default pod-ops modal)
8
+ * Properties: login (SolLogin element ref), currentPath, items
9
+ * Events: sol-navigate({url}), sol-drag-start({item}), sol-drag-end(), sol-status({message,type})
10
+ *
11
+ * Usage:
12
+ * <sol-login id="auth"></sol-login>
13
+ * <sol-pod source="https://pod.example/" login="#auth"></sol-pod>
14
+ */
15
+
16
+ import { CSS, sheet as POD_SHEET } from './styles/sol-pod-css.js';
17
+ import { sheet as POD_MODAL_SHEET, CSS as POD_MODAL_CSS } from './styles/sol-pod-modal-css.js';
18
+ import { adopt } from '../core/adopt.js';
19
+ import { define } from '../core/define.js';
20
+ import {
21
+ fileIcon,
22
+ fetchContainer,
23
+ discoverOwnerWebIds, getStoragesFromWebIds,
24
+ } from '../core/pod-ops.js';
25
+ import { getRegistry } from '../core/pod-registry.js';
26
+ import './sol-modal.js'; // modal shell is part of sol-pod's own UX
27
+ import './sol-login.js'; // built-in login button in the pod header
28
+
29
+ // ── SolPod component ──────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Solid pod file browser web component.
33
+ *
34
+ * Browse containers, view/edit files, manage permissions. Pairs with
35
+ * sol-login for authenticated access. Delegates file operations to sol-pod-ops.
36
+ *
37
+ * @class SolPod
38
+ * @extends HTMLElement
39
+ * @attr {string} source - pod storage URL, or comma/space-separated list of URLs (discovers from origin if omitted)
40
+ * @attr {string} login - CSS selector for a sol-login element
41
+ * @attr {string} login-mode - forwarded to the built-in sol-login as its `mode` ("redirect" | "popup")
42
+ * @attr {string} login-callback - forwarded to the built-in sol-login as its `popup-callback`
43
+ * @attr {string} issuers - comma-separated OIDC issuer origins, forwarded to the built-in sol-login
44
+ * @attr {string} side - auth session tag; also forwarded to the built-in sol-login as its `side`
45
+ * @attr {string} pod-click-action - callback when an item is activated (gear / Enter / double-click)
46
+ * @attr {string} handler - default sol-* component for file viewing
47
+ * @attr {string} gear-icon - icon for BOTH the per-item action button and
48
+ * the breadcrumb (current-container) gear. Treated as a URL
49
+ * when it contains '/' or ends in svg/png/jpg/gif/webp;
50
+ * otherwise used as text (emoji). Defaults to '⚙'.
51
+ * @attr {string} pods-group - shared pod-list scope; absent = the default shared
52
+ * group, 'none' = a standalone unshared registry
53
+ *
54
+ * Lifecycle: auto-initializes from `connectedCallback` (microtask-deferred so
55
+ * JS callers that set `podClickAction` between `appendChild` and the next
56
+ * microtask still land setup before init runs). `initialize()` is
57
+ * single-flight — calling it explicitly returns the in-flight or resolved
58
+ * promise instead of triggering a duplicate discovery.
59
+ *
60
+ * Last-visited recall: the current container URL is persisted to
61
+ * localStorage keyed by (pods-group, side). On the next mount, if the
62
+ * remembered path sits under one of the known storages, sol-pod restores it
63
+ * (and switches the pod selector to that storage if it differs from
64
+ * `storages[0]`). Wrapped against storage-unavailable contexts.
65
+ *
66
+ * Item shape (returned by `fetchContainer`, passed to `podClickAction`):
67
+ * { url, name, displayName, isContainer, contentType,
68
+ * size, // bytes, from posix:size (null if not emitted)
69
+ * mtime, // POSIX epoch seconds, from posix:mtime
70
+ * modified, // ISO datetime string, from dct:modified
71
+ * types } // array of rdf:type IRIs
72
+ *
73
+ * @property {Object} login - SolLogin element reference (external if given, else the built-in one)
74
+ * @property {string} currentPath - current container URL (also the remembered start path)
75
+ * @property {Array} items - current directory listing (see item shape above)
76
+ * @property {Array} storages - known pod URLs for this pod's group
77
+ * @fires sol-navigate - detail: { url }
78
+ * @fires sol-drag-start - detail: { item, element }
79
+ * @fires sol-drag-end
80
+ * @fires sol-auth-needed - detail: { url }
81
+ * @fires sol-status - detail: { message, type }
82
+ * @fires sol-prefs-change - detail: { prefs } — a hide-pattern filter was toggled
83
+ * @fires sol-pod-pods-changed - detail: { group, pods } — the group's pod list
84
+ * grew (discovery / add). May fire once per pod in the group;
85
+ * a host listener should treat it idempotently.
86
+ */
87
+ class SolPod extends HTMLElement {
88
+ static get observedAttributes() { return ['source', 'login', 'pod-click-action', 'handler', 'side']; }
89
+
90
+ constructor() {
91
+ super();
92
+ this.attachShadow({ mode: 'open' });
93
+ this._login = null;
94
+ this._loginEl = null;
95
+ this._side = null;
96
+ this._currentPath = '';
97
+ this._rootUrl = '';
98
+ this._items = [];
99
+ this._storages = []; // cache mirror of the group registry
100
+ this._group = null;
101
+ this._registry = null;
102
+ this._onRegistryChange = null;
103
+ this._pendingSeed = null;
104
+ this._initialized = false;
105
+ this._modal = null;
106
+ this._toastTimer = null;
107
+ this._draggedItem = null;
108
+ this._podClickAction = null;
109
+ this._selected = new Set();
110
+ this._lastSelectedIndex = -1;
111
+ this._currentItems = [];
112
+ this._allItems = [];
113
+ this._filterText = '';
114
+ this._focusIndex = -1;
115
+ this._prefs = { hideDot: true, hideHash: true, hideTilde: true };
116
+ }
117
+
118
+ get login() { return this._login || this._loginEl; }
119
+ set login(el) {
120
+ if (typeof el === 'string') el = document.querySelector(el);
121
+ this._login = el;
122
+ }
123
+
124
+ /**
125
+ * Auth session tag for this pod ('left' / 'right' in podz, etc).
126
+ * Passed to the linked sol-login's fetchFor() so multi-session setups
127
+ * pick the right session. When unset, fetchFor falls back to
128
+ * origin-coverage matching — back-compatible for single-session pages.
129
+ */
130
+ get side() { return this._side; }
131
+ set side(v) { this._side = v || null; }
132
+
133
+ get currentPath() { return this._currentPath; }
134
+ get items() { return this._items; }
135
+ get rootUrl() { return this._rootUrl; }
136
+
137
+ /** Known pod URLs for this pod's group (shared across the group). */
138
+ get storages() { return this._registry ? this._registry.list() : [...this._storages]; }
139
+
140
+ /**
141
+ * Add pod URLs to this pod's group registry and optionally select one.
142
+ * Additive — it contributes to the shared list rather than replacing it.
143
+ */
144
+ setStorages(arr, currentUrl) {
145
+ this._registry?.addAll(Array.isArray(arr) ? arr : []);
146
+ const target = currentUrl || this._rootUrl;
147
+ if (target && this.storages.includes(target)) {
148
+ const sel = this.shadowRoot.querySelector('.pod-select');
149
+ if (sel) sel.value = target;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Preload known pod URLs (e.g. restored from host persistence) into
155
+ * this pod's group registry. Silent — does not emit sol-pod-pods-changed.
156
+ */
157
+ seedPods(urls) {
158
+ if (this._registry) this._registry.addAll(urls, { silent: true });
159
+ else this._pendingSeed = (this._pendingSeed || []).concat(urls || []);
160
+ }
161
+
162
+ get prefs() { return this._prefs; }
163
+ set prefs(p) { this._prefs = { ...this._prefs, ...p }; }
164
+
165
+ get source() { return this.getAttribute('source') || ''; }
166
+ set source(v) { this.setAttribute('source', v); }
167
+
168
+ // The `source` attribute may be one URL or a comma/space-separated list;
169
+ // each becomes a pod-selector entry. Every URL is normalised to end '/'
170
+ // and the list is de-duplicated (after normalisation, so 'x' and 'x/'
171
+ // collapse to one).
172
+ _sources() {
173
+ const seen = new Set();
174
+ const out = [];
175
+ for (const raw of (this.getAttribute('source') || '').split(/[,\s]+/)) {
176
+ const s = raw.trim();
177
+ if (!s) continue;
178
+ const u = s.endsWith('/') ? s : s + '/';
179
+ if (!seen.has(u)) { seen.add(u); out.push(u); }
180
+ }
181
+ return out;
182
+ }
183
+
184
+ get podClickAction() { return this._podClickAction; }
185
+ set podClickAction(v) {
186
+ if (typeof v === 'function') { this._podClickAction = v; return; }
187
+ if (typeof v === 'string' && v) { this._podClickAction = v; return; }
188
+ this._podClickAction = null;
189
+ }
190
+
191
+ connectedCallback() {
192
+ if (!this._initialized) {
193
+ this._initialized = true;
194
+ this._render();
195
+
196
+ // Join this pod's group registry — the shared set of known pods
197
+ // that drives the selector. `pods-group` scopes it; absent = the
198
+ // default shared group; 'none' = a standalone, unshared registry.
199
+ this._group = this.getAttribute('pods-group') || '__default__';
200
+ this._registry = getRegistry(this._group);
201
+ this._onRegistryChange = (pods, silent) => this._applyRegistry(pods, silent);
202
+ this._registry.subscribe(this._onRegistryChange);
203
+ if (this._pendingSeed) {
204
+ this._registry.addAll(this._pendingSeed, { silent: true });
205
+ this._pendingSeed = null;
206
+ }
207
+
208
+ const loginAttr = this.getAttribute('login');
209
+ if (loginAttr) this.login = loginAttr;
210
+ const sideAttr = this.getAttribute('side');
211
+ if (sideAttr) this._side = sideAttr;
212
+ const clickAttr = this.getAttribute('pod-click-action') || this.getAttribute('handler');
213
+ if (clickAttr) this.podClickAction = clickAttr;
214
+
215
+ // The header carries a built-in <sol-login>. An external login=
216
+ // selector, when given, takes its place and the built-in is dropped.
217
+ const embeddedLogin = this.shadowRoot.querySelector('sol-login');
218
+ if (this._login) {
219
+ embeddedLogin.remove();
220
+ } else {
221
+ this._loginEl = embeddedLogin;
222
+ // Forward this pod's login config to the built-in <sol-login> so a
223
+ // host can opt into popup mode / a side tag / a callback page /
224
+ // a starting issuer list.
225
+ const lm = this.getAttribute('login-mode');
226
+ if (lm) embeddedLogin.setAttribute('mode', lm);
227
+ if (sideAttr) embeddedLogin.setAttribute('side', sideAttr);
228
+ const lc = this.getAttribute('login-callback');
229
+ if (lc) embeddedLogin.setAttribute('popup-callback', lc);
230
+ const iss = this.getAttribute('issuers');
231
+ if (iss) embeddedLogin.setAttribute('issuers', iss);
232
+ const reload = () => { if (this._currentPath) this.loadContainer(this._currentPath); };
233
+ // A login can reveal pods the logged-out session could not see —
234
+ // re-discover, then reload the current container.
235
+ this._loginEl.addEventListener('sol-login', () => {
236
+ this.discover().catch(() => {});
237
+ reload();
238
+ });
239
+ this._loginEl.addEventListener('sol-logout', reload);
240
+ this._loginEl.initialize().catch(() => {});
241
+ }
242
+
243
+ // Kick off discovery + first-container load on mount so the
244
+ // pod always resolves out of its "Loading pods..." placeholder.
245
+ // Deferred to a microtask so JS callers that set
246
+ // `podClickAction` / other properties between `appendChild` and
247
+ // the next turn of the microtask queue still land their setup
248
+ // before init runs. `initialize()` is single-flight so an
249
+ // explicit `await pod.initialize()` from such a caller just
250
+ // awaits the same in-flight promise rather than triggering a
251
+ // duplicate discovery.
252
+ queueMicrotask(() => this.initialize().catch((err) =>
253
+ console.warn('[sol-pod] init failed:', err)));
254
+ }
255
+ }
256
+
257
+ disconnectedCallback() {
258
+ if (this._onDocClick) {
259
+ document.removeEventListener('click', this._onDocClick);
260
+ this._onDocClick = null;
261
+ }
262
+ if (this._registry && this._onRegistryChange) {
263
+ this._registry.unsubscribe(this._onRegistryChange);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Group-registry subscriber: a pod was added by this or a sibling
269
+ * <sol-pod>. Refresh the selector; on a non-silent change, let the
270
+ * host persist the new list via the sol-pod-pods-changed event.
271
+ */
272
+ _applyRegistry(pods, silent) {
273
+ this._storages = pods;
274
+ this._populateSelect(pods);
275
+ const sel = this.shadowRoot.querySelector('.pod-select');
276
+ if (sel && this._rootUrl && pods.includes(this._rootUrl)) sel.value = this._rootUrl;
277
+ if (!silent) {
278
+ this.dispatchEvent(new CustomEvent('sol-pod-pods-changed', {
279
+ bubbles: true, composed: true, detail: { group: this._group, pods },
280
+ }));
281
+ }
282
+ }
283
+
284
+ attributeChangedCallback(name, oldV, newV) {
285
+ if (oldV === newV) return;
286
+ if (name === 'source' && this._initialized) {
287
+ this._setSource();
288
+ }
289
+ if (name === 'login' && this._initialized) {
290
+ this.login = newV;
291
+ }
292
+ if (name === 'pod-click-action' || name === 'handler') {
293
+ this.podClickAction = newV;
294
+ }
295
+ if (name === 'side') {
296
+ this._side = newV || null;
297
+ }
298
+ }
299
+
300
+ /** Initialize the component — discovers pods and loads initial view.
301
+ * Single-flight: subsequent calls return the same in-flight (or
302
+ * resolved) promise, so the connectedCallback auto-init and an
303
+ * explicit `await pod.initialize()` from a caller share one pass. */
304
+ initialize() {
305
+ if (this._initPromise) return this._initPromise;
306
+ this._initPromise = this._doInitialize();
307
+ return this._initPromise;
308
+ }
309
+
310
+ async _doInitialize() {
311
+ if (this._sources().length) {
312
+ // An explicit `source` lists exactly the pods to use — skip
313
+ // discovery. An absent or empty `source` falls through to it.
314
+ await this._setSource();
315
+ return;
316
+ }
317
+ // No explicit source: use whatever the group registry already holds
318
+ // (seeded by the host, or discovered by a sibling pod); otherwise
319
+ // discover for this session/origin. Seeded pods are authoritative —
320
+ // a non-empty registry stands as the dropdown's contents and no
321
+ // discovery is attempted. Discovery only fills an empty registry.
322
+ if (this.storages.length === 0) {
323
+ await this.discover();
324
+ }
325
+ const pods = this.storages;
326
+ // Always render the selector — an empty list must show "No pods
327
+ // found" rather than leave the initial "Loading pods..." placeholder.
328
+ this._populateSelect(pods);
329
+ if (pods.length > 0) {
330
+ const start = this._pickStartPath(pods[0]);
331
+ this._rootUrl = this._rootForPath(start, pods) || pods[0];
332
+ const sel = this.shadowRoot.querySelector('.pod-select');
333
+ if (sel) sel.value = this._rootUrl;
334
+ await this.loadContainer(start);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Discover pod storages for the current session/origin (WebID-based,
340
+ * falling back to the current origin) and add them to this pod's
341
+ * group registry. Returns the group's full pod list.
342
+ */
343
+ async discover() {
344
+ let found;
345
+ try {
346
+ const webIds = await discoverOwnerWebIds();
347
+ found = await getStoragesFromWebIds(webIds);
348
+ } catch (e) {
349
+ console.warn('[sol-pod] Discovery failed:', e);
350
+ found = [window.location.origin + '/'];
351
+ }
352
+ this._registry?.addAll(found);
353
+ return this.storages;
354
+ }
355
+
356
+ _fetchFor(url) {
357
+ const login = this._login || this._loginEl;
358
+ if (login?.fetchFor) return login.fetchFor(url, this._side);
359
+ return fetch;
360
+ }
361
+
362
+ async _setSource() {
363
+ const sources = this._sources();
364
+ if (!sources.length) return;
365
+ this._registry?.addAll(sources);
366
+ const start = this._pickStartPath(sources[0]);
367
+ // Align the selected pod root with whichever storage the
368
+ // remembered path is under, so the dropdown matches the
369
+ // breadcrumb on a "return to last visited" boot.
370
+ this._rootUrl = this._rootForPath(start, sources) || sources[0];
371
+ this._populateSelect(this.storages);
372
+ const sel = this.shadowRoot.querySelector('.pod-select');
373
+ if (sel) sel.value = this._rootUrl;
374
+ await this.loadContainer(start);
375
+ }
376
+
377
+ async loadContainer(url) {
378
+ this._showLoading();
379
+ try {
380
+ const fetchFn = this._fetchFor(url);
381
+ const items = await fetchContainer(url, fetchFn);
382
+ this._rawItems = items; // unfiltered — kept so prefs can re-apply
383
+ this._currentPath = url;
384
+ this._rememberPath(url);
385
+ this._items = this._filterItems(items);
386
+ this._allItems = this._items;
387
+ // New container = fresh context; clear any in-flight filter.
388
+ this._filterText = '';
389
+ const filterInput = this.shadowRoot.querySelector('.pod-filter');
390
+ if (filterInput) filterInput.value = '';
391
+ this._renderTree(this._allItems, { preserveFocus: false });
392
+ this._updateBreadcrumb(url);
393
+ this._emitStatus('', '');
394
+
395
+ this.dispatchEvent(new CustomEvent('sol-navigate', {
396
+ bubbles: true, composed: true, detail: { url }
397
+ }));
398
+ } catch (e) {
399
+ if (e.message?.includes('401') || e.message?.includes('403') || e.needsAuth) {
400
+ this._showMessage('Authentication required \u2014 please log in.', true);
401
+ this._currentPath = url;
402
+ this._updateBreadcrumb(url);
403
+ this.dispatchEvent(new CustomEvent('sol-auth-needed', {
404
+ bubbles: true, composed: true, detail: { url }
405
+ }));
406
+ } else {
407
+ this._showMessage(`Failed to load: ${e.message}`, true);
408
+ }
409
+ }
410
+ }
411
+
412
+ /* ── last-visited path memory ──────────────────────────────────────
413
+ * Persist the current container URL in localStorage keyed by
414
+ * (pods-group, side) so the next page load can restore the user
415
+ * where they were. Multiple sol-pods sharing the same group/side
416
+ * share the memory — same context, same recall. Wrapped against
417
+ * environments where localStorage is unavailable (private mode,
418
+ * partitioned iframes). */
419
+ _pathStorageKey() {
420
+ return 'sol-pod:lastPath:' + (this._group || '__default__') + ':' + (this._side || 'default');
421
+ }
422
+
423
+ _rememberPath(url) {
424
+ try { localStorage.setItem(this._pathStorageKey(), url); } catch (_) {}
425
+ }
426
+
427
+ _recallPath() {
428
+ try { return localStorage.getItem(this._pathStorageKey()) || null; } catch (_) { return null; }
429
+ }
430
+
431
+ /** Pick the remembered path if it sits under one of the available
432
+ * pod storages — otherwise the caller's fallback (root) wins. */
433
+ _pickStartPath(fallback) {
434
+ const remembered = this._recallPath();
435
+ if (!remembered) return fallback;
436
+ if (this._rootForPath(remembered, this.storages)) return remembered;
437
+ return fallback;
438
+ }
439
+
440
+ /** Return whichever root URL in `pods` contains `path`, or null. */
441
+ _rootForPath(path, pods) {
442
+ if (!path || !pods?.length) return null;
443
+ return pods.find(root => path === root || path.startsWith(root)) || null;
444
+ }
445
+
446
+ _filterItems(items) {
447
+ return items.filter(item => {
448
+ // Match the user's mental model: filter on the decoded name so
449
+ // e.g. %23foo (decodes to #foo) hides when 'hide hash' is on.
450
+ const n = item.displayName || item.name;
451
+ if (this._prefs.hideDot && n.startsWith('.')) return false;
452
+ if (this._prefs.hideHash && n.startsWith('#')) return false;
453
+ if (this._prefs.hideTilde && n.endsWith('~')) return false;
454
+ return true;
455
+ });
456
+ }
457
+
458
+ // Re-apply the pattern prefs to the cached listing — no refetch.
459
+ _reapplyPrefs() {
460
+ this._items = this._allItems = this._filterItems(this._rawItems || []);
461
+ this._renderTree(this._allItems, { preserveFocus: false });
462
+ }
463
+
464
+ // ── DOM rendering ───────────────────────────────────────────────────
465
+
466
+ _render() {
467
+ const s = this.shadowRoot;
468
+ s.innerHTML = `
469
+ <div class="pod-header">
470
+ <div class="pod-header-row">
471
+ <select class="pod-select" aria-label="Pod storage">
472
+ <option value="">Loading pods...</option>
473
+ </select>
474
+ <sol-login class="pod-login" visible></sol-login>
475
+ <button class="pod-settings-btn" type="button" title="Settings"
476
+ aria-label="Display settings" aria-expanded="false">⚙</button>
477
+ </div>
478
+ <div class="pod-settings" role="group" aria-label="Display settings">
479
+ <label><input type="checkbox" data-pref="hideDot"> Hide dot-files</label>
480
+ <label><input type="checkbox" data-pref="hideHash"> Hide #-files</label>
481
+ <label><input type="checkbox" data-pref="hideTilde"> Hide ~ backups</label>
482
+ </div>
483
+ </div>
484
+ <div class="breadcrumb"></div>
485
+ <div class="pod-filter-row">
486
+ <input class="pod-filter" type="search"
487
+ placeholder="Filter (type to search, esc to clear)"
488
+ aria-label="Filter items in this container" />
489
+ </div>
490
+ <div class="tree-wrapper" tabindex="0">
491
+ <div class="empty">Loading...</div>
492
+ </div>`;
493
+ s.adoptedStyleSheets = [];
494
+ adopt(s, { sheet: POD_SHEET, css: CSS });
495
+
496
+ const sel = s.querySelector('.pod-select');
497
+ sel.addEventListener('change', () => {
498
+ if (sel.value === '__add__') {
499
+ this._promptAddPod();
500
+ } else if (sel.value) {
501
+ this._rootUrl = sel.value;
502
+ this.loadContainer(sel.value);
503
+ }
504
+ });
505
+
506
+ const filter = s.querySelector('.pod-filter');
507
+ filter.addEventListener('input', () => {
508
+ this._filterText = filter.value;
509
+ this._renderTree(this._allItems, { preserveFocus: false });
510
+ });
511
+ filter.addEventListener('keydown', (e) => {
512
+ if (e.key === 'Escape') {
513
+ filter.value = '';
514
+ this._filterText = '';
515
+ this._renderTree(this._allItems, { preserveFocus: false });
516
+ this.shadowRoot.querySelector('.tree-wrapper')?.focus();
517
+ e.preventDefault();
518
+ } else if (e.key === 'ArrowDown' || e.key === 'Enter') {
519
+ // Move focus into the list.
520
+ const ul = this.shadowRoot.querySelector('.file-tree');
521
+ const first = ul?.querySelector('li');
522
+ if (first) {
523
+ this._focusIndex = 0;
524
+ first.focus();
525
+ e.preventDefault();
526
+ }
527
+ }
528
+ });
529
+
530
+ // Display-settings panel — toggle the pattern prefs (hide dot / # / ~).
531
+ const settingsBtn = s.querySelector('.pod-settings-btn');
532
+ const settings = s.querySelector('.pod-settings');
533
+ settingsBtn.addEventListener('click', () => {
534
+ const open = settings.classList.toggle('open');
535
+ settingsBtn.setAttribute('aria-expanded', String(open));
536
+ if (open) {
537
+ settings.querySelectorAll('input[data-pref]').forEach(cb => {
538
+ cb.checked = !!this._prefs[cb.dataset.pref];
539
+ });
540
+ }
541
+ });
542
+ settings.addEventListener('change', (e) => {
543
+ const cb = e.target;
544
+ if (!cb?.dataset?.pref) return;
545
+ this._prefs[cb.dataset.pref] = cb.checked;
546
+ this._reapplyPrefs();
547
+ // Let a host persist the change — the panel itself keeps no storage.
548
+ this.dispatchEvent(new CustomEvent('sol-prefs-change', {
549
+ bubbles: true, composed: true, detail: { prefs: { ...this._prefs } }
550
+ }));
551
+ });
552
+ // Close the panel on any click outside it. composedPath() crosses the
553
+ // shadow boundary, so this also catches clicks elsewhere in the pod.
554
+ this._onDocClick = (e) => {
555
+ if (!settings.classList.contains('open')) return;
556
+ const path = e.composedPath();
557
+ if (path.includes(settings) || path.includes(settingsBtn)) return;
558
+ settings.classList.remove('open');
559
+ settingsBtn.setAttribute('aria-expanded', 'false');
560
+ };
561
+ document.addEventListener('click', this._onDocClick);
562
+
563
+ // Keyboard nav at the wrapper level so it works whether the wrapper
564
+ // or an individual li has focus.
565
+ const wrapper = s.querySelector('.tree-wrapper');
566
+ wrapper.addEventListener('keydown', (e) => this._onWrapperKey(e));
567
+ }
568
+
569
+ _populateSelect(storages) {
570
+ const sel = this.shadowRoot.querySelector('.pod-select');
571
+ if (!sel) return;
572
+ sel.innerHTML = '';
573
+ if (storages.length === 0) {
574
+ sel.innerHTML = '<option value="">No pods found</option>';
575
+ } else {
576
+ storages.forEach(url => {
577
+ const opt = document.createElement('option');
578
+ opt.value = url;
579
+ // Strip the scheme for display; the value still carries the
580
+ // full URL so selection / fetch keep working.
581
+ opt.textContent = url.replace(/^https?:\/\//, '');
582
+ sel.appendChild(opt);
583
+ });
584
+ }
585
+ const addOpt = document.createElement('option');
586
+ addOpt.value = '__add__'; addOpt.textContent = '\uFF0B Add a Pod...';
587
+ sel.appendChild(addOpt);
588
+
589
+ // Offer the pod storages (which double as OIDC issuers) as login
590
+ // choices, so clicking the login button drops down the pod list.
591
+ // Merge rather than replace \u2014 any host-configured issuers survive.
592
+ if (this._loginEl) storages.forEach(u => this._loginEl.addIssuer(u));
593
+ }
594
+
595
+ async _promptAddPod() {
596
+ const sel = this.shadowRoot.querySelector('.pod-select');
597
+ const prev = this._currentPath || sel.options[0]?.value;
598
+ if (prev && prev !== '__add__') sel.value = prev;
599
+
600
+ // Use sol-modal prompt if available, else native prompt
601
+ let url;
602
+ if (customElements.get('sol-modal')) {
603
+ const { SolModal } = await import('./sol-modal.js');
604
+ url = await SolModal.prompt('Enter pod URL:', 'https://example.solidcommunity.net/');
605
+ } else {
606
+ url = prompt('Enter pod URL:', 'https://example.solidcommunity.net/');
607
+ }
608
+
609
+ if (!url || !url.startsWith('http')) {
610
+ if (prev && prev !== '__add__') sel.value = prev;
611
+ return;
612
+ }
613
+ const normalized = url.endsWith('/') ? url : url + '/';
614
+ // Adding to the registry repopulates the selector(s) for the group.
615
+ this._registry?.add(normalized);
616
+ sel.value = normalized;
617
+ this._rootUrl = normalized;
618
+ this.dispatchEvent(new CustomEvent('sol-pod-add', {
619
+ bubbles: true, composed: true, detail: { url: normalized }
620
+ }));
621
+ await this.loadContainer(normalized);
622
+ }
623
+
624
+ _showLoading() {
625
+ const tw = this.shadowRoot.querySelector('.tree-wrapper');
626
+ if (tw) tw.innerHTML = '<div class="loading">Loading...</div>';
627
+ }
628
+
629
+ _showMessage(msg, isError) {
630
+ const tw = this.shadowRoot.querySelector('.tree-wrapper');
631
+ if (tw) tw.innerHTML = `<div class="empty${isError ? ' error' : ''}">${msg}</div>`;
632
+ }
633
+
634
+ _updateBreadcrumb(url) {
635
+ const el = this.shadowRoot.querySelector('.breadcrumb');
636
+ if (!el || !this._rootUrl) return;
637
+ el.innerHTML = '';
638
+ const home = document.createElement('button');
639
+ home.textContent = '\u{1F3E0}'; home.className = 'sol-btn sol-btn-sm sol-btn-ghost';
640
+ home.onclick = () => this.loadContainer(this._rootUrl);
641
+ el.appendChild(home);
642
+ if (url !== this._rootUrl) {
643
+ const parts = url.replace(this._rootUrl, '').split('/').filter(Boolean);
644
+ let cur = this._rootUrl;
645
+ parts.forEach(part => {
646
+ cur += part + '/'; const pathUrl = cur;
647
+ el.appendChild(document.createTextNode(' / '));
648
+ const btn = document.createElement('button');
649
+ btn.textContent = part; btn.className = 'sol-btn sol-btn-sm sol-btn-ghost';
650
+ btn.onclick = () => this.loadContainer(pathUrl);
651
+ el.appendChild(btn);
652
+ });
653
+ }
654
+
655
+ // Gear at the right edge — activates the current container (host
656
+ // podClickAction first, else the sol-pod-ops modal). Same icon
657
+ // treatment as per-item gears so a `gear-icon` attribute applies
658
+ // consistently across the whole view.
659
+ const gear = document.createElement('button');
660
+ gear.className = 'sol-btn sol-btn-sm sol-btn-ghost crumb-gear';
661
+ gear.title = 'Edit this folder';
662
+ gear.setAttribute('aria-label', 'Edit this folder');
663
+ this._paintGearIcon(gear);
664
+ gear.onclick = () => this._openCurrentContainerModal();
665
+ el.appendChild(gear);
666
+ }
667
+
668
+ /** Paint a gear button using the `gear-icon` attribute (URL → img,
669
+ * short string → text, absent → default ⚙). Shared by per-item
670
+ * and breadcrumb gears. */
671
+ _paintGearIcon(btn) {
672
+ btn.textContent = '';
673
+ const iconAttr = this.getAttribute('gear-icon');
674
+ if (iconAttr && /\/|\.(svg|png|jpe?g|gif|webp)$/i.test(iconAttr)) {
675
+ const img = document.createElement('img');
676
+ img.src = iconAttr;
677
+ img.alt = '';
678
+ btn.appendChild(img);
679
+ } else {
680
+ btn.textContent = iconAttr || '⚙';
681
+ }
682
+ }
683
+
684
+ _openCurrentContainerModal() {
685
+ const u = this._currentPath || this._rootUrl;
686
+ if (!u) return;
687
+ const trimmed = u.replace(/\/$/, '');
688
+ const name = trimmed.split('/').pop() || u;
689
+ let displayName;
690
+ try { displayName = decodeURIComponent(name); } catch { displayName = name; }
691
+ // Route through _activateItem so a host-supplied podClickAction
692
+ // (e.g. dk-solidos navigating the iframe to this container) gets
693
+ // first refusal — same precedence as per-item activation. Falls
694
+ // through to the pod-ops modal when no handler is wired.
695
+ this._activateItem({
696
+ url: u,
697
+ name,
698
+ displayName,
699
+ isContainer: true,
700
+ contentType: 'text/turtle',
701
+ });
702
+ }
703
+
704
+ _renderTree(allItems, { preserveFocus = true } = {}) {
705
+ this._allItems = allItems;
706
+ const visible = this._applyFilter(allItems);
707
+ this._currentItems = visible;
708
+
709
+ // Drop selections that are no longer visible.
710
+ const visibleUrls = new Set(visible.map(it => it.url));
711
+ for (const u of [...this._selected]) if (!visibleUrls.has(u)) this._selected.delete(u);
712
+
713
+ const tw = this.shadowRoot.querySelector('.tree-wrapper');
714
+ const prevFocusUrl = preserveFocus
715
+ ? tw.querySelector('li:focus')?.dataset?.url || null
716
+ : null;
717
+ tw.innerHTML = '';
718
+
719
+ if (visible.length === 0) {
720
+ const msg = this._filterText
721
+ ? `No matches for "${this._filterText}"`
722
+ : (allItems.length === 0 ? 'Empty container' : 'No matches');
723
+ tw.innerHTML = `<div class="empty">${msg}</div>`;
724
+ this._focusIndex = -1;
725
+ return;
726
+ }
727
+
728
+ const ul = document.createElement('ul');
729
+ ul.className = 'file-tree';
730
+ visible.forEach((item, idx) => ul.appendChild(this._createTreeItem(item, idx)));
731
+ tw.appendChild(ul);
732
+
733
+ // Restore focus if requested (linear scan — URLs contain characters that
734
+ // are awkward to escape in a CSS attribute selector).
735
+ if (prevFocusUrl) {
736
+ const li = Array.from(ul.children).find(el => el.dataset.url === prevFocusUrl);
737
+ if (li) {
738
+ li.focus();
739
+ this._focusIndex = Number(li.dataset.index);
740
+ }
741
+ }
742
+
743
+ // Drop zone
744
+ tw.ondragover = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; tw.parentElement?.classList.add('drag-over'); };
745
+ tw.ondragleave = (e) => { if (e.target === tw) tw.parentElement?.classList.remove('drag-over'); };
746
+ tw.ondrop = (e) => { e.preventDefault(); tw.parentElement?.classList.remove('drag-over'); };
747
+ }
748
+
749
+ _applyFilter(items) {
750
+ const q = (this._filterText || '').trim().toLowerCase();
751
+ if (!q) return items;
752
+ return items.filter(it => {
753
+ const n = (it.displayName || it.name || '').toLowerCase();
754
+ return n.includes(q);
755
+ });
756
+ }
757
+
758
+ _onWrapperKey(e) {
759
+ // Don't intercept while the filter input is the target.
760
+ if (e.target?.classList?.contains('pod-filter')) return;
761
+
762
+ const ul = this.shadowRoot.querySelector('.file-tree');
763
+ const items = ul ? Array.from(ul.children) : [];
764
+ const focusEl = this.shadowRoot.activeElement;
765
+ let idx = focusEl?.tagName === 'LI'
766
+ ? items.indexOf(focusEl)
767
+ : (this._focusIndex >= 0 ? this._focusIndex : -1);
768
+
769
+ const focusAt = (i) => {
770
+ if (i < 0 || i >= items.length) return;
771
+ this._focusIndex = i;
772
+ items[i].focus();
773
+ };
774
+
775
+ switch (e.key) {
776
+ case 'ArrowDown':
777
+ focusAt(idx < 0 ? 0 : Math.min(items.length - 1, idx + 1));
778
+ e.preventDefault();
779
+ break;
780
+ case 'ArrowUp':
781
+ focusAt(idx <= 0 ? 0 : idx - 1);
782
+ e.preventDefault();
783
+ break;
784
+ case 'Home':
785
+ focusAt(0);
786
+ e.preventDefault();
787
+ break;
788
+ case 'End':
789
+ focusAt(items.length - 1);
790
+ e.preventDefault();
791
+ break;
792
+ case 'Enter': {
793
+ if (idx < 0) return;
794
+ const item = this._currentItems[idx];
795
+ if (!item) return;
796
+ if (item.isContainer) this.loadContainer(item.url);
797
+ else this._activateItem(item);
798
+ e.preventDefault();
799
+ break;
800
+ }
801
+ case 'Backspace': {
802
+ const parent = this._parentOf(this._currentPath);
803
+ if (parent) { this.loadContainer(parent); e.preventDefault(); }
804
+ break;
805
+ }
806
+ case '/': {
807
+ const f = this.shadowRoot.querySelector('.pod-filter');
808
+ if (f) { f.focus(); f.select(); e.preventDefault(); }
809
+ break;
810
+ }
811
+ case 'Escape':
812
+ if (this._filterText) {
813
+ this._filterText = '';
814
+ const f = this.shadowRoot.querySelector('.pod-filter');
815
+ if (f) f.value = '';
816
+ this._renderTree(this._allItems, { preserveFocus: false });
817
+ e.preventDefault();
818
+ }
819
+ break;
820
+ }
821
+ }
822
+
823
+ _parentOf(url) {
824
+ if (!url || !this._rootUrl) return null;
825
+ if (url === this._rootUrl) return null;
826
+ const u = url.endsWith('/') ? url.slice(0, -1) : url;
827
+ const i = u.lastIndexOf('/');
828
+ if (i < 0) return null;
829
+ const p = u.slice(0, i + 1);
830
+ return p.startsWith(this._rootUrl) ? p : this._rootUrl;
831
+ }
832
+
833
+ async _activateItem(item) {
834
+ if (typeof this._podClickAction === 'function') {
835
+ this._podClickAction(await this._withContentType(item), this);
836
+ } else if (typeof this._podClickAction === 'string') {
837
+ this._openNamedHandler(this._podClickAction, item);
838
+ } else {
839
+ this._openItemModal(item);
840
+ }
841
+ }
842
+
843
+ // HEAD the clicked resource so a function podClickAction receives the
844
+ // server's real Content-Type rather than the extension-inferred guess.
845
+ // One request, only for the clicked item; on failure the guess stands.
846
+ async _withContentType(item) {
847
+ try {
848
+ const resp = await this._fetchFor(item.url)(item.url, { method: 'HEAD' });
849
+ const ct = (resp.headers.get('Content-Type') || '').split(';')[0].trim();
850
+ if (ct) return { ...item, contentType: ct };
851
+ } catch (e) { /* keep the inferred contentType */ }
852
+ return item;
853
+ }
854
+
855
+ _createTreeItem(item, idx) {
856
+ const li = document.createElement('li');
857
+ li.className = item.isContainer ? 'folder' : 'file';
858
+ li.tabIndex = 0;
859
+ li.dataset.url = item.url;
860
+ li.dataset.index = String(idx);
861
+
862
+ const label = document.createElement('span');
863
+ label.className = 'item-label';
864
+ label.textContent = `${item.isContainer ? '\u{1F4C1}' : fileIcon(item.name)} ${item.displayName || item.name}`;
865
+ li.appendChild(label);
866
+
867
+ const openItemAction = (e) => {
868
+ e.stopPropagation();
869
+ e.preventDefault();
870
+ this._activateItem(item);
871
+ };
872
+
873
+ const gear = document.createElement('button');
874
+ gear.className = 'item-gear';
875
+ gear.title = 'Actions';
876
+ this._paintGearIcon(gear);
877
+ gear.onclick = openItemAction;
878
+ li.appendChild(gear);
879
+
880
+ const handleSelectClick = (e) => {
881
+ if (e.shiftKey && this._lastSelectedIndex >= 0) {
882
+ const a = Math.min(this._lastSelectedIndex, idx);
883
+ const b = Math.max(this._lastSelectedIndex, idx);
884
+ this._selected.clear();
885
+ for (let i = a; i <= b; i++) this._selected.add(this._currentItems[i].url);
886
+ } else if (e.ctrlKey || e.metaKey) {
887
+ if (this._selected.has(item.url)) this._selected.delete(item.url);
888
+ else this._selected.add(item.url);
889
+ this._lastSelectedIndex = idx;
890
+ } else {
891
+ this._selected.clear();
892
+ this._selected.add(item.url);
893
+ this._lastSelectedIndex = idx;
894
+ }
895
+ this._refreshSelectionUI();
896
+ };
897
+
898
+ // Drag
899
+ li.draggable = true;
900
+ li.ondragstart = (e) => {
901
+ let items;
902
+ if (this._selected.has(item.url) && this._selected.size > 1) {
903
+ items = this._currentItems.filter(it => this._selected.has(it.url));
904
+ } else {
905
+ this._selected.clear();
906
+ this._selected.add(item.url);
907
+ this._lastSelectedIndex = idx;
908
+ this._refreshSelectionUI();
909
+ items = [item];
910
+ }
911
+ this._draggedItem = items[0];
912
+ e.dataTransfer.effectAllowed = 'copyMove';
913
+ e.dataTransfer.setData('text/plain', items.map(it => it.url).join('\n'));
914
+ li.classList.add('dragging');
915
+ this.dispatchEvent(new CustomEvent('sol-drag-start', {
916
+ bubbles: true, composed: true, detail: { items, item: items[0], element: this }
917
+ }));
918
+ };
919
+ li.ondragend = () => {
920
+ li.classList.remove('dragging');
921
+ this.dispatchEvent(new CustomEvent('sol-drag-end', { bubbles: true, composed: true }));
922
+ };
923
+
924
+ if (item.isContainer) {
925
+ li.onclick = (e) => {
926
+ if (e.shiftKey || e.ctrlKey || e.metaKey) { handleSelectClick(e); return; }
927
+ if (!li.classList.contains('dragging')) this.loadContainer(item.url);
928
+ };
929
+ } else {
930
+ li.onclick = handleSelectClick;
931
+ li.ondblclick = openItemAction;
932
+ }
933
+ // Keyboard activation (Enter / Backspace / arrows / `/`) is handled at
934
+ // the .tree-wrapper level — see _onWrapperKey.
935
+
936
+ return li;
937
+ }
938
+
939
+ _refreshSelectionUI() {
940
+ const ul = this.shadowRoot.querySelector('.file-tree');
941
+ if (!ul) return;
942
+ for (const li of ul.children) {
943
+ li.classList.toggle('selected', this._selected.has(li.dataset.url));
944
+ }
945
+ }
946
+
947
+ // ── Item modal — delegates to <sol-pod-ops> when it is loaded ───────
948
+
949
+ _openItemModal(item) {
950
+ // sol-pod-ops is an optional add-on. Without it — and with no
951
+ // podClickAction wired up — show a short how-to instead.
952
+ if (!customElements.get('sol-pod-ops')) { this._openHelpModal(item); return; }
953
+
954
+ const modal = document.createElement('sol-modal');
955
+ modal.modalTitle = item.isContainer ? `Folder: ${item.displayName || item.name}` : (item.displayName || item.name);
956
+ modal.styles = [POD_MODAL_SHEET || POD_MODAL_CSS];
957
+
958
+ modal.handler = (body) => {
959
+ body.style.padding = '0';
960
+ body.style.overflow = 'hidden';
961
+ const ops = document.createElement('sol-pod-ops');
962
+ ops.item = item;
963
+ ops.fetchFn = this._fetchFor(item.url);
964
+ ops.setAttribute('source', item.url);
965
+ ops.style.height = '100%';
966
+ ops.addEventListener('sol-status', (e) => this._emitStatus(e.detail.message, e.detail.type));
967
+ ops.addEventListener('sol-navigate', async () => {
968
+ modal.close();
969
+ await this.loadContainer(this._currentPath);
970
+ });
971
+ body.appendChild(ops);
972
+ };
973
+ modal.open();
974
+ this._modal = modal;
975
+ }
976
+
977
+ // Shown when an item is activated but nothing is set up to handle it —
978
+ // no podClickAction, and <sol-pod-ops> has not been loaded.
979
+ _openHelpModal(item) {
980
+ const modal = document.createElement('sol-modal');
981
+ modal.modalTitle = item.displayName || item.name;
982
+ modal.setAttribute('size', 'large');
983
+ modal.handler = (body) => {
984
+ body.style.padding = '16px 20px';
985
+ body.innerHTML =
986
+ '<p>Nothing is wired up to handle this item.</p>' +
987
+ '<p>Set a <code>podClickAction</code> on the &lt;sol-pod&gt; element ' +
988
+ 'to receive item clicks and render the info into your page:</p>' +
989
+ '<pre>document.querySelector(\'sol-pod\').podClickAction =\n' +
990
+ ' (item) =&gt; { /* item: url, name, displayName, isContainer, contentType */ };</pre>' +
991
+ '<p>Or load the <code>sol-pod-ops</code> script for the built-in ' +
992
+ 'file-operations panel.</p>';
993
+ };
994
+ modal.open();
995
+ this._modal = modal;
996
+ }
997
+
998
+ // ── Named gear handlers ─────────────────────────────────────────────
999
+
1000
+ async _openNamedHandler(name, item) {
1001
+ switch (name) {
1002
+ case 'solidos': return this._openSolidosModal(item);
1003
+ default: return this._openItemModal(item);
1004
+ }
1005
+ }
1006
+
1007
+ _openSolidosModal(item) {
1008
+ // sol-solidos is optional too — fall back to the item modal without it.
1009
+ if (!customElements.get('sol-solidos')) { this._openItemModal(item); return; }
1010
+
1011
+ const modal = document.createElement('sol-modal');
1012
+ modal.modalTitle = item.displayName || item.name;
1013
+ modal.size = 'large';
1014
+
1015
+ modal.handler = (body) => {
1016
+ body.style.padding = '0';
1017
+ body.style.overflow = 'hidden';
1018
+ const solidos = document.createElement('sol-solidos');
1019
+ solidos.setAttribute('source', item.url);
1020
+ solidos.style.height = '100%';
1021
+ body.appendChild(solidos);
1022
+ };
1023
+ modal.open();
1024
+ this._modal = modal;
1025
+ }
1026
+
1027
+ // ── Status emission ─────────────────────────────────────────────────
1028
+
1029
+ _emitStatus(message, type) {
1030
+ this.dispatchEvent(new CustomEvent('sol-status', {
1031
+ bubbles: true, composed: true,
1032
+ detail: { message, type }
1033
+ }));
1034
+ }
1035
+ }
1036
+
1037
+ define('sol-pod', SolPod);
1038
+ export { SolPod };
1039
+ export default SolPod;