@uplift-beyond/upex 0.0.0-stage → 1.5.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.
@@ -0,0 +1,766 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>upex</title>
7
+ <meta name="color-scheme" content="dark light">
8
+ <style>
9
+ :root {
10
+ --bg: #0e1012;
11
+ --panel: #15181b;
12
+ --raise: #1c2024;
13
+ --line: #262b31;
14
+ --line-strong: #353c43;
15
+ --text: #e4e7ea;
16
+ --muted: #9aa3ac;
17
+ --accent: #46d39a;
18
+ --accent-ink: #052016;
19
+ --accent-soft: rgba(70, 211, 154, .13);
20
+ --warn: #f0c050;
21
+ --warn-soft: rgba(240, 192, 80, .1);
22
+ --danger: #ff7a72;
23
+ --danger-soft: rgba(255, 122, 114, .12);
24
+ --sans: "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
25
+ --mono: "Cascadia Mono", "Cascadia Code", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
26
+ --r: 6px;
27
+ }
28
+ @media (prefers-color-scheme: light) {
29
+ :root {
30
+ --bg: #f6f6f4;
31
+ --panel: #ffffff;
32
+ --raise: #eeeeeb;
33
+ --line: #e0e0dc;
34
+ --line-strong: #c9c9c4;
35
+ --text: #1a1c1e;
36
+ --muted: #5a6168;
37
+ --accent: #0c8455;
38
+ --accent-ink: #ffffff;
39
+ --accent-soft: rgba(12, 132, 85, .1);
40
+ --warn: #8a6100;
41
+ --warn-soft: rgba(176, 125, 0, .1);
42
+ --danger: #c0352b;
43
+ --danger-soft: rgba(192, 53, 43, .08);
44
+ }
45
+ }
46
+
47
+ * { box-sizing: border-box; }
48
+ html { scrollbar-color: var(--line-strong) var(--bg); }
49
+ body {
50
+ margin: 0;
51
+ background: var(--bg);
52
+ color: var(--text);
53
+ font: 14px/1.45 var(--sans);
54
+ -webkit-font-smoothing: antialiased;
55
+ }
56
+ ::selection { background: var(--accent-soft); color: var(--text); }
57
+ input, select, button { font: inherit; color: inherit; }
58
+ input, select { caret-color: var(--accent); accent-color: var(--accent); }
59
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
60
+ .i { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
61
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
62
+ .mono { font-family: var(--mono); font-size: 12.5px; }
63
+
64
+ /* Top bar */
65
+ header {
66
+ position: sticky; top: 0; z-index: 2;
67
+ background: color-mix(in srgb, var(--bg) 92%, transparent);
68
+ backdrop-filter: blur(8px);
69
+ border-bottom: 1px solid var(--line);
70
+ }
71
+ .bar { max-width: 1080px; margin: 0 auto; padding: 12px 24px; display: flex; align-items: center; gap: 16px; }
72
+ .brand { font-weight: 700; font-size: 16px; letter-spacing: -.01em; display: flex; align-items: center; gap: 8px; }
73
+ .brand::before { content: ""; width: 8px; height: 8px; border-radius: 2px; background: var(--accent); }
74
+ nav { display: flex; gap: 2px; }
75
+ .tab {
76
+ background: none; border: 0; padding: 6px 10px; border-radius: var(--r);
77
+ color: var(--muted); cursor: pointer; display: flex; gap: 6px; align-items: baseline;
78
+ }
79
+ .tab:hover { color: var(--text); background: var(--raise); }
80
+ .tab[aria-current="page"] { color: var(--text); background: var(--raise); }
81
+ .tab .n { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
82
+ .search { margin-left: auto; position: relative; width: min(320px, 40vw); }
83
+ .search .i { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--muted); }
84
+ .search input { width: 100%; padding-left: 32px; padding-right: 28px; }
85
+ .search kbd {
86
+ position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
87
+ font: 11px var(--mono); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 4px; padding: 0 5px;
88
+ }
89
+
90
+ /* Controls */
91
+ input[type="text"], input[type="number"], input[type="search"], select {
92
+ background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--r);
93
+ padding: 6px 9px; min-width: 0; height: 32px;
94
+ }
95
+ input::placeholder { color: var(--muted); opacity: .8; }
96
+ input:hover, select:hover { border-color: var(--muted); }
97
+ input[aria-invalid="true"] { border-color: var(--danger); }
98
+ .btn {
99
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
100
+ height: 32px; padding: 0 12px; border-radius: var(--r); cursor: pointer; white-space: nowrap;
101
+ background: var(--raise); border: 1px solid var(--line-strong);
102
+ transition: background-color .15s ease-out, border-color .15s ease-out, color .15s ease-out;
103
+ }
104
+ .btn:hover { border-color: var(--muted); }
105
+ .btn:disabled { opacity: .5; cursor: default; }
106
+ .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
107
+ .btn.primary:hover { filter: brightness(1.08); }
108
+ .btn.danger { color: var(--danger); }
109
+ .btn.danger.solid { background: var(--danger); border-color: var(--danger); color: var(--bg); font-weight: 600; }
110
+ .btn.ghost { background: none; border-color: transparent; color: var(--muted); }
111
+ .btn.ghost:hover { color: var(--text); background: var(--raise); }
112
+ .btn.icon { width: 32px; padding: 0; }
113
+ .btn.ghost.danger:hover { color: var(--danger); background: var(--danger-soft); }
114
+
115
+ main { max-width: 1080px; margin: 0 auto; padding: 20px 24px 96px; }
116
+ h1 { font-size: 20px; letter-spacing: -.015em; margin: 8px 0 4px; }
117
+ .lede { color: var(--muted); margin: 0 0 16px; }
118
+ .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
119
+ .toolbar .grow { flex: 1; }
120
+
121
+ /* Tag filter */
122
+ .chips { display: flex; gap: 6px; flex-wrap: wrap; }
123
+ .chip {
124
+ border: 1px solid var(--line-strong); background: none; border-radius: 99px;
125
+ padding: 2px 10px; font-size: 12.5px; color: var(--muted); cursor: pointer;
126
+ }
127
+ .chip:hover { color: var(--text); }
128
+ .chip[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--text); }
129
+
130
+ /* Notice for broken paths */
131
+ .notice {
132
+ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
133
+ border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft);
134
+ border-radius: var(--r); padding: 10px 12px; margin-bottom: 12px;
135
+ }
136
+ .notice .i { color: var(--warn); }
137
+ .notice p { margin: 0; flex: 1; min-width: 200px; }
138
+
139
+ /* Forms (inline, never modal) */
140
+ .form {
141
+ border: 1px solid var(--line-strong); background: var(--panel); border-radius: var(--r);
142
+ padding: 14px; margin-bottom: 12px; display: grid; gap: 10px;
143
+ }
144
+ .form .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
145
+ .form .row > input { flex: 1 1 160px; }
146
+ .form .row > .wide { flex: 3 1 260px; }
147
+ .form .err { color: var(--danger); margin: 0; font-size: 13px; }
148
+ .seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: var(--r); padding: 2px; gap: 2px; }
149
+ .seg label { display: flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 4px; cursor: pointer; color: var(--muted); }
150
+ .seg input { position: absolute; opacity: 0; pointer-events: none; }
151
+ .seg input:checked + label { background: var(--raise); color: var(--text); }
152
+ .seg input:focus-visible + label { outline: 2px solid var(--accent); }
153
+
154
+ /* Shortcut list */
155
+ .list { border: 1px solid var(--line); border-radius: var(--r); background: var(--panel); overflow: hidden; }
156
+ .item {
157
+ display: grid; grid-template-columns: 18px minmax(120px, 1.1fr) minmax(0, 2.2fr) 64px auto;
158
+ align-items: center; gap: 14px; padding: 9px 12px; border-top: 1px solid var(--line);
159
+ }
160
+ .item:first-child { border-top: 0; }
161
+ .item:hover { background: color-mix(in srgb, var(--raise) 55%, transparent); }
162
+ .item > .kind { display: flex; color: var(--muted); }
163
+ .item.broken > .kind { color: var(--warn); }
164
+ .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
165
+ .tags { display: flex; gap: 4px; margin-top: 2px; flex-wrap: wrap; }
166
+ .tag { font-size: 12px; color: var(--muted); }
167
+ .target { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: ltr; }
168
+ .target .miss { color: var(--warn); font-family: var(--sans); font-size: 12px; margin-right: 8px; }
169
+ .when { color: var(--muted); font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; }
170
+ .actions { display: flex; gap: 4px; align-items: center; justify-content: flex-end; }
171
+ .actions select { height: 32px; width: 122px; }
172
+ .actions input.count { width: 56px; }
173
+ .actions input.start { width: 150px; }
174
+ [hidden] { display: none !important; }
175
+ .item.editing { grid-template-columns: 1fr; background: var(--raise); }
176
+ .item.editing .form { margin: 0; border: 0; padding: 4px 0; background: none; }
177
+ .confirm { display: flex; gap: 6px; align-items: center; }
178
+ .confirm span { color: var(--danger); margin-right: 4px; }
179
+
180
+ .empty { text-align: center; padding: 48px 24px; color: var(--muted); }
181
+ .empty strong { display: block; color: var(--text); font-size: 15px; margin-bottom: 4px; }
182
+ .empty .btn { margin-top: 14px; }
183
+
184
+ /* Workspaces */
185
+ .ws { border: 1px solid var(--line); border-radius: var(--r); background: var(--panel); margin-bottom: 12px; }
186
+ .ws-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
187
+ .ws-head h2 { font-size: 15px; margin: 0; flex: 1; }
188
+ .ws-head input { flex: 1; }
189
+ .ws-entry { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(100px, 1fr) minmax(0, 2fr) auto; gap: 12px; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); }
190
+ .ws-entry .muted { color: var(--muted); }
191
+ .ws-add { padding: 10px 12px; display: flex; gap: 8px; flex-wrap: wrap; }
192
+ .ws-add select, .ws-add input { flex: 1 1 120px; }
193
+ .ws-add input.count { flex: 0 0 72px; }
194
+ .ws-add input.start { flex: 2 1 200px; }
195
+ .ws-add.editing { background: var(--raise); border-bottom: 1px solid var(--line); }
196
+
197
+ /* Status line */
198
+ #status {
199
+ position: fixed; left: 50%; bottom: 20px; transform: translate(-50%, 12px); opacity: 0;
200
+ background: var(--raise); border: 1px solid var(--line-strong); border-radius: var(--r);
201
+ padding: 8px 14px; box-shadow: 0 6px 24px rgba(0, 0, 0, .28); max-width: calc(100vw - 32px);
202
+ transition: opacity .18s cubic-bezier(.16, 1, .3, 1), transform .18s cubic-bezier(.16, 1, .3, 1);
203
+ pointer-events: none;
204
+ }
205
+ #status.show { opacity: 1; transform: translate(-50%, 0); }
206
+ #status.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 50%, transparent); }
207
+
208
+ @media (max-width: 760px) {
209
+ .bar { flex-wrap: wrap; padding: 10px 16px; gap: 10px; }
210
+ .search { width: 100%; order: 3; }
211
+ .search kbd { display: none; }
212
+ main { padding: 16px 16px 96px; }
213
+ .item { grid-template-columns: 18px minmax(0, 1fr) auto; grid-template-areas: "i n w" "i t t" "a a a"; row-gap: 6px; }
214
+ .item > .kind { grid-area: i; align-self: start; margin-top: 3px; }
215
+ .item > .nm { grid-area: n; min-width: 0; }
216
+ .item > .target { grid-area: t; }
217
+ .item > .when { grid-area: w; }
218
+ .item > .actions { grid-area: a; justify-content: flex-start; flex-wrap: wrap; }
219
+ .ws-entry { grid-template-columns: 1fr 1fr auto; }
220
+ .ws-entry .start { grid-column: 1 / 3; }
221
+ }
222
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true">
227
+ <symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.2l2 2.2h8.8A1.5 1.5 0 0 1 21 9.7v8.8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18.5z"/></symbol>
228
+ <symbol id="i-app" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M3.5 9h17M7 6.8h.01M9.5 6.8h.01"/></symbol>
229
+ <symbol id="i-cmd" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="m7.5 10 3 2.5-3 2.5M12.5 15.5h4"/></symbol>
230
+ <symbol id="i-url" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></symbol>
231
+ <symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.4-4.4"/></symbol>
232
+ <symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
233
+ <symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16z"/><path d="m13.5 6.5 4 4"/></symbol>
234
+ <symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13"/></symbol>
235
+ <symbol id="i-open" viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></symbol>
236
+ <symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4 2.8 19.5h18.4z"/><path d="M12 10v4.5M12 17.2h.01"/></symbol>
237
+ <symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5 10 17.5 19 7"/></symbol>
238
+ <symbol id="i-copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="11" height="11" rx="1.5"/><path d="M15.5 8.5v-3a1 1 0 0 0-1-1h-9a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3"/></symbol>
239
+ <symbol id="i-up" viewBox="0 0 24 24"><path d="m6 14 6-6 6 6"/></symbol>
240
+ <symbol id="i-down" viewBox="0 0 24 24"><path d="m6 10 6 6 6-6"/></symbol>
241
+ <symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
242
+ </svg>
243
+
244
+ <header>
245
+ <div class="bar">
246
+ <div class="brand">upex</div>
247
+ <nav aria-label="Views">
248
+ <button class="tab" data-view="links" aria-current="page">Shortcuts <span class="n" id="n-links"></span></button>
249
+ <button class="tab" data-view="apps">Apps <span class="n" id="n-apps"></span></button>
250
+ <button class="tab" data-view="ws">Workspaces <span class="n" id="n-ws"></span></button>
251
+ </nav>
252
+ <div class="search">
253
+ <svg class="i" aria-hidden="true"><use href="#i-search"/></svg>
254
+ <label class="sr" for="q">Search shortcuts</label>
255
+ <input id="q" type="search" placeholder="Search names, paths, tags" autocomplete="off" spellcheck="false">
256
+ <kbd>/</kbd>
257
+ </div>
258
+ </div>
259
+ </header>
260
+
261
+ <main id="main" aria-busy="true"><p class="lede">Loading shortcuts…</p></main>
262
+ <div id="status" role="status" aria-live="polite"></div>
263
+
264
+ <script nonce="__NONCE__">
265
+ const TOKEN = '__TOKEN__';
266
+ const NS = 'http://www.w3.org/2000/svg';
267
+ const KINDS = { folder: 'Folder', app: 'App', cmd: 'Command', url: 'URL' };
268
+ // Kinds each list tab can add; folders on Shortcuts, the rest on Apps.
269
+ const TAB_KINDS = { links: ['folder'], apps: ['app', 'cmd', 'url'] };
270
+ const TITLES = { links: 'Shortcuts', apps: 'Apps', ws: 'Workspaces' };
271
+ const tabLinks = () => S.links.filter(l => l.tab === S.view);
272
+ const COPY_WORD = { folder: 'path', app: 'path', cmd: 'command', url: 'URL' };
273
+ function savedSort() {
274
+ try { return localStorage.getItem('upex.sort') === 'name' ? 'name' : 'recent'; } catch { return 'recent'; }
275
+ }
276
+
277
+ const S = {
278
+ links: [], workspaces: {}, apps: [], home: '', sep: '/',
279
+ view: 'links', q: '', tag: null, sort: savedSort(),
280
+ adding: false, addKind: 'folder', addErr: '', add: { name: '', target: '', tags: '', opener: '' },
281
+ editing: null, draft: null, editErr: '',
282
+ confirming: null, confirmWs: null, editWs: null, renamingWs: null, wsNewName: '',
283
+ // Keyed by user names, so no prototype ("constructor" etc.).
284
+ openWith: Object.create(null), openCount: Object.create(null), openStart: Object.create(null),
285
+ wsDraft: Object.create(null), newWs: false, newWsName: '',
286
+ };
287
+
288
+ // DOM builder; text always goes in as text nodes, never HTML.
289
+ function h(tag, attrs, ...kids) {
290
+ const el = document.createElement(tag);
291
+ for (const [k, v] of Object.entries(attrs || {})) {
292
+ if (v == null || v === false) continue;
293
+ if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
294
+ else if (k === 'class') el.className = v;
295
+ else if (k === 'value') el.value = v;
296
+ else el.setAttribute(k, v === true ? '' : v);
297
+ }
298
+ for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid);
299
+ return el;
300
+ }
301
+ function icon(name) {
302
+ const s = document.createElementNS(NS, 'svg');
303
+ s.setAttribute('class', 'i');
304
+ s.setAttribute('aria-hidden', 'true');
305
+ const u = document.createElementNS(NS, 'use');
306
+ u.setAttribute('href', '#i-' + name);
307
+ s.append(u);
308
+ return s;
309
+ }
310
+ const btn = (label, cls, onclick, ico, extra) =>
311
+ h('button', { type: 'button', class: 'btn ' + (cls || ''), onclick, ...extra }, ico && icon(ico), label);
312
+ // Open a panel, then focus its first field (only on open, so re-renders never steal focus).
313
+ function openPanel(key, id) {
314
+ S[key] = true;
315
+ render();
316
+ document.getElementById(id)?.focus();
317
+ }
318
+
319
+ const iconBtn = (ico, label, cls, onclick, extra) =>
320
+ h('button', { type: 'button', class: 'btn icon ghost ' + (cls || ''), 'aria-label': label, title: label, onclick, ...extra }, icon(ico));
321
+
322
+ async function api(method, path, body) {
323
+ const res = await fetch('/api/' + path, {
324
+ method,
325
+ headers: { 'content-type': 'application/json', 'x-upex-token': TOKEN },
326
+ body: body && JSON.stringify(body),
327
+ });
328
+ const data = await res.json().catch(() => ({}));
329
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
330
+ return data;
331
+ }
332
+
333
+ let statusTimer;
334
+ function say(msg, isError) {
335
+ const el = document.getElementById('status');
336
+ el.textContent = msg;
337
+ el.className = 'show' + (isError ? ' error' : '');
338
+ clearTimeout(statusTimer);
339
+ statusTimer = setTimeout(() => (el.className = isError ? 'error' : ''), isError ? 6000 : 2600);
340
+ }
341
+
342
+ // Last loaded state minus the time-based score, so a no-op refresh can skip the re-render.
343
+ let seen = '';
344
+ const stamp = s => JSON.stringify(s, (k, v) => (k === 'score' ? undefined : v));
345
+ // With `skip`, a quiet refresh: no re-render when nothing changed or skip() is true by the time it lands.
346
+ async function load(skip) {
347
+ try {
348
+ const s = await api('GET', 'state');
349
+ if (skip && (stamp(s) === seen || skip())) return;
350
+ seen = stamp(s);
351
+ Object.assign(S, s);
352
+ } catch (e) {
353
+ say(e.message, true);
354
+ if (skip) return;
355
+ }
356
+ render();
357
+ }
358
+
359
+ // Run an action, report it, reload. Returns true on success.
360
+ async function act(fn, okMsg) {
361
+ try {
362
+ await fn();
363
+ if (okMsg) say(okMsg);
364
+ await load();
365
+ return true;
366
+ } catch (e) {
367
+ say(e.message, true);
368
+ await load();
369
+ return false;
370
+ }
371
+ }
372
+
373
+ function ago(t) {
374
+ if (!t) return 'never';
375
+ const m = (Date.now() - t) / 60000;
376
+ if (m < 1) return 'now';
377
+ if (m < 60) return Math.floor(m) + 'm ago';
378
+ if (m < 1440) return Math.floor(m / 60) + 'h ago';
379
+ if (m < 43200) return Math.floor(m / 1440) + 'd ago';
380
+ return Math.floor(m / 43200) + 'mo ago';
381
+ }
382
+
383
+ function placeholder(kind) {
384
+ if (kind === 'url') return 'github.com/you/repo';
385
+ if (kind === 'cmd') return 'command line, runs in a terminal, e.g. npm run dev';
386
+ if (kind === 'app') return S.sep === '\\' ? 'C:\\Program Files\\App\\app.exe or a command' : '/usr/bin/app or a command';
387
+ return [S.home, 'projects', 'my-app'].join(S.sep);
388
+ }
389
+
390
+ const appLabel = key => (S.apps.find(a => a.key === key) || {}).label || key;
391
+ const isTerm = key => Boolean((S.apps.find(a => a.key === key) || {}).terminal);
392
+
393
+ // Default opener picker for folder shortcuts ('' = none). Shows the current one even if it's copy or unknown.
394
+ const openerSelect = (value, set) => h('select', { 'aria-label': 'Default opener', onchange: e => set(e.target.value) },
395
+ h('option', { value: '' }, 'Default opener'),
396
+ S.apps.filter(a => a.key !== 'copy' || a.key === value).map(a => h('option', { value: a.key, selected: value === a.key }, a.label)),
397
+ !!value && !S.apps.some(a => a.key === value) && h('option', { value, selected: true }, value + ' (unknown)'));
398
+
399
+ // ---------- Shortcuts view ----------
400
+
401
+ function visibleLinks() {
402
+ const q = S.q.trim().toLowerCase();
403
+ return tabLinks()
404
+ .filter(l => !S.tag || l.tags.includes(S.tag))
405
+ .filter(l => !q || [l.name, l.target, ...l.tags].some(s => s.toLowerCase().includes(q)))
406
+ .sort((a, b) => (S.sort === 'name' ? 0 : b.score - a.score) || a.name.localeCompare(b.name));
407
+ }
408
+
409
+ function addForm() {
410
+ const err = h('p', { class: 'err', role: 'alert' }, S.addErr);
411
+ const d = S.add;
412
+ const field = (key, attrs) => h('input', { type: 'text', value: d[key], spellcheck: 'false', autocomplete: 'off',
413
+ oninput: e => (d[key] = e.target.value), ...attrs });
414
+ const target = field('target', { class: 'wide mono', placeholder: placeholder(S.addKind), 'aria-label': KINDS[S.addKind] + ' target' });
415
+ const kinds = TAB_KINDS[S.view];
416
+ const seg = kinds.length > 1 && h('div', { class: 'seg', role: 'radiogroup', 'aria-label': 'Kind' },
417
+ kinds.map(k => [k, KINDS[k]]).map(([k, label]) => [
418
+ h('input', { type: 'radio', name: 'kind', id: 'k-' + k, checked: S.addKind === k, onchange: () => {
419
+ S.addKind = k;
420
+ target.placeholder = placeholder(k);
421
+ target.setAttribute('aria-label', label + ' target');
422
+ } }),
423
+ h('label', { for: 'k-' + k }, icon(k), label),
424
+ ]));
425
+ const submit = async (e) => {
426
+ e.preventDefault();
427
+ try {
428
+ // The draft is shared across tabs; an opener picked for a folder must not ride along on an app.
429
+ await api('POST', 'links', { kind: S.addKind, ...d, opener: S.addKind === 'folder' ? d.opener : undefined });
430
+ say(`Added ${d.name.trim()}`);
431
+ S.adding = false; S.addErr = ''; S.add = { name: '', target: '', tags: '', opener: '' };
432
+ await load();
433
+ } catch (ex) {
434
+ err.textContent = S.addErr = ex.message;
435
+ }
436
+ };
437
+ const form = h('form', { class: 'form', onsubmit: submit, onkeydown: e => e.key === 'Escape' && close() },
438
+ seg && h('div', { class: 'row' }, seg),
439
+ h('div', { class: 'row' },
440
+ field('name', { id: 'add-name', placeholder: 'name', 'aria-label': 'Name' }),
441
+ target,
442
+ field('tags', { placeholder: 'tags, comma separated', 'aria-label': 'Tags' }),
443
+ S.addKind === 'folder' && openerSelect(d.opener, v => (d.opener = v))),
444
+ h('div', { class: 'row' },
445
+ h('button', { type: 'submit', class: 'btn primary' }, icon('plus'), S.view === 'apps' ? 'Add app' : 'Add shortcut'),
446
+ btn('Cancel', 'ghost', () => close())),
447
+ err);
448
+ function close() { S.adding = false; S.addErr = ''; render(); }
449
+ return form;
450
+ }
451
+
452
+ function editRow(l) {
453
+ const d = S.draft;
454
+ const err = h('p', { class: 'err', role: 'alert' }, S.editErr);
455
+ const field = (key, attrs) => h('input', { type: 'text', value: d[key], spellcheck: 'false', autocomplete: 'off',
456
+ oninput: e => (d[key] = e.target.value), ...attrs });
457
+ const name = field('name', { id: 'edit-name', 'aria-label': 'Name' });
458
+ const save = async (e) => {
459
+ e.preventDefault();
460
+ try {
461
+ await api('PUT', 'links', { old: l.name, ...d });
462
+ say(d.name.trim() !== l.name ? `Renamed ${l.name} to ${d.name.trim()}` : `Saved ${l.name}`);
463
+ S.editing = null; S.editErr = '';
464
+ await load();
465
+ } catch (ex) {
466
+ err.textContent = S.editErr = ex.message;
467
+ }
468
+ };
469
+ const cancel = () => { S.editing = null; S.editErr = ''; render(); };
470
+ return h('div', { class: 'item editing' },
471
+ h('form', { class: 'form', onsubmit: save, onkeydown: e => e.key === 'Escape' && cancel() },
472
+ h('div', { class: 'row' },
473
+ name,
474
+ field('target', { class: 'wide mono', 'aria-label': KINDS[l.kind] + ' target', placeholder: placeholder(l.kind) }),
475
+ field('tags', { 'aria-label': 'Tags', placeholder: 'tags, comma separated' }),
476
+ // Opener is only sent once changed, so a stale one doesn't block saving other edits.
477
+ l.kind === 'folder' && openerSelect(d.opener ?? l.opener, v => (d.opener = v))),
478
+ h('div', { class: 'row' },
479
+ h('button', { type: 'submit', class: 'btn primary' }, icon('check'), 'Save'),
480
+ btn('Cancel', 'ghost', cancel)),
481
+ err));
482
+ }
483
+
484
+ function row(l) {
485
+ if (S.editing === l.name) return editRow(l);
486
+
487
+ let actions;
488
+ if (S.confirming === l.name) {
489
+ actions = h('div', { class: 'actions confirm' },
490
+ h('span', null, `Delete ${l.name}?`),
491
+ btn('Delete', 'danger solid', () => { S.confirming = null; act(() => api('DELETE', 'links', { name: l.name }), `Deleted ${l.name}`); }),
492
+ btn('Keep', 'ghost', () => { S.confirming = null; render(); }));
493
+ } else {
494
+ // Window count and start command only show for a terminal opener.
495
+ const term = isTerm(S.openWith[l.name] || l.opener);
496
+ const count = h('input', { type: 'number', class: 'count', min: 1, max: 9, placeholder: 'x1', value: S.openCount[l.name] || '',
497
+ hidden: !term, 'aria-label': `Windows for ${l.name}`, oninput: e => (S.openCount[l.name] = e.target.value) });
498
+ const start = h('input', { type: 'text', class: 'start mono', placeholder: 'start command', value: S.openStart[l.name] || '',
499
+ hidden: !term, spellcheck: 'false', 'aria-label': `Start command for ${l.name}`, oninput: e => (S.openStart[l.name] = e.target.value) });
500
+ const openWith = l.kind === 'folder' && h('select', { 'aria-label': `Open ${l.name} with`, onchange: e => {
501
+ S.openWith[l.name] = e.target.value;
502
+ count.hidden = start.hidden = !isTerm(e.target.value || l.opener);
503
+ } },
504
+ h('option', { value: '' }, l.opener ? 'Default: ' + appLabel(l.opener) : 'Default'),
505
+ S.apps.map(a => h('option', { value: a.key, selected: S.openWith[l.name] === a.key }, a.label)));
506
+ const open = () => act(() => api('POST', 'open', {
507
+ name: l.name, app: S.openWith[l.name] || undefined, ...(!count.hidden && { count: count.value, start: start.value }),
508
+ }), `Opened ${l.name}`);
509
+ const copy = () => Promise.resolve().then(() => navigator.clipboard.writeText(l.target))
510
+ .then(() => say('Copied ' + l.target), () => say('Could not copy to the clipboard.', true));
511
+ actions = h('div', { class: 'actions' },
512
+ openWith, count, start,
513
+ btn('Open', '', open, 'open', { disabled: l.broken, title: l.broken ? 'Path is missing. Edit it first.' : null }),
514
+ iconBtn('copy', `Copy ${COPY_WORD[l.kind]} of ${l.name}`, '', copy),
515
+ iconBtn('edit', `Edit ${l.name}`, '', () => {
516
+ S.editing = l.name; S.editErr = '';
517
+ S.draft = { name: l.name, target: l.target, tags: l.tags.join(', ') };
518
+ render();
519
+ document.getElementById('edit-name')?.focus();
520
+ }),
521
+ iconBtn('trash', `Delete ${l.name}`, 'danger', () => { S.confirming = l.name; render(); }));
522
+ }
523
+
524
+ return h('div', { class: 'item' + (l.broken ? ' broken' : '') },
525
+ h('span', { class: 'kind', title: l.broken ? 'Path missing' : KINDS[l.kind] }, icon(l.broken ? 'alert' : l.kind)),
526
+ h('div', { class: 'nm' },
527
+ h('div', { class: 'name', title: l.name }, l.name),
528
+ l.tags.length > 0 && h('div', { class: 'tags' }, l.tags.map(t => h('span', { class: 'tag' }, '#' + t)))),
529
+ h('div', { class: 'target mono', title: l.target }, l.broken && h('span', { class: 'miss' }, 'missing'), l.target),
530
+ h('div', { class: 'when', title: 'Last opened' }, ago(l.lastUsed)),
531
+ actions);
532
+ }
533
+
534
+ function linksView() {
535
+ const out = [];
536
+ const mine = tabLinks();
537
+ const isApps = S.view === 'apps';
538
+ const newLabel = isApps ? 'New app' : 'New shortcut';
539
+ const broken = S.links.filter(l => l.broken);
540
+ const tags = [...new Set(mine.flatMap(l => l.tags))].sort();
541
+
542
+ out.push(h('div', { class: 'toolbar' },
543
+ tags.length > 0 && h('div', { class: 'chips', role: 'group', 'aria-label': 'Filter by tag' },
544
+ tags.map(t => h('button', { type: 'button', class: 'chip', 'aria-pressed': String(S.tag === t),
545
+ onclick: () => { S.tag = S.tag === t ? null : t; render(); } }, '#' + t))),
546
+ h('div', { class: 'grow' }),
547
+ h('select', { id: 'sort', 'aria-label': 'Sort', onchange: e => {
548
+ S.sort = e.target.value;
549
+ try { localStorage.setItem('upex.sort', S.sort); } catch {}
550
+ render();
551
+ document.getElementById('sort').focus();
552
+ } },
553
+ h('option', { value: 'recent', selected: S.sort === 'recent' }, 'Most used'),
554
+ h('option', { value: 'name', selected: S.sort === 'name' }, 'A to Z')),
555
+ !S.adding && btn(newLabel, 'primary', () => openPanel('adding', 'add-name'), 'plus', { 'aria-keyshortcuts': 'n' })));
556
+
557
+ if (broken.length) {
558
+ out.push(h('div', { class: 'notice', role: 'region', 'aria-label': 'Missing paths' },
559
+ icon('alert'),
560
+ h('p', null, `${broken.length} folder${broken.length === 1 ? '' : 's'} no longer exist: `, broken.map(l => l.name).join(', '),
561
+ '. Edit them, or remove them all. Shortcuts that also launch an app keep the app.'),
562
+ btn(`Remove ${broken.length} missing`, 'danger', () =>
563
+ act(async () => {
564
+ const { removed } = await api('POST', 'prune');
565
+ say(`Removed ${removed.length} shortcut${removed.length === 1 ? '' : 's'}`);
566
+ }))));
567
+ }
568
+
569
+ if (S.adding) out.push(addForm());
570
+
571
+ const list = visibleLinks();
572
+ if (!mine.length) {
573
+ out.push(h('div', { class: 'list' }, h('div', { class: 'empty' },
574
+ h('strong', null, isApps ? 'No apps yet' : 'No shortcuts yet'),
575
+ isApps
576
+ ? 'An app shortcut launches a program, runs a command line in a terminal, or opens a URL. Run it here or with upex <name> in a terminal.'
577
+ : 'A shortcut is a short name for a folder. Open it here or with upex <name> in a terminal.',
578
+ h('div', null, !S.adding && btn(isApps ? 'Add your first app' : 'Add your first shortcut', 'primary', () => openPanel('adding', 'add-name'), 'plus')))));
579
+ } else if (!list.length) {
580
+ out.push(h('div', { class: 'list' }, h('div', { class: 'empty' },
581
+ h('strong', null, 'Nothing matches'),
582
+ `No shortcut has "${S.q || '#' + S.tag}" in its name, target or tags.`,
583
+ h('div', null, btn('Clear filters', '', () => { S.q = ''; S.tag = null; document.getElementById('q').value = ''; render(); })))));
584
+ } else {
585
+ out.push(h('div', { class: 'list' }, list.map(row)));
586
+ }
587
+ return out;
588
+ }
589
+
590
+ // ---------- Workspaces view ----------
591
+
592
+ // Entry form: adds an entry, or edits one in place when `editing`.
593
+ function entryForm(key, init, onSubmit, editing) {
594
+ const d = S.wsDraft[key] ||= { shortcut: '', app: '', count: '', start: '', ...init };
595
+ const set = k => e => (d[k] = e.target.value);
596
+ const names = S.links.map(l => l.name).sort((a, b) => a.localeCompare(b));
597
+ // Keep a missing shortcut selectable so editing doesn't silently drop it.
598
+ if (d.shortcut && !names.includes(d.shortcut)) names.unshift(d.shortcut);
599
+ const cancel = () => { delete S.wsDraft[key]; S.editWs = null; render(); };
600
+ return h('form', { class: 'ws-add' + (editing ? ' editing' : ''), onkeydown: e => editing && e.key === 'Escape' && cancel(), onsubmit: async e => {
601
+ e.preventDefault();
602
+ if (!d.shortcut) return say('Pick a shortcut first', true);
603
+ // onSubmit already re-rendered with this draft; clear it and render again.
604
+ if (await onSubmit({ ...d })) { delete S.wsDraft[key]; render(); }
605
+ } },
606
+ h('select', { id: editing ? 'ws-edit-first' : null, 'aria-label': 'Shortcut', onchange: set('shortcut') },
607
+ h('option', { value: '' }, 'Shortcut…'),
608
+ names.map(n => h('option', { value: n, selected: d.shortcut === n }, n))),
609
+ h('select', { 'aria-label': 'Open with', onchange: set('app') },
610
+ h('option', { value: '' }, 'Default opener'),
611
+ S.apps.map(a => h('option', { value: a.key, selected: d.app === a.key }, a.label))),
612
+ h('input', { type: 'number', class: 'count', min: 1, max: 9, placeholder: 'x1', value: d.count, 'aria-label': 'Window count (terminals)', oninput: set('count') }),
613
+ h('input', { type: 'text', class: 'start mono', placeholder: 'start command, e.g. claude (terminals)', value: d.start, spellcheck: 'false', 'aria-label': 'Start command', oninput: set('start') }),
614
+ h('button', { type: 'submit', class: 'btn' + (editing ? ' primary' : '') }, icon(editing ? 'check' : 'plus'), editing ? 'Save' : 'Add entry'),
615
+ editing && btn('Cancel', 'ghost', cancel));
616
+ }
617
+
618
+ // prev: the list this page saw (null = new); the server refuses if it changed since.
619
+ const saveWs = (name, entries, msg, prev) => act(() => api('PUT', 'workspace', { name, entries, prev }), msg);
620
+ function wsHead(name, entries) {
621
+ if (S.confirmWs === name) {
622
+ return h('div', { class: 'ws-head' }, h('h2', null, name),
623
+ h('div', { class: 'confirm' }, h('span', null, `Delete workspace ${name}?`),
624
+ btn('Delete', 'danger solid', () => { S.confirmWs = null; saveWs(name, [], `Deleted ${name}`, entries); }),
625
+ btn('Keep', 'ghost', () => { S.confirmWs = null; render(); })));
626
+ }
627
+ if (S.renamingWs === name) {
628
+ const cancel = () => { S.renamingWs = null; render(); };
629
+ return h('form', { class: 'ws-head', onkeydown: e => e.key === 'Escape' && cancel(), onsubmit: async e => {
630
+ e.preventDefault();
631
+ const to = S.wsNewName.trim();
632
+ if (to === name) return cancel();
633
+ const ok = await act(() => api('PUT', 'workspace', { name, newName: to, entries, prev: entries }), `Renamed ${name} to ${to}`);
634
+ if (ok) { S.renamingWs = null; render(); }
635
+ } },
636
+ h('input', { type: 'text', id: 'ws-rename', value: S.wsNewName, spellcheck: 'false', autocomplete: 'off', 'aria-label': `New name for ${name}`,
637
+ oninput: e => (S.wsNewName = e.target.value) }),
638
+ h('button', { type: 'submit', class: 'btn primary' }, icon('check'), 'Rename'),
639
+ btn('Cancel', 'ghost', cancel));
640
+ }
641
+ return h('div', { class: 'ws-head' }, h('h2', null, name),
642
+ btn('Open all', 'primary', () => act(() => api('POST', 'workspace/open', { name }), `Opened ${name}`), 'open'),
643
+ iconBtn('edit', `Rename workspace ${name}`, '', () => {
644
+ S.renamingWs = name; S.wsNewName = name;
645
+ render();
646
+ document.getElementById('ws-rename')?.select();
647
+ }),
648
+ iconBtn('trash', `Delete workspace ${name}`, 'danger', () => { S.confirmWs = name; render(); }));
649
+ }
650
+
651
+ function wsBlock(name, entries) {
652
+ const known = new Set(S.links.map(l => l.name));
653
+ const move = async (i, by) => {
654
+ const next = [...entries];
655
+ [next[i], next[i + by]] = [next[i + by], next[i]];
656
+ const label = up => `Move ${entries[i].shortcut} ${up ? 'up' : 'down'}`;
657
+ await saveWs(name, next, label(by < 0).replace('Move', 'Moved'), entries);
658
+ // Keep keyboard focus on the moved entry (the other arrow once it hits an end).
659
+ const btns = [...document.querySelectorAll('section.ws button:enabled')].filter(b => b.closest('section').getAttribute('aria-label') === name);
660
+ const find = up => btns.find(b => b.getAttribute('aria-label') === label(up));
661
+ (find(by < 0) || find(by > 0))?.focus();
662
+ };
663
+ // Includes the entry itself, so a reorder or outside change can't point an open edit at another entry.
664
+ const editKey = i => `${name}\0${i}\0${JSON.stringify(entries[i])}`;
665
+ return h('section', { class: 'ws', 'aria-label': name },
666
+ wsHead(name, entries),
667
+ entries.map((e, i) => S.editWs === editKey(i)
668
+ ? entryForm(editKey(i), { shortcut: e.shortcut, app: e.app || '', count: e.count > 1 ? String(e.count) : '', start: e.start || '' },
669
+ async d => {
670
+ const ok = await saveWs(name, entries.map((x, j) => (j === i ? d : x)), `Saved ${d.shortcut}`, entries);
671
+ if (ok) S.editWs = null;
672
+ return ok;
673
+ }, true)
674
+ : h('div', { class: 'ws-entry' },
675
+ h('span', { class: 'name' }, e.shortcut, !known.has(e.shortcut) && h('span', { class: 'tag', title: 'No shortcut with this name' }, ' (missing)')),
676
+ h('span', { class: 'muted' }, (e.app ? appLabel(e.app) : 'Default') + (e.count > 1 ? ` x${e.count}` : '')),
677
+ h('span', { class: 'muted mono start', title: e.start || '' }, e.start || ''),
678
+ h('div', { class: 'actions' },
679
+ iconBtn('up', `Move ${e.shortcut} up`, '', () => move(i, -1), { disabled: i === 0 }),
680
+ iconBtn('down', `Move ${e.shortcut} down`, '', () => move(i, 1), { disabled: i === entries.length - 1 }),
681
+ iconBtn('edit', `Edit ${e.shortcut} in ${name}`, '', () => {
682
+ S.editWs = editKey(i);
683
+ delete S.wsDraft[editKey(i)];
684
+ render();
685
+ document.getElementById('ws-edit-first')?.focus();
686
+ }),
687
+ iconBtn('x', `Remove ${e.shortcut} from ${name}`, 'danger', () =>
688
+ saveWs(name, entries.filter((_, j) => j !== i), entries.length === 1 ? `Deleted ${name}` : `Removed ${e.shortcut}`, entries))))),
689
+ entryForm(name, {}, d => saveWs(name, [...entries, d], `Added ${d.shortcut} to ${name}`, entries)));
690
+ }
691
+
692
+ function wsView() {
693
+ const out = [];
694
+ const names = Object.keys(S.workspaces).filter(n => !S.q || n.toLowerCase().includes(S.q.trim().toLowerCase()));
695
+ out.push(h('div', { class: 'toolbar' }, h('div', { class: 'grow' }),
696
+ !S.newWs && btn('New workspace', 'primary', () => openPanel('newWs', 'ws-name'), 'plus')));
697
+
698
+ if (S.newWs) {
699
+ const nameIn = h('input', { type: 'text', id: 'ws-name', placeholder: 'workspace name, e.g. dev', 'aria-label': 'Workspace name', spellcheck: 'false', value: S.newWsName,
700
+ oninput: e => (S.newWsName = e.target.value) });
701
+ out.push(h('div', { class: 'form', onkeydown: e => e.key === 'Escape' && (S.newWs = false, render()) },
702
+ h('div', { class: 'row' }, nameIn, btn('Cancel', 'ghost', () => { S.newWs = false; render(); })),
703
+ h('p', { class: 'lede', style: 'margin:0' }, 'Add the first entry to create it.'),
704
+ entryForm('\0new', {}, async d => {
705
+ const name = S.newWsName.trim();
706
+ if (!name) { say('Give the workspace a name first', true); return false; }
707
+ if (Object.hasOwn(S.workspaces, name)) { say(`"${name}" already exists. Add entries to it below.`, true); return false; }
708
+ const ok = await saveWs(name, [d], `Created ${name}`, null);
709
+ if (ok) { S.newWs = false; S.newWsName = ''; render(); }
710
+ return ok;
711
+ })));
712
+ }
713
+
714
+ if (!Object.keys(S.workspaces).length && !S.newWs) {
715
+ out.push(h('div', { class: 'list' }, h('div', { class: 'empty' },
716
+ h('strong', null, 'No workspaces yet'),
717
+ 'A workspace opens several shortcuts at once, like a project in VS Code plus two terminals running claude.',
718
+ h('div', null, btn('Create a workspace', 'primary', () => openPanel('newWs', 'ws-name'), 'plus')))));
719
+ }
720
+ for (const n of names) out.push(wsBlock(n, S.workspaces[n]));
721
+ return out;
722
+ }
723
+
724
+ // ---------- Shell ----------
725
+
726
+ function render() {
727
+ document.getElementById('n-links').textContent = S.links.filter(l => l.tab === 'links').length;
728
+ document.getElementById('n-apps').textContent = S.links.filter(l => l.tab === 'apps').length;
729
+ document.getElementById('n-ws').textContent = Object.keys(S.workspaces).length;
730
+ document.querySelectorAll('.tab').forEach(t =>
731
+ t.dataset.view === S.view ? t.setAttribute('aria-current', 'page') : t.removeAttribute('aria-current'));
732
+ document.getElementById('q').placeholder = S.view === 'ws' ? 'Search workspaces' : 'Search names, paths, tags';
733
+
734
+ const main = document.getElementById('main');
735
+ main.removeAttribute('aria-busy');
736
+ main.replaceChildren(
737
+ h('h1', null, TITLES[S.view]),
738
+ ...(S.view === 'ws' ? wsView() : linksView()));
739
+ }
740
+
741
+ document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
742
+ if (S.view === t.dataset.view) return;
743
+ S.view = t.dataset.view;
744
+ // Per-tab state: an open add form or tag filter belongs to the tab it was opened on.
745
+ S.adding = false; S.addErr = ''; S.tag = null; S.editing = null; S.confirming = null;
746
+ S.editWs = null; S.renamingWs = null; S.confirmWs = null;
747
+ if (TAB_KINDS[S.view]) S.addKind = TAB_KINDS[S.view][0];
748
+ render();
749
+ }));
750
+ // Coming back to the page re-checks paths and picks up CLI changes, unless something is being edited.
751
+ // Re-renders only on a real change: replacing the DOM mid-click would swallow the click that focused the window.
752
+ const busy = () => Boolean(S.adding || S.editing || S.newWs || S.editWs || S.renamingWs || S.confirming || S.confirmWs ||
753
+ document.querySelector('main :is(input, select):focus'));
754
+ window.addEventListener('focus', () => { if (!busy()) load(busy); });
755
+ document.getElementById('q').addEventListener('input', e => { S.q = e.target.value; render(); });
756
+ document.addEventListener('keydown', e => {
757
+ const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName);
758
+ if (e.key === '/' && !typing) { e.preventDefault(); document.getElementById('q').focus(); }
759
+ else if (e.key === 'n' && !typing && !e.ctrlKey && !e.metaKey && S.view !== 'ws') { e.preventDefault(); openPanel('adding', 'add-name'); }
760
+ else if (e.key === 'Escape' && document.activeElement?.id === 'q') { e.target.value = S.q = ''; e.target.blur(); render(); }
761
+ });
762
+
763
+ load();
764
+ </script>
765
+ </body>
766
+ </html>