claude-code-marketplace 0.2.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.
package/public/app.js ADDED
@@ -0,0 +1,1088 @@
1
+ let marketplaces = [];
2
+ let selectedPluginId = null;
3
+ let searchFilter = '';
4
+ let scopeFilter = 'installed';
5
+ const expandedNodes = new Set();
6
+ let componentCache = {};
7
+ const detailHistory = [];
8
+ let focusedRowId = null;
9
+ let _focusedRowEl = null;
10
+
11
+ function matchKey(e, ...keys) {
12
+ if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return false;
13
+ return keys.some((k) => e.key === k || e.code === k);
14
+ }
15
+
16
+ const SVG = (d, w = 14) =>
17
+ `<svg width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
18
+ const ICONS = {
19
+ marketplace: SVG(
20
+ '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
21
+ ),
22
+ plugin: SVG(
23
+ '<path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
24
+ ),
25
+ skills: SVG('<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>'),
26
+ commands: SVG('<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>'),
27
+ agents: SVG(
28
+ '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>',
29
+ ),
30
+ mcpServers: SVG(
31
+ '<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
32
+ ),
33
+ hooks: SVG('<polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 00-4-4H4"/>'),
34
+ lspServers: SVG(
35
+ '<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>',
36
+ ),
37
+ folder: SVG('<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>'),
38
+ file: SVG('<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/>'),
39
+ gear: SVG(
40
+ '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
41
+ ),
42
+ kebab:
43
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="12" cy="5" r="2.5"/><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="19" r="2.5"/></svg>',
44
+ };
45
+ ICONS.settings = ICONS.gear;
46
+ const COMP_HAS_DIR = new Set(['skills', 'commands', 'agents']);
47
+ const COMP_LABELS = {
48
+ skills: 'Skills',
49
+ commands: 'Commands',
50
+ agents: 'Agents',
51
+ mcpServers: 'MCP Servers',
52
+ hooks: 'Hooks',
53
+ lspServers: 'LSP Servers',
54
+ settings: 'Settings',
55
+ };
56
+
57
+ function updateArrow(p) {
58
+ if (!p.hasUpdate) return '';
59
+ const title =
60
+ p.version && p.availableVersion
61
+ ? `Update available: v${esc(p.version)} \u2192 v${esc(p.availableVersion)}`
62
+ : 'Update available';
63
+ return `<span class="update-indicator" title="${title}">\u2191</span>`;
64
+ }
65
+
66
+ // --- Init ---
67
+ document.addEventListener('DOMContentLoaded', () => {
68
+ restoreAppState();
69
+ loadProject();
70
+ loadData();
71
+
72
+ let searchTimer;
73
+ document.getElementById('searchInput').addEventListener('input', (e) => {
74
+ searchFilter = e.target.value.toLowerCase();
75
+ clearTimeout(searchTimer);
76
+ searchTimer = setTimeout(renderTree, 150);
77
+ });
78
+
79
+ document.getElementById('scopeFilter').addEventListener('change', (e) => {
80
+ scopeFilter = e.target.value;
81
+ renderTree();
82
+ });
83
+
84
+ document.getElementById('refreshBtn').addEventListener('click', refresh);
85
+ document.getElementById('themeBtn').addEventListener('click', toggleTheme);
86
+
87
+ document.getElementById('projectBtn').addEventListener('click', changeProject);
88
+ document.getElementById('addMarketplaceBtn').addEventListener('click', openAddMarketplace);
89
+ document.getElementById('helpBtn').addEventListener('click', showHelpModal);
90
+
91
+ // Enter key in modal
92
+ document.getElementById('marketplaceSource').addEventListener('keydown', (e) => {
93
+ if (e.key === 'Enter') {
94
+ e.preventDefault();
95
+ submitAddMarketplace();
96
+ }
97
+ if (e.key === 'Escape') {
98
+ e.preventDefault();
99
+ closeModal('addMarketplaceModal');
100
+ }
101
+ });
102
+
103
+ const savedTheme = localStorage.getItem('theme');
104
+ if (savedTheme === 'light') {
105
+ document.body.classList.add('light');
106
+ } else if (savedTheme === 'dark') {
107
+ document.body.classList.add('dark-forced');
108
+ }
109
+ syncHljsTheme();
110
+
111
+ document.addEventListener('keydown', handleKeydown);
112
+ });
113
+
114
+ function syncHljsTheme() {
115
+ const light = document.body.classList.contains('light');
116
+ const darkSheet = document.getElementById('hljsDark');
117
+ const lightSheet = document.getElementById('hljsLight');
118
+ if (darkSheet) darkSheet.disabled = light;
119
+ if (lightSheet) lightSheet.disabled = !light;
120
+ }
121
+
122
+ function toggleTheme() {
123
+ const isLight = document.body.classList.contains('light');
124
+ document.body.classList.remove('light', 'dark-forced');
125
+ if (isLight) {
126
+ document.body.classList.add('dark-forced');
127
+ localStorage.setItem('theme', 'dark');
128
+ } else {
129
+ document.body.classList.add('light');
130
+ localStorage.setItem('theme', 'light');
131
+ }
132
+ syncHljsTheme();
133
+ }
134
+
135
+ async function loadProject() {
136
+ try {
137
+ const res = await fetch('/api/project');
138
+ const data = await res.json();
139
+ document.getElementById('projectPath').textContent = shortenPath(data.path);
140
+ document.getElementById('projectBtn').title = data.path;
141
+ } catch {}
142
+ }
143
+
144
+ async function loadData() {
145
+ try {
146
+ componentCache = {};
147
+ const res = await fetch('/api/marketplaces');
148
+ marketplaces = await res.json();
149
+ for (const m of marketplaces) {
150
+ m.updateCount = m.plugins.filter((p) => p.hasUpdate).length;
151
+ }
152
+
153
+ renderTree();
154
+ if (selectedPluginId) showDetail(selectedPluginId);
155
+
156
+ // Prefetch components for all plugins in background
157
+ for (const m of marketplaces) {
158
+ for (const p of m.plugins) {
159
+ if (!componentCache[p.fullId]) {
160
+ fetchComponents(p.fullId);
161
+ }
162
+ }
163
+ }
164
+ } catch (err) {
165
+ document.getElementById('treeContainer').innerHTML =
166
+ `<div class="loading" style="color:var(--error)">Failed to load: ${err.message}</div>`;
167
+ }
168
+ }
169
+
170
+ async function refresh() {
171
+ const btn = document.getElementById('refreshBtn');
172
+ btn.classList.add('loading');
173
+ btn.disabled = true;
174
+ await fetch('/api/refresh', { method: 'POST' });
175
+ await loadData();
176
+ btn.classList.remove('loading');
177
+ btn.disabled = false;
178
+ toast('Data refreshed', 'success');
179
+ }
180
+
181
+ async function changeProject() {
182
+ // Try native directory picker API first, fall back to prompt
183
+ let dirPath = null;
184
+ if (window.showDirectoryPicker) {
185
+ try {
186
+ const handle = await window.showDirectoryPicker({ mode: 'read' });
187
+ dirPath = handle.name;
188
+ // showDirectoryPicker doesn't give full path — fall back to prompt with the name as hint
189
+ dirPath = prompt('Confirm project directory (browser cannot read full path):', dirPath);
190
+ } catch (e) {
191
+ if (e.name === 'AbortError') return;
192
+ }
193
+ }
194
+ if (!dirPath) {
195
+ const current = document.getElementById('projectBtn').title;
196
+ dirPath = prompt('Project directory:', current);
197
+ }
198
+ if (!dirPath) return;
199
+
200
+ try {
201
+ const res = await fetch('/api/project', {
202
+ method: 'PUT',
203
+ headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify({ path: dirPath }),
205
+ });
206
+ if (!res.ok) {
207
+ const err = await res.json();
208
+ toast(err.error, 'error');
209
+ return;
210
+ }
211
+ await loadProject();
212
+ await loadData();
213
+ toast('Project switched', 'success');
214
+ } catch (err) {
215
+ toast(err.message, 'error');
216
+ }
217
+ }
218
+
219
+ // --- Render Tree ---
220
+
221
+ function renderTree() {
222
+ const container = document.getElementById('treeContainer');
223
+ if (!marketplaces.length) {
224
+ container.innerHTML = '<div class="loading">No marketplaces found</div>';
225
+ return;
226
+ }
227
+
228
+ let html = '';
229
+
230
+ for (const m of marketplaces) {
231
+ const mid = safeId(m.name);
232
+ const plugins = filterPlugins(m.plugins);
233
+ if (plugins.length === 0 && (searchFilter || scopeFilter !== 'all')) continue;
234
+
235
+ const srcBadge = m.isVirtual ? '' : sourceBadge(m.source.type);
236
+ const updateBadge =
237
+ m.updateCount > 0
238
+ ? `<span class="update-badge">${m.updateCount} update${m.updateCount !== 1 ? 's' : ''}</span>`
239
+ : '';
240
+ const pluginCount = m.isVirtual
241
+ ? ''
242
+ : `<span class="badge-count">${plugins.length} plugin${plugins.length !== 1 ? 's' : ''}</span>`;
243
+
244
+ const mExpanded = expandedNodes.has(`m_${mid}`) || !!searchFilter;
245
+ const mIcon = m.isVirtual ? ICONS.gear : ICONS.marketplace;
246
+ const kebabBtn = m.isVirtual
247
+ ? ''
248
+ : `<button class="mkt-info-btn" onclick="event.stopPropagation(); showMarketplaceDetail('${esc(m.name)}')" title="Marketplace info">${ICONS.kebab}</button>`;
249
+
250
+ html += `<div class="tree-row marketplace-row${m.isVirtual ? ' virtual' : ''}" data-row-type="marketplace" data-row-id="m_${mid}" onclick="toggleChildren('m_${mid}')">
251
+ <span class="tree-chevron${mExpanded ? ' expanded' : ''}" id="chev_m_${mid}">\u25B6</span>
252
+ <span class="tree-icon">${mIcon}</span>
253
+ <span class="tree-label"><span class="mkt-name">${esc(m.name)}</span> ${m.version ? `<span class="version">v${esc(m.version)}</span>` : ''}</span>
254
+ ${srcBadge}
255
+ ${pluginCount}
256
+ ${updateBadge}
257
+ ${kebabBtn}
258
+ </div>`;
259
+
260
+ html += `<div class="tree-children${mExpanded ? ' open' : ''}" id="children_m_${mid}">`;
261
+ for (const p of plugins) {
262
+ html += renderPluginRow(p);
263
+ }
264
+ html += `</div>`;
265
+ }
266
+
267
+ const scrollTop = container.scrollTop;
268
+ container.innerHTML = html;
269
+ container.scrollTop = scrollTop;
270
+ updateUrl();
271
+ if (focusedRowId) {
272
+ const row = container.querySelector(`.tree-row[data-row-id="${CSS.escape(focusedRowId)}"]`);
273
+ if (row && row.offsetParent !== null) {
274
+ _focusedRowEl = row;
275
+ row.classList.add('focused');
276
+ } else {
277
+ focusedRowId = null;
278
+ _focusedRowEl = null;
279
+ }
280
+ }
281
+ }
282
+
283
+ function renderPluginRow(p) {
284
+ const selected = selectedPluginId === p.fullId ? ' selected' : '';
285
+ const scopes = p.isVirtual ? '' : renderScopeToggles(p);
286
+ const summary = renderCompSummary(p);
287
+ const ver = p.version ? `<span class="version">v${esc(p.version)}</span>` : '';
288
+ const updateIndicator = updateArrow(p);
289
+ const virtualCls = p.isVirtual ? ' virtual' : '';
290
+ const icon = p.isVirtual ? ICONS.gear : ICONS.plugin;
291
+
292
+ const desc = `<span class="tree-desc-inline">${p.description ? esc(p.description) : ''}</span>`;
293
+
294
+ const html = `<div class="tree-row${selected}${virtualCls}" data-row-type="plugin" data-row-id="${esc(p.fullId)}" onclick="showDetail('${esc(p.fullId)}')">
295
+ <span class="tree-indent" style="width:40px"></span>
296
+ <span class="tree-icon">${icon}</span>
297
+ <span class="tree-label">${esc(p.name)} ${ver} ${updateIndicator}</span>
298
+ ${desc}
299
+ ${scopes}
300
+ ${summary}
301
+ </div>`;
302
+
303
+ return html;
304
+ }
305
+
306
+ function renderScopeToggles(plugin) {
307
+ const scopes = ['user', 'project', 'local'];
308
+ const toggles = scopes
309
+ .map((s) => {
310
+ const detail = plugin.scopeDetails[s];
311
+ let cls = `scope-toggle ${s}`;
312
+ let title;
313
+ if (detail.installed && detail.enabled) {
314
+ cls += ' active';
315
+ title = `${s}: enabled (v${detail.version || '?'})`;
316
+ } else if (detail.installed && !detail.enabled) {
317
+ cls += ' disabled';
318
+ title = `${s}: disabled (v${detail.version || '?'})`;
319
+ } else {
320
+ title = `${s}: not installed`;
321
+ }
322
+ return `<div class="${cls}" title="${title}" onclick="event.stopPropagation(); scopeAction('${esc(plugin.fullId)}', '${s}')">${s[0].toUpperCase()}</div>`;
323
+ })
324
+ .join('');
325
+ return `<div class="scope-toggles">${toggles}</div>`;
326
+ }
327
+
328
+ function renderCompSummary(plugin) {
329
+ if (!plugin.components) return '';
330
+ const parts = [];
331
+ for (const [k, v] of Object.entries(plugin.components)) {
332
+ if (k === 'settings' || k.startsWith('_')) continue;
333
+ const count = Array.isArray(v) ? v.length : v;
334
+ if (count > 0) parts.push(`${count} ${COMP_LABELS[k]?.toLowerCase() || k}`);
335
+ }
336
+ return parts.length ? `<span class="tree-meta">${parts.join(' \u00B7 ')}</span>` : '';
337
+ }
338
+
339
+ // --- Detail Panel ---
340
+
341
+ async function showDetail(pluginId) {
342
+ selectedPluginId = pluginId;
343
+ updateUrl();
344
+ const plugin = findPlugin(pluginId);
345
+ if (!plugin) return;
346
+
347
+ // Highlight in tree
348
+ document.querySelectorAll('.tree-row.selected').forEach((r) => r.classList.remove('selected'));
349
+ const row = document.querySelector(`.tree-row[data-row-id="${CSS.escape(pluginId)}"]`);
350
+ if (row) row.classList.add('selected');
351
+
352
+ const panel = document.getElementById('detailPanel');
353
+ const marketplace = marketplaces.find((m) => m.plugins.some((p) => p.fullId === pluginId));
354
+ const mName = marketplace?.name || '?';
355
+ const isVirtual = plugin.isVirtual;
356
+
357
+ const componentsHtml = '<div style="color:var(--text-dim);font-size:12px">Loading components...</div>';
358
+ const headerIcon = isVirtual ? ICONS.gear : ICONS.plugin;
359
+
360
+ const updateBanner = plugin.hasUpdate
361
+ ? `<div class="update-banner">
362
+ <span>Update available: <strong>v${esc(plugin.version)}</strong> \u2192 <strong>v${esc(plugin.availableVersion)}</strong></span>
363
+ <button class="action-btn primary" onclick="runAction('update', '${esc(plugin.fullId)}')">Update Plugin</button>
364
+ </div>`
365
+ : '';
366
+
367
+ const scopeSection = isVirtual
368
+ ? `<div class="detail-section">
369
+ <span class="badge badge-virtual">Custom</span>
370
+ <span class="detail-meta-item" style="margin-left:8px">${esc(plugin.installedScopes?.[0] || '')} scope</span>
371
+ </div>`
372
+ : `<div class="detail-section">
373
+ <h4>Scope Installation</h4>
374
+ ${renderScopeMatrix(plugin)}
375
+ </div>`;
376
+
377
+ const metaRow = isVirtual
378
+ ? `<div class="detail-meta-row">
379
+ <span class="detail-meta-item">${esc(shortenPath(plugin._pluginDir || ''))}</span>
380
+ </div>`
381
+ : `<div class="detail-meta-row">
382
+ <span class="detail-meta-item">from ${esc(mName)}</span>
383
+ ${sourceBadge(marketplace?.source?.type)}
384
+ </div>`;
385
+
386
+ panel.innerHTML = `
387
+ <div class="detail-header">
388
+ <h3>${headerIcon} ${esc(plugin.name)} ${plugin.version ? `<span class="version">v${esc(plugin.version)}</span>` : ''}</h3>
389
+ <button class="detail-close" onclick="closeDetail()">\u2715</button>
390
+ </div>
391
+ <div class="detail-body">
392
+ ${updateBanner}
393
+ <div class="detail-section">
394
+ <p class="detail-desc">${esc(plugin.description || 'No description')}</p>
395
+ ${metaRow}
396
+ </div>
397
+ ${scopeSection}
398
+ <div class="detail-section">
399
+ <h4>Components</h4>
400
+ <div id="detailComponents">${componentsHtml}</div>
401
+ </div>
402
+ </div>
403
+ `;
404
+
405
+ const comps = (await fetchComponents(pluginId)) || plugin.components || {};
406
+ const hasDirAccess = !!comps._pluginDir;
407
+ const el = document.getElementById('detailComponents');
408
+ if (el) el.innerHTML = renderDetailComponents(pluginId, comps, hasDirAccess);
409
+ }
410
+
411
+ function renderScopeMatrix(plugin) {
412
+ const scopes = ['user', 'project', 'local'];
413
+ return `<div class="scope-matrix">${scopes
414
+ .map((s) => {
415
+ const d = plugin.scopeDetails[s];
416
+ let status, actions;
417
+
418
+ if (d.installed && d.enabled) {
419
+ status = `Enabled${d.version ? ` \u00B7 v${esc(d.version)}` : ''}`;
420
+ actions = `
421
+ <button class="action-btn" onclick="runAction('disable', '${esc(plugin.fullId)}', '${s}')">Disable</button>
422
+ <button class="action-btn danger" onclick="runAction('uninstall', '${esc(plugin.fullId)}', '${s}')">Remove</button>
423
+ `;
424
+ } else if (d.installed && !d.enabled) {
425
+ status = `Disabled${d.version ? ` \u00B7 v${esc(d.version)}` : ''}`;
426
+ actions = `
427
+ <button class="action-btn primary" onclick="runAction('enable', '${esc(plugin.fullId)}', '${s}')">Enable</button>
428
+ <button class="action-btn danger" onclick="runAction('uninstall', '${esc(plugin.fullId)}', '${s}')">Remove</button>
429
+ `;
430
+ } else {
431
+ status = 'Not installed';
432
+ actions = `<button class="action-btn primary" onclick="runAction('install', '${esc(plugin.fullId)}', '${s}')">Install</button>`;
433
+ }
434
+
435
+ return `<div class="scope-matrix-row">
436
+ <span class="scope-matrix-label ${s}">${s}</span>
437
+ <span class="scope-matrix-status">${status}</span>
438
+ <div class="scope-matrix-actions">${actions}</div>
439
+ </div>`;
440
+ })
441
+ .join('')}</div>`;
442
+ }
443
+
444
+ function renderDetailComponents(pluginId, comps, hasDirAccess) {
445
+ const configFiles = comps._configFiles || {};
446
+ const entries = Object.entries(comps).filter(
447
+ ([k, v]) => !k.startsWith('_') && (Array.isArray(v) ? v.length > 0 : v > 0),
448
+ );
449
+ if (!entries.length) return '<div style="color:var(--text-dim);font-size:12px">No components found</div>';
450
+
451
+ return entries
452
+ .map(([type, items]) => {
453
+ const names = Array.isArray(items) ? items : [];
454
+ const count = names.length || items;
455
+ let html = `<div class="detail-comp-group">
456
+ <div class="detail-comp-header">
457
+ <span class="comp-icon">${ICONS[type] || ''}</span>
458
+ ${COMP_LABELS[type] || type}
459
+ <span class="count">${count}</span>
460
+ </div>`;
461
+
462
+ if (names.length) {
463
+ const configFile = configFiles[type];
464
+ const dir = COMP_HAS_DIR.has(type) ? type : null;
465
+ html += '<div class="detail-comp-items">';
466
+ for (const name of names) {
467
+ const clickPath = configFile || (dir ? `${dir}/${name}` : name);
468
+ const cls = hasDirAccess ? '' : ' disabled';
469
+ const click = hasDirAccess
470
+ ? ` onclick="openContentModal('${esc(pluginId)}', '${esc(clickPath)}', '${esc(type)}')"`
471
+ : '';
472
+ html += `<div class="detail-comp-item${cls}"${click}>
473
+ <span class="icon">${type === 'skills' ? ICONS.folder : ICONS.file}</span>
474
+ ${esc(name)}
475
+ </div>`;
476
+ }
477
+ html += '</div>';
478
+ }
479
+
480
+ html += '</div>';
481
+ return html;
482
+ })
483
+ .join('');
484
+ }
485
+
486
+ const EXT_TO_LANG = {
487
+ md: 'markdown',
488
+ json: 'json',
489
+ yaml: 'yaml',
490
+ yml: 'yaml',
491
+ js: 'javascript',
492
+ ts: 'typescript',
493
+ py: 'python',
494
+ sh: 'bash',
495
+ bash: 'bash',
496
+ css: 'css',
497
+ html: 'xml',
498
+ xml: 'xml',
499
+ toml: 'ini',
500
+ };
501
+
502
+ const PREFERRED_FILE = 'SKILL.MD';
503
+ let _contentCodeEl = null;
504
+
505
+ function highlightSource(text, fileName) {
506
+ const ext = (fileName || '').split('.').pop().toLowerCase();
507
+ const lang = EXT_TO_LANG[ext];
508
+ if (typeof hljs === 'undefined' || !lang) return esc(text);
509
+ try {
510
+ if (lang === 'markdown') {
511
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
512
+ if (fm) {
513
+ const fmHtml = hljs.highlight(fm[1], { language: 'yaml' }).value;
514
+ const bodyHtml = hljs.highlight(fm[2], { language: 'markdown' }).value;
515
+ return `<span class="hl-frontmatter">---</span>\n${fmHtml}\n<span class="hl-frontmatter">---</span>\n${bodyHtml}`;
516
+ }
517
+ }
518
+ return hljs.highlight(text, { language: lang }).value;
519
+ } catch {}
520
+ return esc(text);
521
+ }
522
+
523
+ function getContentCodeEl() {
524
+ if (!_contentCodeEl) {
525
+ const pre = document.getElementById('contentViewerCode');
526
+ _contentCodeEl = pre.querySelector('code') || pre;
527
+ }
528
+ return _contentCodeEl;
529
+ }
530
+
531
+ async function openContentModal(pluginId, initialPath, componentType) {
532
+ const plugin = findPlugin(pluginId);
533
+ const label = COMP_LABELS[componentType] || componentType;
534
+ document.getElementById('contentModalTitle').textContent = `${plugin?.name || pluginId} \u2014 ${label}`;
535
+
536
+ const tree = document.getElementById('contentTree');
537
+ const codeEl = getContentCodeEl();
538
+ const pathEl = document.getElementById('contentViewerPath');
539
+ tree.innerHTML = '';
540
+ codeEl.innerHTML = '';
541
+ pathEl.textContent = '';
542
+
543
+ document.getElementById('contentModal').classList.add('open');
544
+ await loadContentTree(pluginId, initialPath, tree, 0, true);
545
+ }
546
+
547
+ async function loadContentTree(pluginId, treePath, container, depth, autoSelect) {
548
+ try {
549
+ const res = await fetch(`/api/plugins/${encodeURIComponent(pluginId)}/preview/${treePath}`);
550
+ if (!res.ok) throw new Error('Not found');
551
+ const data = await res.json();
552
+
553
+ if (data.type === 'directory') {
554
+ let firstFile = null;
555
+ let preferredFile = null;
556
+ for (const entry of data.entries) {
557
+ const subPath = `${treePath}/${entry.name}`;
558
+ const item = document.createElement('div');
559
+ item.className = 'content-tree-item';
560
+ item.style.paddingLeft = `${12 + depth * 14}px`;
561
+ item.dataset.path = subPath;
562
+ item.innerHTML = `<span class="icon">${entry.isDirectory ? ICONS.folder : ICONS.file}</span>${esc(entry.name)}`;
563
+
564
+ if (entry.isDirectory) {
565
+ let expanded = false;
566
+ const childContainer = document.createElement('div');
567
+ childContainer.className = 'content-tree-children';
568
+ childContainer.style.display = 'none';
569
+ item.addEventListener('click', async () => {
570
+ expanded = !expanded;
571
+ if (expanded && !childContainer.children.length) {
572
+ await loadContentTree(pluginId, subPath, childContainer, depth + 1, false);
573
+ }
574
+ childContainer.style.display = expanded ? 'block' : 'none';
575
+ });
576
+ container.appendChild(item);
577
+ container.appendChild(childContainer);
578
+ } else {
579
+ if (!firstFile) firstFile = subPath;
580
+ if (entry.name.toUpperCase() === PREFERRED_FILE) preferredFile = subPath;
581
+ item.addEventListener('click', () => loadContentFile(pluginId, subPath));
582
+ container.appendChild(item);
583
+ }
584
+ }
585
+ if (autoSelect && (preferredFile || firstFile)) {
586
+ await loadContentFile(pluginId, preferredFile || firstFile);
587
+ }
588
+ } else {
589
+ await loadContentFile(pluginId, treePath);
590
+ }
591
+ } catch {
592
+ container.innerHTML = '<div style="color:var(--error);font-size:11px;padding:8px 12px">Failed to load</div>';
593
+ }
594
+ }
595
+
596
+ async function loadContentFile(pluginId, filePath) {
597
+ const codeEl = getContentCodeEl();
598
+ const pathEl = document.getElementById('contentViewerPath');
599
+ pathEl.textContent = filePath;
600
+ codeEl.innerHTML = '<span style="color:var(--text-dim)">Loading...</span>';
601
+
602
+ document.querySelectorAll('#contentTree .content-tree-item.active').forEach((el) => el.classList.remove('active'));
603
+ const activeItem = document.querySelector(`#contentTree .content-tree-item[data-path="${CSS.escape(filePath)}"]`);
604
+ if (activeItem) activeItem.classList.add('active');
605
+
606
+ try {
607
+ const res = await fetch(`/api/plugins/${encodeURIComponent(pluginId)}/preview/${filePath}`);
608
+ if (!res.ok) throw new Error('Not found');
609
+ const data = await res.json();
610
+
611
+ if (data.type === 'directory') {
612
+ codeEl.innerHTML = `<span style="color:var(--text-dim)">(directory with ${data.entries.length} entries)</span>`;
613
+ return;
614
+ }
615
+
616
+ if (!data.content) {
617
+ codeEl.innerHTML = '<span style="color:var(--text-dim)">(empty file)</span>';
618
+ return;
619
+ }
620
+
621
+ if (data.content.length > 100000) {
622
+ codeEl.innerHTML = `${highlightSource(data.content.slice(0, 100000), data.name)}\n\n--- truncated (100KB limit) ---`;
623
+ return;
624
+ }
625
+
626
+ codeEl.innerHTML = highlightSource(data.content, data.name);
627
+ } catch {
628
+ codeEl.innerHTML = '<span style="color:var(--error)">Failed to load file</span>';
629
+ }
630
+ }
631
+
632
+ function showMarketplaceDetail(name) {
633
+ selectedPluginId = null;
634
+ updateUrl();
635
+ document.querySelectorAll('.tree-row.selected').forEach((r) => r.classList.remove('selected'));
636
+
637
+ const m = marketplaces.find((m) => m.name === name);
638
+ if (!m) return;
639
+
640
+ const panel = document.getElementById('detailPanel');
641
+ const installed = m.plugins.filter((p) => p.isInstalled).length;
642
+ const total = m.plugins.length;
643
+ const srcType = m.source.type || 'unknown';
644
+ const srcDetail = m.source.repo || m.source.path || m.source.url || '';
645
+ const updated = m.lastUpdated ? timeAgo(new Date(m.lastUpdated)) : 'unknown';
646
+
647
+ panel.innerHTML = `
648
+ <div class="detail-header">
649
+ <h3>${ICONS.marketplace} ${esc(m.name)} ${m.version ? `<span class="version">v${esc(m.version)}</span>` : ''}</h3>
650
+ <button class="detail-close" onclick="closeDetail()">\u2715</button>
651
+ </div>
652
+ <div class="detail-body">
653
+ <div class="detail-section">
654
+ <div class="detail-meta-grid">
655
+ <span class="meta-label">Source</span>
656
+ <span class="meta-value">${sourceBadge(srcType)} ${esc(srcDetail)}</span>
657
+ ${m.version ? `<span class="meta-label">Version</span><span class="meta-value">v${esc(m.version)}</span>` : ''}
658
+ ${m.owner?.name ? `<span class="meta-label">Owner</span><span class="meta-value">${esc(m.owner.name)}${m.owner.email ? ` &lt;${esc(m.owner.email)}&gt;` : ''}${m.owner.url ? ` \u00B7 ${esc(m.owner.url)}` : ''}</span>` : ''}
659
+ ${m.description ? `<span class="meta-label">Description</span><span class="meta-value">${esc(m.description)}</span>` : ''}
660
+ <span class="meta-label">Location</span>
661
+ <span class="meta-value" style="word-break:break-all;font-size:11px">${esc(m.installLocation || '?')}</span>
662
+ <span class="meta-label">Last updated</span>
663
+ <span class="meta-value">${esc(updated)}</span>
664
+ <span class="meta-label">Plugins</span>
665
+ <span class="meta-value">${installed} installed / ${total} total</span>
666
+ </div>
667
+ </div>
668
+ <div class="detail-section">
669
+ <h4>Actions</h4>
670
+ <div class="mkt-actions">
671
+ <button class="action-btn primary" onclick="runMarketplaceAction('update', '${esc(m.name)}')">Update</button>
672
+ <button class="action-btn danger" onclick="runMarketplaceAction('remove', '${esc(m.name)}')">Remove</button>
673
+ </div>
674
+ </div>
675
+ <div class="detail-section">
676
+ <h4>Plugins</h4>
677
+ ${m.plugins
678
+ .map((p) => {
679
+ const status = p.isInstalled
680
+ ? p.isEnabled
681
+ ? '<span style="color:var(--success)">enabled</span>'
682
+ : '<span style="color:var(--warning)">disabled</span>'
683
+ : '<span style="color:var(--text-muted)">not installed</span>';
684
+ const updateTag = updateArrow(p);
685
+ return `<div class="mkt-plugin-item" onclick="if(detailHistory.length<20)detailHistory.push({type:'marketplace',name:'${esc(m.name)}'}); showDetail('${esc(p.fullId)}')">${esc(p.name)} ${status}${updateTag}</div>`;
686
+ })
687
+ .join('')}
688
+ </div>
689
+ </div>
690
+ `;
691
+ }
692
+
693
+ async function runMarketplaceAction(action, name) {
694
+ await postAndReload(`/api/marketplace/${action}`, { name }, action);
695
+ }
696
+
697
+ function closeDetail() {
698
+ const prev = detailHistory.pop();
699
+ if (prev) {
700
+ if (prev.type === 'marketplace') {
701
+ showMarketplaceDetail(prev.name);
702
+ return;
703
+ }
704
+ }
705
+ selectedPluginId = null;
706
+ updateUrl();
707
+ document.querySelectorAll('.tree-row.selected').forEach((r) => r.classList.remove('selected'));
708
+ document.getElementById('detailPanel').innerHTML = `
709
+ <div class="detail-empty">
710
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" opacity="0.3"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
711
+ <span>Select a plugin to view details</span>
712
+ </div>`;
713
+ }
714
+
715
+ // --- Actions ---
716
+
717
+ async function scopeAction(pluginId, scope) {
718
+ const plugin = findPlugin(pluginId);
719
+ if (!plugin) return;
720
+
721
+ const detail = plugin.scopeDetails[scope];
722
+ if (!detail.installed) {
723
+ await runAction('install', pluginId, scope);
724
+ } else if (detail.enabled) {
725
+ await runAction('disable', pluginId, scope);
726
+ } else {
727
+ await runAction('enable', pluginId, scope);
728
+ }
729
+ }
730
+
731
+ async function runAction(action, pluginId, scope) {
732
+ await postAndReload(`/api/plugins/${action}`, { pluginId, scope }, action);
733
+ }
734
+
735
+ async function postAndReload(url, body, label) {
736
+ toast(`Running ${label}...`, 'info');
737
+ try {
738
+ const res = await fetch(url, {
739
+ method: 'POST',
740
+ headers: { 'Content-Type': 'application/json' },
741
+ body: JSON.stringify(body),
742
+ });
743
+ const data = await res.json();
744
+ if (!res.ok) {
745
+ toast(data.error || 'Action failed', 'error');
746
+ return;
747
+ }
748
+ toast(`${label} successful`, 'success');
749
+ await loadData();
750
+ } catch (err) {
751
+ toast(err.message, 'error');
752
+ }
753
+ }
754
+
755
+ // --- URL State ---
756
+
757
+ function updateUrl() {
758
+ const params = new URLSearchParams();
759
+ if (searchFilter) params.set('q', searchFilter);
760
+ if (scopeFilter !== 'installed') params.set('scope', scopeFilter);
761
+ if (selectedPluginId) params.set('plugin', selectedPluginId);
762
+ const qs = params.toString();
763
+ const url = qs ? `?${qs}` : window.location.pathname;
764
+ history.replaceState(null, '', url);
765
+ }
766
+
767
+ function restoreAppState() {
768
+ const params = new URLSearchParams(window.location.search);
769
+ if (params.has('q')) {
770
+ searchFilter = params.get('q');
771
+ document.getElementById('searchInput').value = searchFilter;
772
+ }
773
+ if (params.has('scope')) {
774
+ scopeFilter = params.get('scope');
775
+ const sel = document.getElementById('scopeFilter');
776
+ if (sel) sel.value = scopeFilter;
777
+ }
778
+ if (params.has('plugin')) {
779
+ selectedPluginId = params.get('plugin');
780
+ }
781
+ try {
782
+ const saved = JSON.parse(localStorage.getItem('expandedNodes') || '[]');
783
+ saved.forEach((n) => expandedNodes.add(n));
784
+ } catch {}
785
+ }
786
+
787
+ async function fetchComponents(pluginId) {
788
+ if (componentCache[pluginId]) return componentCache[pluginId];
789
+ try {
790
+ const res = await fetch(`/api/plugins/${encodeURIComponent(pluginId)}/components`);
791
+ if (res.ok) {
792
+ componentCache[pluginId] = await res.json();
793
+ return componentCache[pluginId];
794
+ }
795
+ } catch {}
796
+ return null;
797
+ }
798
+
799
+ // --- Helpers ---
800
+
801
+ function toggleChildren(id) {
802
+ const el = document.getElementById(`children_${id}`);
803
+ const ch = document.getElementById(`chev_${id}`);
804
+ if (el) {
805
+ const isOpen = el.classList.toggle('open');
806
+ ch?.classList.toggle('expanded', isOpen);
807
+ if (isOpen) expandedNodes.add(id);
808
+ else expandedNodes.delete(id);
809
+ saveExpandedNodes();
810
+ }
811
+ }
812
+
813
+ function findPlugin(id) {
814
+ for (const m of marketplaces) {
815
+ const p = m.plugins.find((p) => p.fullId === id);
816
+ if (p) return p;
817
+ }
818
+ return null;
819
+ }
820
+
821
+ function filterPlugins(plugins) {
822
+ let result = plugins;
823
+ if (scopeFilter === 'installed') {
824
+ result = result.filter((p) => p.isInstalled);
825
+ } else if (scopeFilter !== 'all') {
826
+ result = result.filter((p) => p.scopeDetails[scopeFilter]?.installed);
827
+ }
828
+ if (searchFilter) {
829
+ result = result.filter(
830
+ (p) => p.name.toLowerCase().includes(searchFilter) || (p.description || '').toLowerCase().includes(searchFilter),
831
+ );
832
+ }
833
+ return result;
834
+ }
835
+
836
+ function sourceBadge(type) {
837
+ if (type === 'github') return '<span class="badge badge-github">GitHub</span>';
838
+ if (type === 'directory') return '<span class="badge badge-directory">Local</span>';
839
+ if (type === 'git') return '<span class="badge badge-git">Git</span>';
840
+ return `<span class="badge" style="background:var(--accent-dim);color:var(--accent)">${esc(type || '?')}</span>`;
841
+ }
842
+
843
+ function timeAgo(date) {
844
+ const s = Math.floor((Date.now() - date.getTime()) / 1000);
845
+ if (s < 60) return 'just now';
846
+ const m = Math.floor(s / 60);
847
+ if (m < 60) return `${m}m ago`;
848
+ const h = Math.floor(m / 60);
849
+ if (h < 24) return `${h}h ago`;
850
+ const d = Math.floor(h / 24);
851
+ if (d < 30) return `${d}d ago`;
852
+ return date.toLocaleDateString();
853
+ }
854
+
855
+ function saveExpandedNodes() {
856
+ localStorage.setItem('expandedNodes', JSON.stringify([...expandedNodes]));
857
+ }
858
+
859
+ function safeId(str) {
860
+ return str.replace(/[^a-zA-Z0-9_-]/g, '_');
861
+ }
862
+
863
+ function esc(str) {
864
+ if (!str) return '';
865
+ return str
866
+ .replace(/&/g, '&amp;')
867
+ .replace(/</g, '&lt;')
868
+ .replace(/>/g, '&gt;')
869
+ .replace(/"/g, '&quot;')
870
+ .replace(/'/g, '&#39;');
871
+ }
872
+
873
+ function shortenPath(p) {
874
+ if (!p) return '';
875
+ const home = '~';
876
+ return p
877
+ .replace(/\\/g, '/')
878
+ .replace(/^[A-Z]:\//i, '/')
879
+ .replace(/^\/Users\/[^/]+/i, home)
880
+ .replace(/^\/home\/[^/]+/i, home);
881
+ }
882
+
883
+ let toastTimeout;
884
+ function toast(msg, type = 'info') {
885
+ const el = document.getElementById('toast');
886
+ el.textContent = msg;
887
+ el.className = `toast ${type} show`;
888
+ clearTimeout(toastTimeout);
889
+ toastTimeout = setTimeout(() => el.classList.remove('show'), 3000);
890
+ }
891
+
892
+ function closeModal(id) {
893
+ document.getElementById(id).classList.remove('open');
894
+ }
895
+
896
+ function openAddMarketplace() {
897
+ document.getElementById('marketplaceSource').value = '';
898
+ document.getElementById('addMarketplaceModal').classList.add('open');
899
+ setTimeout(() => document.getElementById('marketplaceSource').focus(), 100);
900
+ }
901
+
902
+ async function submitAddMarketplace() {
903
+ const source = document.getElementById('marketplaceSource').value.trim();
904
+ if (!source) return;
905
+ const btn = document.getElementById('addMarketplaceSubmit');
906
+ btn.disabled = true;
907
+ btn.textContent = 'Adding...';
908
+ try {
909
+ const res = await fetch('/api/marketplace/add', {
910
+ method: 'POST',
911
+ headers: { 'Content-Type': 'application/json' },
912
+ body: JSON.stringify({ source }),
913
+ });
914
+ const data = await res.json();
915
+ if (!res.ok) {
916
+ toast(data.error || 'Failed to add marketplace', 'error');
917
+ } else {
918
+ toast('Marketplace added', 'success');
919
+ closeModal('addMarketplaceModal');
920
+ await loadData();
921
+ }
922
+ } catch (err) {
923
+ toast(err.message, 'error');
924
+ } finally {
925
+ btn.disabled = false;
926
+ btn.textContent = 'Add';
927
+ }
928
+ }
929
+
930
+ // --- Keyboard Navigation ---
931
+
932
+ function getVisibleRows() {
933
+ return [...document.querySelectorAll('#treeContainer .tree-row')].filter((r) => r.offsetParent !== null);
934
+ }
935
+
936
+ function setFocusedRow(index, rows) {
937
+ rows = rows || getVisibleRows();
938
+ if (_focusedRowEl) _focusedRowEl.classList.remove('focused');
939
+ if (!rows.length) {
940
+ focusedRowId = null;
941
+ _focusedRowEl = null;
942
+ return;
943
+ }
944
+ index = Math.max(0, Math.min(index, rows.length - 1));
945
+ const row = rows[index];
946
+ row.classList.add('focused');
947
+ row.scrollIntoView({ block: 'nearest' });
948
+ focusedRowId = row.dataset.rowId;
949
+ _focusedRowEl = row;
950
+ }
951
+
952
+ function getFocusedIndex(rows) {
953
+ if (!focusedRowId) return -1;
954
+ return rows.findIndex((r) => r.dataset.rowId === focusedRowId);
955
+ }
956
+
957
+ function handleKeydown(e) {
958
+ const tag = e.target.tagName;
959
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
960
+ if (e.key === 'Escape') {
961
+ e.target.blur();
962
+ e.preventDefault();
963
+ }
964
+ return;
965
+ }
966
+ if ((tag === 'BUTTON' || tag === 'A') && (e.key === 'Enter' || e.key === ' ')) return;
967
+
968
+ const openModal = document.querySelector('.modal-overlay.open');
969
+ if (openModal) {
970
+ if (e.key === 'Escape') {
971
+ openModal.classList.remove('open');
972
+ e.preventDefault();
973
+ }
974
+ return;
975
+ }
976
+
977
+ if (e.key === '?' || (e.key === '/' && e.shiftKey)) {
978
+ e.preventDefault();
979
+ showHelpModal();
980
+ return;
981
+ }
982
+
983
+ if (e.key === '/') {
984
+ e.preventDefault();
985
+ document.getElementById('searchInput').focus();
986
+ return;
987
+ }
988
+
989
+ if (matchKey(e, 'Escape')) {
990
+ e.preventDefault();
991
+ if (selectedPluginId) closeDetail();
992
+ return;
993
+ }
994
+
995
+ if (matchKey(e, 's')) {
996
+ e.preventDefault();
997
+ document.getElementById('scopeFilter').focus();
998
+ return;
999
+ }
1000
+
1001
+ if (matchKey(e, 'r')) {
1002
+ e.preventDefault();
1003
+ refresh();
1004
+ return;
1005
+ }
1006
+
1007
+ const rows = getVisibleRows();
1008
+ const idx = getFocusedIndex(rows);
1009
+
1010
+ if (matchKey(e, 'ArrowDown', 'j')) {
1011
+ e.preventDefault();
1012
+ if (idx < 0) {
1013
+ const selIdx = selectedPluginId ? rows.findIndex((r) => r.dataset.rowId === selectedPluginId) : -1;
1014
+ setFocusedRow(selIdx >= 0 ? selIdx : 0, rows);
1015
+ } else {
1016
+ setFocusedRow(idx + 1, rows);
1017
+ }
1018
+ return;
1019
+ }
1020
+
1021
+ if (matchKey(e, 'ArrowUp', 'k')) {
1022
+ e.preventDefault();
1023
+ if (idx < 0) {
1024
+ const selIdx = selectedPluginId ? rows.findIndex((r) => r.dataset.rowId === selectedPluginId) : -1;
1025
+ setFocusedRow(selIdx >= 0 ? selIdx : 0, rows);
1026
+ } else {
1027
+ setFocusedRow(idx - 1, rows);
1028
+ }
1029
+ return;
1030
+ }
1031
+
1032
+ if (e.key === 'Enter' || e.key === ' ') {
1033
+ e.preventDefault();
1034
+ if (idx < 0) return;
1035
+ const row = rows[idx];
1036
+ if (row.dataset.rowType === 'marketplace') toggleChildren(row.dataset.rowId);
1037
+ else if (row.dataset.rowType === 'plugin') showDetail(row.dataset.rowId);
1038
+ return;
1039
+ }
1040
+
1041
+ if (matchKey(e, 'ArrowRight', 'l')) {
1042
+ e.preventDefault();
1043
+ if (idx < 0) return;
1044
+ const row = rows[idx];
1045
+ if (row.dataset.rowType === 'marketplace' && !expandedNodes.has(row.dataset.rowId)) {
1046
+ toggleChildren(row.dataset.rowId);
1047
+ }
1048
+ return;
1049
+ }
1050
+
1051
+ if (matchKey(e, 'ArrowLeft', 'h')) {
1052
+ e.preventDefault();
1053
+ if (idx < 0) return;
1054
+ const row = rows[idx];
1055
+ if (row.dataset.rowType === 'marketplace' && expandedNodes.has(row.dataset.rowId)) {
1056
+ toggleChildren(row.dataset.rowId);
1057
+ } else if (row.dataset.rowType === 'plugin') {
1058
+ for (let i = idx - 1; i >= 0; i--) {
1059
+ if (rows[i].dataset.rowType === 'marketplace') {
1060
+ setFocusedRow(i, rows);
1061
+ break;
1062
+ }
1063
+ }
1064
+ }
1065
+ return;
1066
+ }
1067
+ }
1068
+
1069
+ let _helpModalHandler = null;
1070
+
1071
+ function showHelpModal() {
1072
+ document.getElementById('helpModal').classList.add('open');
1073
+ if (_helpModalHandler) document.removeEventListener('keydown', _helpModalHandler, true);
1074
+ _helpModalHandler = (e) => {
1075
+ if (e.key === 'Escape' || e.key === '?') {
1076
+ e.preventDefault();
1077
+ e.stopPropagation();
1078
+ closeModal('helpModal');
1079
+ document.removeEventListener('keydown', _helpModalHandler, true);
1080
+ _helpModalHandler = null;
1081
+ }
1082
+ };
1083
+ document.addEventListener('keydown', _helpModalHandler, true);
1084
+ }
1085
+
1086
+ if ('serviceWorker' in navigator) {
1087
+ navigator.serviceWorker.register('/sw.js');
1088
+ }