dsh-url-router 0.4.0 → 0.4.2
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/AGENTS.md +85 -2
- package/README.md +72 -5
- package/lib/client.js +1784 -87
- package/package.json +4 -3
package/lib/client.js
CHANGED
|
@@ -35,11 +35,26 @@
|
|
|
35
35
|
|
|
36
36
|
window.__ModuleLoader__.load({
|
|
37
37
|
id: 'dsh-url-router',
|
|
38
|
-
factory:
|
|
38
|
+
factory: require => {
|
|
39
39
|
const module = { exports: {} }
|
|
40
40
|
const exports = module.exports
|
|
41
41
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
42
42
|
|
|
43
|
+
// ---------------------------------------------------------------------
|
|
44
|
+
// React, when the module loader hands one over.
|
|
45
|
+
//
|
|
46
|
+
// Routing, the address bar and the row links are plain DOM, so a host without
|
|
47
|
+
// React still gets every route. Only the plugin manager's settings card is a
|
|
48
|
+
// React component; without the runtime the card is not registered at all, and
|
|
49
|
+
// nothing else changes.
|
|
50
|
+
// ---------------------------------------------------------------------
|
|
51
|
+
let react
|
|
52
|
+
try {
|
|
53
|
+
react = typeof require === 'function' ? require('react') : undefined
|
|
54
|
+
} catch {
|
|
55
|
+
react = undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
// ---------------------------------------------------------------------
|
|
44
59
|
// main-view session: resolve the Session the main view currently shows.
|
|
45
60
|
// Reads the catalog's rows rather than a per-id retain-info source, so this
|
|
@@ -126,19 +141,39 @@ window.__ModuleLoader__.load({
|
|
|
126
141
|
const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
|
|
127
142
|
/** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
|
|
128
143
|
const PLUGINS_PANEL_ID = 'plugins'
|
|
144
|
+
/** Panel id of the automation-task manager, whose status filter and open task are routed. */
|
|
145
|
+
const SCHEDULES_PANEL_ID = 'schedules'
|
|
146
|
+
/** Inner segment that names the automation task a list has open. */
|
|
147
|
+
const TASK_SEGMENT = 'task'
|
|
148
|
+
/** Inner segment that names the model-provider card an open editor belongs to. */
|
|
149
|
+
const PROVIDER_SEGMENT = 'provider'
|
|
150
|
+
/** Inner segment that names the automation list's status filter. */
|
|
151
|
+
const FILTER_SEGMENT = 'filter'
|
|
152
|
+
/**
|
|
153
|
+
* The status filters, in the fixed order the manager renders them.
|
|
154
|
+
*
|
|
155
|
+
* Its own source maps `["all", "active", "inactive"]` over the three buttons, and the
|
|
156
|
+
* buttons carry no value of their own (only `aria-pressed`), so the position is the
|
|
157
|
+
* identity — the same positional contract the conversation view tabs already use.
|
|
158
|
+
*/
|
|
159
|
+
const SCHEDULE_FILTERS = ['all', 'active', 'inactive']
|
|
160
|
+
/** Attribute the manager puts on a task-detail tab, whose value is `rule` or `records`. */
|
|
161
|
+
const SCHEDULE_TAB_ATTRIBUTE = 'data-detail-tab'
|
|
162
|
+
/** The right bar's own "collapsed" marker: its tabs stay mounted while the bar is shut. */
|
|
163
|
+
const RIGHT_BAR_COLLAPSED_ATTRIBUTE = 'data-rightbar-collapsed'
|
|
129
164
|
/** Log prefix for every diagnostic. */
|
|
130
165
|
/**
|
|
131
166
|
* The router's own version, for the report a human files. A test keeps it equal to the
|
|
132
167
|
* package version, so a release cannot forget to bump it.
|
|
133
168
|
*/
|
|
134
|
-
const ROUTER_VERSION = '0.4.
|
|
169
|
+
const ROUTER_VERSION = '0.4.2'
|
|
135
170
|
/**
|
|
136
171
|
* Hand-maintained build marker, reported alongside the version. A working copy that is
|
|
137
172
|
* linked into a profile serves this file straight from disk, so the package version
|
|
138
173
|
* cannot say *which* copy a live window loaded — this can, and `report().build` is how a
|
|
139
174
|
* real-machine check answers that. Bump it whenever a live window must be told apart.
|
|
140
175
|
*/
|
|
141
|
-
const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss'
|
|
176
|
+
const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar+bar-select-all+recent-links+copy-link+automation-links+page-over-pane+band-yield+provider-card+perf+compat+turn-jump.12'
|
|
142
177
|
|
|
143
178
|
const TAG = '[url-router]'
|
|
144
179
|
|
|
@@ -153,12 +188,234 @@ window.__ModuleLoader__.load({
|
|
|
153
188
|
/** The bar's structural hook, the way the row anchors carry theirs. */
|
|
154
189
|
const TITLEBAR_PART = 'url-router-address-bar'
|
|
155
190
|
const TITLEBAR_STYLE_ID = 'dsh-url-router-titlebar-style'
|
|
191
|
+
/** Structural hooks the bar's recent list publishes (tests and the observer read them). */
|
|
192
|
+
const RECENT_LIST_ATTRIBUTE = 'data-dsh-url-router-recent'
|
|
193
|
+
const RECENT_ITEM_ATTRIBUTE = 'data-dsh-url-router-recent-item'
|
|
194
|
+
/** The bar's copy-confirmation chip. */
|
|
195
|
+
const TOAST_ATTRIBUTE = 'data-dsh-url-router-toast'
|
|
156
196
|
/** Width the window's caption buttons take at the right end of the strip. */
|
|
157
197
|
const TITLEBAR_CAPTION_WIDTH = 138
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
/** Gap between the shell's menu and the bar. */
|
|
199
|
+
const TITLEBAR_GAP = 8
|
|
200
|
+
/**
|
|
201
|
+
* macOS has no in-page title-bar strip, so the bar cannot borrow window chrome
|
|
202
|
+
* the way the Windows build does. It borrows the next best thing: the shell's
|
|
203
|
+
* resident header row, which exists on every screen. The bar stays mounted in
|
|
204
|
+
* the body with fixed positioning — exactly the Windows approach — and `place`
|
|
205
|
+
* measures the title cluster and the right-hand cluster to get its insets.
|
|
206
|
+
*/
|
|
207
|
+
const MAC_BAR_HEIGHT = 26
|
|
208
|
+
/** The least room the bar will draw itself into; below this it stays hidden. */
|
|
209
|
+
const MAC_BAR_MIN_WIDTH = 120
|
|
210
|
+
/** The breathing space the bar keeps from whatever stands next to it. */
|
|
211
|
+
const MAC_BAR_GAP = 8
|
|
212
|
+
/** The margin the bar leaves inside the free stretch, on each side. */
|
|
213
|
+
const MAC_BAR_INSET = 25
|
|
214
|
+
/**
|
|
215
|
+
* The bar's own length.
|
|
216
|
+
*
|
|
217
|
+
* The free stretch begins wherever the conversation's title happens to end, so
|
|
218
|
+
* without a length of its own the bar would grow and shrink as titles change.
|
|
219
|
+
* It stops here instead, and only gets shorter when the row cannot spare this
|
|
220
|
+
* much.
|
|
221
|
+
*/
|
|
222
|
+
const MAC_BAR_MAX_WIDTH = 1150
|
|
223
|
+
/**
|
|
224
|
+
* How much the window may change before a remembered stretch stops counting.
|
|
225
|
+
*
|
|
226
|
+
* The stretch was measured against one window; a different one moves the
|
|
227
|
+
* header's buttons, and there is nothing to re-measure from on a screen that
|
|
228
|
+
* has no power button. A couple of pixels is rounding, more is a resize.
|
|
229
|
+
*/
|
|
230
|
+
const MAC_BAR_RESIZE_SLACK = 8
|
|
231
|
+
const MAC_STYLE_TEXT = [
|
|
232
|
+
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"]{position:fixed;display:flex;align-items:center;height:${MAC_BAR_HEIGHT}px;z-index:1200;-webkit-app-region:no-drag}`,
|
|
233
|
+
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input{height:${MAC_BAR_HEIGHT}px;width:100%;box-sizing:border-box;border:1px solid rgba(127,127,127,.34);border-radius:8px;padding:0 10px;font:inherit;font-size:12px;color:inherit;background:rgba(127,127,127,.16);outline:none;-webkit-app-region:no-drag}`,
|
|
234
|
+
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
235
|
+
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"][data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
|
|
236
|
+
].join('')
|
|
237
|
+
/** Where the bar's last spot is kept, so a screen without a power button still gets it. */
|
|
238
|
+
const MAC_PLACEMENT_KEY = 'dsh-url-router-placement'
|
|
239
|
+
/**
|
|
240
|
+
* Describe one element: what it is, and where it sits.
|
|
241
|
+
* @param {object} el - the element to describe.
|
|
242
|
+
* @returns {object|null} the description, or null when there is no element.
|
|
243
|
+
*/
|
|
244
|
+
function macDescribe(el) {
|
|
245
|
+
if (el === null || el === undefined) return null
|
|
246
|
+
const rect = el.getBoundingClientRect?.()
|
|
247
|
+
const cls = el.className?.baseVal ?? el.className ?? ''
|
|
248
|
+
return {
|
|
249
|
+
tag: String(el.tagName ?? '?').toLowerCase(),
|
|
250
|
+
cls: typeof cls === 'string' ? cls.slice(0, 70) : '',
|
|
251
|
+
rect: rect === undefined || rect === null
|
|
252
|
+
? null
|
|
253
|
+
: { top: Math.round(rect.top), left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height) },
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Every descendant of an element that carries text of its own or answers a
|
|
258
|
+
* click — the header's real contents, so the bar can find the gap between
|
|
259
|
+
* them instead of guessing where it is.
|
|
260
|
+
* @param {object} el - the element to walk.
|
|
261
|
+
* @param {number} depth - how far down to walk.
|
|
262
|
+
* @returns {Array<object>} the leaves, with their boxes.
|
|
263
|
+
*/
|
|
264
|
+
function macLeaves(el, depth) {
|
|
265
|
+
const leaves = []
|
|
266
|
+
if (el === null || el === undefined || depth > 6) return leaves
|
|
267
|
+
for (const child of el.children ?? []) {
|
|
268
|
+
const own = ownText(child)
|
|
269
|
+
const interactive = typeof child.closest === 'function'
|
|
270
|
+
&& child.matches?.('button, a, input, [role="button"], [role="tab"], img, svg') === true
|
|
271
|
+
if (own !== '' || interactive || child.children.length === 0) {
|
|
272
|
+
leaves.push({ ...macDescribe(child), own: own.slice(0, 40) })
|
|
273
|
+
} else {
|
|
274
|
+
leaves.push(...macLeaves(child, depth + 1))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return leaves
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The things whose presence on a line means "do not draw over this".
|
|
281
|
+
*
|
|
282
|
+
* Controls only: a page heading or an icon is decoration the bar may sit on, while a tab, a
|
|
283
|
+
* button or a field is something to click or type into (measured: the automation task's detail
|
|
284
|
+
* tabs were exactly what the bar covered).
|
|
285
|
+
*/
|
|
286
|
+
const BAND_SELECTORS = ['button', '[role="tab"]', '[role="button"]', 'a', 'input', 'textarea', 'select', '[contenteditable="true"]', 'h1', 'h2', 'h3']
|
|
287
|
+
/**
|
|
288
|
+
* The page's own contents that stand on one line of the window, as merged spans.
|
|
289
|
+
*
|
|
290
|
+
* A screen with no header row to measure (a panel, the settings overlay) still draws things
|
|
291
|
+
* at the bar's line: the automation task's detail tabs, a page heading, a toolbar. Reusing a
|
|
292
|
+
* stretch measured on a conversation put the bar straight across those tabs (measured on the
|
|
293
|
+
* automation panel). Only controls, headings and icons count — a background or a plain
|
|
294
|
+
* container is not something to give way to.
|
|
295
|
+
* @param {object} document - the page.
|
|
296
|
+
* @param {object} own - the bar itself, whose box is never an obstacle.
|
|
297
|
+
* @param {number} top - the line's top edge.
|
|
298
|
+
* @param {number} height - the line's height.
|
|
299
|
+
* @returns {Array<{left: number, right: number}>} merged spans, left to right.
|
|
300
|
+
*/
|
|
301
|
+
function bandSpans(document, own, top, height) {
|
|
302
|
+
const bottom = top + height
|
|
303
|
+
const seen = new Set()
|
|
304
|
+
const spans = []
|
|
305
|
+
// One selector at a time: a comma list is beyond a small DOM stub.
|
|
306
|
+
for (const selector of BAND_SELECTORS) {
|
|
307
|
+
for (const node of attemptValue(() => [...(document?.querySelectorAll?.(selector) ?? [])]) ?? []) {
|
|
308
|
+
if (node === null || node === undefined || seen.has(node)) continue
|
|
309
|
+
seen.add(node)
|
|
310
|
+
const mine = node === own || (typeof own?.contains === 'function' && own.contains(node) === true)
|
|
311
|
+
if (mine) continue
|
|
312
|
+
const rect = attemptValue(() => node.getBoundingClientRect?.())
|
|
313
|
+
if (rect === undefined || rect === null) continue
|
|
314
|
+
if (!(rect.height > 0) || rect.top >= bottom || rect.top + rect.height <= top) continue
|
|
315
|
+
const left = Math.round(rect.left)
|
|
316
|
+
const right = Math.round(rect.left + rect.width)
|
|
317
|
+
if (right > left) spans.push({ left, right })
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
spans.sort((first, second) => first.left - second.left)
|
|
321
|
+
const merged = []
|
|
322
|
+
for (const span of spans) {
|
|
323
|
+
const last = merged[merged.length - 1]
|
|
324
|
+
if (last !== undefined && span.left <= last.right) {
|
|
325
|
+
if (span.right > last.right) last.right = span.right
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
merged.push({ left: span.left, right: span.right })
|
|
329
|
+
}
|
|
330
|
+
return merged
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The header row the bar has to live in: the row that carries the
|
|
334
|
+
* conversation's title on the left and the header's own buttons on the right.
|
|
335
|
+
*
|
|
336
|
+
* The shell names the row in its stylesheet, so that name is tried first; when
|
|
337
|
+
* it has changed, the row is taken from the power button's own corner, whose
|
|
338
|
+
* parent is the row the button was mounted on.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} trigger - the power-menu trigger.
|
|
341
|
+
* @returns {object|undefined} the row element, or undefined when there is none.
|
|
342
|
+
*/
|
|
343
|
+
function macHeaderRow(trigger) {
|
|
344
|
+
const page = attemptValue(() => window.document)
|
|
345
|
+
const corner = attemptValue(() => page?.querySelector?.('[data-conversation-header-corner]'))
|
|
346
|
+
const named = attemptValue(() => page?.querySelector?.('[class*="titleRow"]'))
|
|
347
|
+
if (named !== undefined && named !== null) {
|
|
348
|
+
// Only the row that actually holds the button is the row to measure.
|
|
349
|
+
if (trigger === undefined || trigger === null) return named
|
|
350
|
+
if (typeof named.contains !== 'function' || named.contains(trigger)) return named
|
|
351
|
+
}
|
|
352
|
+
return corner?.parentElement ?? undefined
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* The free stretch of the header row the bar can live in.
|
|
356
|
+
*
|
|
357
|
+
* The row is measured rather than guessed: every leaf that shares the power
|
|
358
|
+
* button's row — the title text, the mode label, the header's own buttons — is
|
|
359
|
+
* turned into an interval, overlapping intervals are merged, and the widest
|
|
360
|
+
* stretch between them is the one the bar goes into. Nothing is covered,
|
|
361
|
+
* because that stretch holds nothing; nothing is pushed, because the bar is a
|
|
362
|
+
* fixed-position layer of its own.
|
|
363
|
+
*
|
|
364
|
+
* @param {Array<object>} leaves - the row's measured contents.
|
|
365
|
+
* @param {object} rect - the power button's rectangle, which ends the search.
|
|
366
|
+
* @param {object} [bounds] - the row's own rectangle, which begins it.
|
|
367
|
+
* @returns {{left: number, right: number}|null} the stretch, or null when the row has no room.
|
|
368
|
+
*/
|
|
369
|
+
function macFreeGap(leaves, rect, bounds) {
|
|
370
|
+
if (rect === undefined || rect === null) return null
|
|
371
|
+
const rowTop = rect.top
|
|
372
|
+
const rowBottom = rect.top + rect.height
|
|
373
|
+
// The power button is the anchor: the bar never reaches past its left edge.
|
|
374
|
+
const limit = Math.round(rect.left)
|
|
375
|
+
const occupied = []
|
|
376
|
+
for (const leaf of leaves ?? []) {
|
|
377
|
+
const box = leaf?.rect
|
|
378
|
+
if (box === undefined || box === null) continue
|
|
379
|
+
if (!(box.width > 0) || !(box.height > 0)) continue
|
|
380
|
+
// Only what stands on the power button's row can be in the way.
|
|
381
|
+
if (box.top >= rowBottom || box.top + box.height <= rowTop) continue
|
|
382
|
+
const left = Math.round(box.left)
|
|
383
|
+
const right = Math.round(box.left + box.width)
|
|
384
|
+
if (right <= 0 || left >= limit) continue
|
|
385
|
+
occupied.push({ left, right })
|
|
386
|
+
}
|
|
387
|
+
occupied.sort((a, b) => a.left - b.left)
|
|
388
|
+
// Merge what overlaps, so a box inside another cannot split a stretch.
|
|
389
|
+
const merged = []
|
|
390
|
+
for (const span of occupied) {
|
|
391
|
+
const last = merged[merged.length - 1]
|
|
392
|
+
if (last !== undefined && span.left <= last.right) {
|
|
393
|
+
if (span.right > last.right) last.right = span.right
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
merged.push({ left: span.left, right: span.right })
|
|
397
|
+
}
|
|
398
|
+
// The row begins at its own left edge when that is known, and at the first
|
|
399
|
+
// thing standing on it otherwise.
|
|
400
|
+
const start = bounds !== undefined && bounds !== null && bounds.left > 0
|
|
401
|
+
? Math.round(bounds.left)
|
|
402
|
+
: (merged[0]?.left ?? limit)
|
|
403
|
+
const runs = []
|
|
404
|
+
let cursor = start
|
|
405
|
+
for (const span of merged) {
|
|
406
|
+
if (span.left > cursor) runs.push({ left: cursor, right: span.left })
|
|
407
|
+
if (span.right > cursor) cursor = span.right
|
|
408
|
+
}
|
|
409
|
+
if (limit > cursor) runs.push({ left: cursor, right: limit })
|
|
410
|
+
let best = null
|
|
411
|
+
for (const run of runs) {
|
|
412
|
+
if (run.right - run.left < MAC_BAR_MIN_WIDTH) continue
|
|
413
|
+
if (best === null || run.right - run.left > best.right - best.left) best = run
|
|
414
|
+
}
|
|
415
|
+
return best
|
|
416
|
+
}
|
|
417
|
+
/** How long the bar waits for a handed-over link to land before it says it did not. */
|
|
418
|
+
const TITLEBAR_SETTLE_MS = 900
|
|
162
419
|
/** What the address bar accepts, said in its placeholder. */
|
|
163
420
|
const TITLEBAR_PLACEHOLDER = '#/session/… · #/panel/… · #/settings · 会话 id · 链接'
|
|
164
421
|
/** The strip belongs to the shell, so only the bar itself is styled. */
|
|
@@ -168,6 +425,24 @@ window.__ModuleLoader__.load({
|
|
|
168
425
|
`#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
169
426
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
|
|
170
427
|
].join('')
|
|
428
|
+
/**
|
|
429
|
+
* The recent list's own stylesheet: a browser-style drop-down under the bar.
|
|
430
|
+
*
|
|
431
|
+
* Both platforms share it — the bar is the same fixed-position element in both — so
|
|
432
|
+
* the picker can never drift into two different-looking lists.
|
|
433
|
+
*/
|
|
434
|
+
const RECENT_STYLE_TEXT = [
|
|
435
|
+
`#${TITLEBAR_BAR_ID} [${RECENT_LIST_ATTRIBUTE}]{position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:5;display:none;box-sizing:border-box;max-height:min(52vh,420px);overflow:auto;padding:4px;border:1px solid rgba(127,127,127,.34);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 16px 40px rgba(0,0,0,.16);-webkit-app-region:no-drag}`,
|
|
436
|
+
`#${TITLEBAR_BAR_ID} [${RECENT_LIST_ATTRIBUTE}][data-open]{display:block}`,
|
|
437
|
+
`#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}]{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px;line-height:18px;text-align:left;cursor:pointer}`,
|
|
438
|
+
`#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}]:hover,#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}]:focus-visible{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));outline:none}`,
|
|
439
|
+
`#${TITLEBAR_BAR_ID} .dshur-kind{flex:none;padding:0 6px;border-radius:5px;background:rgba(127,127,127,.16);color:var(--dsw-alias-label-secondary,#4b5872);font-size:11px;line-height:18px}`,
|
|
440
|
+
`#${TITLEBAR_BAR_ID} .dshur-title{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary,#1b1b20)}`,
|
|
441
|
+
`#${TITLEBAR_BAR_ID} .dshur-link{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#81858c);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}`,
|
|
442
|
+
`#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}][data-copied] .dshur-link{color:var(--dsw-alias-brand-primary,#4d6bfe);font-weight:600}`,
|
|
443
|
+
`#${TITLEBAR_BAR_ID} [${TOAST_ATTRIBUTE}]{position:absolute;right:6px;top:50%;transform:translateY(-50%);z-index:6;padding:0 8px;line-height:20px;border-radius:999px;border:1px solid rgba(127,127,127,.34);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-secondary,#4b5872);font-size:11px;pointer-events:none;opacity:0;transition:opacity .12s}`,
|
|
444
|
+
`#${TITLEBAR_BAR_ID} [${TOAST_ATTRIBUTE}][data-show]{opacity:1}`,
|
|
445
|
+
].join('')
|
|
171
446
|
/** Listeners told the fragment this router just wrote; the desktop bar reads it. */
|
|
172
447
|
const routeFeed = new Set()
|
|
173
448
|
/**
|
|
@@ -175,6 +450,9 @@ window.__ModuleLoader__.load({
|
|
|
175
450
|
* @param {string} hash - the fragment just written.
|
|
176
451
|
*/
|
|
177
452
|
function publishRoute(hash) {
|
|
453
|
+
// A fragment this router just wrote names the screen the reader moved to, so it is
|
|
454
|
+
// remembered (the bar's recent list). An empty write names nothing and is skipped.
|
|
455
|
+
rememberRecent(hash)
|
|
178
456
|
for (const listener of [...routeFeed]) {
|
|
179
457
|
try {
|
|
180
458
|
listener(hash)
|
|
@@ -200,6 +478,282 @@ window.__ModuleLoader__.load({
|
|
|
200
478
|
*/
|
|
201
479
|
let driveTypedFragment = () => {}
|
|
202
480
|
|
|
481
|
+
// ---------------------------------------------------------------------
|
|
482
|
+
// preferences: how the address bar behaves, remembered on this machine
|
|
483
|
+
//
|
|
484
|
+
// Two small preferences make the bar act like a browser's: the first click
|
|
485
|
+
// selects all of the old link, and the bar remembers the screens it has named
|
|
486
|
+
// plus the links pasted into it. They belong to this window rather than to the
|
|
487
|
+
// profile, so they live in localStorage; the plugin manager page writes them
|
|
488
|
+
// (`#/panel/plugins/dsh-url-router/dsh-url-router`).
|
|
489
|
+
// ---------------------------------------------------------------------
|
|
490
|
+
/** Preference record: the two switches the settings page owns. */
|
|
491
|
+
const SETTINGS_KEY = 'dsh-url-router-settings'
|
|
492
|
+
/** Remembered links, newest first. */
|
|
493
|
+
const RECENT_KEY = 'dsh-url-router-recent'
|
|
494
|
+
/** What the bar does before the settings page has ever been opened. */
|
|
495
|
+
const DEFAULT_SETTINGS = { selectAllOnFocus: true, recentLimit: 8 }
|
|
496
|
+
/**
|
|
497
|
+
* The key the plugin manager dispatches a row's configuration under:
|
|
498
|
+
* `<package name>#<row id>`. This package declares one row whose package name and
|
|
499
|
+
* row id are the same string, so the key names it twice — and the page it opens is
|
|
500
|
+
* exactly `#/panel/plugins/dsh-url-router/dsh-url-router`.
|
|
501
|
+
*/
|
|
502
|
+
const ROW_CONFIG_KEY = 'dsh-url-router#dsh-url-router'
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* How much work the two observers have caused, for the report.
|
|
506
|
+
*
|
|
507
|
+
* A streaming answer mutates the page dozens of times a second; these counters are what a
|
|
508
|
+
* real-machine check reads to tell "coalesced" from "reconciled on every batch".
|
|
509
|
+
*/
|
|
510
|
+
const workCounts = { reconciles: 0, placements: 0 }
|
|
511
|
+
/** Listeners told the preferences changed; the settings card redraws from them. */
|
|
512
|
+
const settingsListeners = new Set()
|
|
513
|
+
/** The parsed record, read once and kept in step with every write. */
|
|
514
|
+
let settingsCache
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The settings record, with anything unusable replaced by its default.
|
|
518
|
+
* @param {unknown} raw - the parsed record, or anything that was on disk.
|
|
519
|
+
* @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record.
|
|
520
|
+
*/
|
|
521
|
+
function normaliseSettings(raw) {
|
|
522
|
+
const source = raw !== null && typeof raw === 'object' ? raw : {}
|
|
523
|
+
const limit = Number(source.recentLimit)
|
|
524
|
+
return {
|
|
525
|
+
selectAllOnFocus: typeof source.selectAllOnFocus === 'boolean' ? source.selectAllOnFocus : DEFAULT_SETTINGS.selectAllOnFocus,
|
|
526
|
+
// A count of remembered links: whole, never negative, and with no upper bound
|
|
527
|
+
// the reader did not ask for.
|
|
528
|
+
recentLimit: Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : DEFAULT_SETTINGS.recentLimit,
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Read the settings, once.
|
|
534
|
+
* @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record in force.
|
|
535
|
+
*/
|
|
536
|
+
function readSettings() {
|
|
537
|
+
if (settingsCache !== undefined) return settingsCache
|
|
538
|
+
settingsCache = normaliseSettings(attemptValue(() => {
|
|
539
|
+
const raw = globalThis.localStorage?.getItem?.(SETTINGS_KEY)
|
|
540
|
+
return raw === null || raw === undefined || raw === '' ? undefined : JSON.parse(raw)
|
|
541
|
+
}))
|
|
542
|
+
return settingsCache
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Change the settings, keep them, and tell the card.
|
|
547
|
+
* @param {object} patch - the fields to change.
|
|
548
|
+
* @returns {object} the record now in force.
|
|
549
|
+
*/
|
|
550
|
+
function updateSettings(patch) {
|
|
551
|
+
const next = normaliseSettings({ ...readSettings(), ...(patch !== null && typeof patch === 'object' ? patch : {}) })
|
|
552
|
+
settingsCache = next
|
|
553
|
+
attemptValue(() => globalThis.localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(next)))
|
|
554
|
+
// A lower count takes effect now, not at the next remembered link.
|
|
555
|
+
trimRecent()
|
|
556
|
+
for (const listener of [...settingsListeners]) {
|
|
557
|
+
try {
|
|
558
|
+
listener(next)
|
|
559
|
+
} catch {
|
|
560
|
+
// A card that throws must not stop the bar from reading the new record.
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return next
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Follow the settings from something that draws them.
|
|
568
|
+
* @param {Function} listener - called with the record after every change.
|
|
569
|
+
* @returns {() => void} the unsubscribe.
|
|
570
|
+
*/
|
|
571
|
+
function subscribeSettings(listener) {
|
|
572
|
+
settingsListeners.add(listener)
|
|
573
|
+
return () => settingsListeners.delete(listener)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* One remembered link, exactly as it was stored.
|
|
578
|
+
* @param {unknown} raw - one entry off disk.
|
|
579
|
+
* @returns {{hash: string, at: number, source: string}|undefined} the entry, or undefined when unusable.
|
|
580
|
+
*/
|
|
581
|
+
function recentEntryOf(raw) {
|
|
582
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
583
|
+
const hash = typeof raw.hash === 'string' ? raw.hash.trim() : ''
|
|
584
|
+
if (!hash.startsWith('#') || hash === '#') return undefined
|
|
585
|
+
const at = Number(raw.at)
|
|
586
|
+
return { hash, at: Number.isFinite(at) ? at : 0, source: raw.source === 'paste' ? 'paste' : 'visit' }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The remembered links, newest first.
|
|
591
|
+
* @returns {Array<object>} the entries, in the order they are shown.
|
|
592
|
+
*/
|
|
593
|
+
function readRecent() {
|
|
594
|
+
const raw = attemptValue(() => {
|
|
595
|
+
const text = globalThis.localStorage?.getItem?.(RECENT_KEY)
|
|
596
|
+
return text === null || text === undefined || text === '' ? [] : JSON.parse(text)
|
|
597
|
+
})
|
|
598
|
+
if (!Array.isArray(raw)) return []
|
|
599
|
+
return raw.map(recentEntryOf).filter(entry => entry !== undefined)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Put the remembered links on disk.
|
|
604
|
+
* @param {Array<object>} entries - the entries to keep, newest first.
|
|
605
|
+
*/
|
|
606
|
+
function writeRecent(entries) {
|
|
607
|
+
attemptValue(() => globalThis.localStorage?.setItem?.(RECENT_KEY, JSON.stringify(entries)))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The screen a remembered link stands for.
|
|
612
|
+
*
|
|
613
|
+
* A link may name a position inside a screen (`turn/…`, `row/…`, a panel's inner
|
|
614
|
+
* page); the memory is of *screens*, so the identity stops at the surface. Without
|
|
615
|
+
* this every click inside a conversation would push another entry of the same
|
|
616
|
+
* conversation and crowd the list out.
|
|
617
|
+
* @param {string} hash - the fragment.
|
|
618
|
+
* @returns {string} the identity two links of the same screen share.
|
|
619
|
+
*/
|
|
620
|
+
function recentIdentity(hash) {
|
|
621
|
+
const route = parseRoute(String(hash ?? ''))
|
|
622
|
+
if (route === undefined || route.unknown !== undefined) return String(hash ?? '')
|
|
623
|
+
if (route.kind === 'workspace') {
|
|
624
|
+
const inner = innerPath(route.inner)
|
|
625
|
+
return inner.length >= 2 ? `${ROW_KEY_PREFIX}${inner[1]}` : `${route.kind}:${route.id}`
|
|
626
|
+
}
|
|
627
|
+
return `${route.kind}:${route.id}`
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The screen the address bar names right now.
|
|
632
|
+
* @returns {string} the current fragment, or an empty string off a page.
|
|
633
|
+
*/
|
|
634
|
+
function currentScreenHash() {
|
|
635
|
+
return typeof window?.location?.hash === 'string' ? window.location.hash : ''
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* How many entries are kept on disk.
|
|
640
|
+
*
|
|
641
|
+
* The count the reader sets is the number of links the drop-down offers — the screen in
|
|
642
|
+
* the address bar is not one of them, because that is where the reader already is. That
|
|
643
|
+
* screen is still remembered (leave it and it comes back in the list), so it may not eat
|
|
644
|
+
* one of the slots: one spare slot covers it, since a screen has exactly one entry.
|
|
645
|
+
* @returns {number} the entries to keep.
|
|
646
|
+
*/
|
|
647
|
+
function recentKeep() {
|
|
648
|
+
const limit = readSettings().recentLimit
|
|
649
|
+
return limit <= 0 ? 0 : limit + 1
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Keep the remembered links at the count in force, newest first.
|
|
654
|
+
* @returns {Array<object>} the entries that are kept.
|
|
655
|
+
*/
|
|
656
|
+
function trimRecent() {
|
|
657
|
+
const keep = recentKeep()
|
|
658
|
+
const stored = readRecent()
|
|
659
|
+
const kept = keep <= 0 ? [] : stored.slice(0, keep)
|
|
660
|
+
if (kept.length !== stored.length) writeRecent(kept)
|
|
661
|
+
return kept
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The remembered links the drop-down offers: the count in force, without the screen the
|
|
666
|
+
* address bar already names, newest first.
|
|
667
|
+
* @returns {Array<object>} the entries, in the order they are shown.
|
|
668
|
+
*/
|
|
669
|
+
function recentEntries() {
|
|
670
|
+
const limit = readSettings().recentLimit
|
|
671
|
+
// The memory is off: nothing is kept and nothing is shown.
|
|
672
|
+
if (limit <= 0) return []
|
|
673
|
+
const keep = recentKeep()
|
|
674
|
+
const stored = readRecent()
|
|
675
|
+
const kept = stored.length > keep ? stored.slice(0, keep) : stored
|
|
676
|
+
// Reading never writes: `report()` calls this and it must stay side-effect free.
|
|
677
|
+
// The disc is trimmed when a link is written and when the count changes.
|
|
678
|
+
const current = currentScreenHash()
|
|
679
|
+
return kept.filter(entry => entry.hash !== current).slice(0, limit)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Remember a link: a screen the view just moved to, or a link pasted into the bar.
|
|
684
|
+
* @param {string} hash - the fragment.
|
|
685
|
+
* @param {string} [source] - `paste` for a pasted link, `visit` (the default) otherwise.
|
|
686
|
+
*/
|
|
687
|
+
function rememberRecent(hash, source) {
|
|
688
|
+
const text = typeof hash === 'string' ? hash.trim() : ''
|
|
689
|
+
if (!text.startsWith('#') || text === '#') return
|
|
690
|
+
// Nothing is remembered while the memory is off, so turning it on later starts
|
|
691
|
+
// from an empty list instead of resurrecting links from before.
|
|
692
|
+
const limit = readSettings().recentLimit
|
|
693
|
+
if (limit <= 0) return
|
|
694
|
+
const identity = recentIdentity(text)
|
|
695
|
+
const entries = readRecent().filter(entry => recentIdentity(entry.hash) !== identity)
|
|
696
|
+
entries.unshift({ hash: text, at: attemptValue(() => Date.now(), 0), source: source === 'paste' ? 'paste' : 'visit' })
|
|
697
|
+
writeRecent(entries.slice(0, recentKeep()))
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Forget every remembered link. */
|
|
701
|
+
function clearRecent() {
|
|
702
|
+
writeRecent([])
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* The conversation title the sidebar shows for an id, when it is rendered.
|
|
707
|
+
* @param {string} sessionId - the conversation id.
|
|
708
|
+
* @returns {string} the title, or an empty string when the row is not on screen.
|
|
709
|
+
*/
|
|
710
|
+
function sessionTitles() {
|
|
711
|
+
const titles = new Map()
|
|
712
|
+
const rows = attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []
|
|
713
|
+
for (const row of rows) {
|
|
714
|
+
const key = typeof row?.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
|
|
715
|
+
if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) continue
|
|
716
|
+
const text = String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
|
|
717
|
+
if (text !== '') titles.set(key, text)
|
|
718
|
+
}
|
|
719
|
+
return titles
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The title of one conversation, read from a map made once for the whole list.
|
|
724
|
+
* @param {string} sessionId - the conversation id.
|
|
725
|
+
* @param {Map<string, string>} [titles] - the row titles, when the caller already has them.
|
|
726
|
+
* @returns {string} the title, or an empty string when the row is not on screen.
|
|
727
|
+
*/
|
|
728
|
+
function sessionTitleOf(sessionId, titles) {
|
|
729
|
+
const key = `${ROW_KEY_PREFIX}${sessionId}`
|
|
730
|
+
if (titles !== undefined) return titles.get(key) ?? ''
|
|
731
|
+
for (const row of attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []) {
|
|
732
|
+
if (typeof row?.getAttribute !== 'function' || row.getAttribute('data-row-key') !== key) continue
|
|
733
|
+
return String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
|
|
734
|
+
}
|
|
735
|
+
return ''
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The words a remembered link reads as: the conversation's own title when the
|
|
740
|
+
* sidebar has it, a settings section's label, or nothing (the fragment then says it).
|
|
741
|
+
* @param {string} hash - the fragment.
|
|
742
|
+
* @returns {string} the label, or an empty string.
|
|
743
|
+
*/
|
|
744
|
+
function recentLabel(hash, titles) {
|
|
745
|
+
const route = parseRoute(String(hash ?? ''))
|
|
746
|
+
if (route === undefined || route.unknown !== undefined) return ''
|
|
747
|
+
if (route.kind === 'session') return sessionTitleOf(route.id, titles)
|
|
748
|
+
if (route.kind === 'workspace') {
|
|
749
|
+
const inner = innerPath(route.inner)
|
|
750
|
+
return inner.length >= 2 ? sessionTitleOf(inner[1], titles) : route.id
|
|
751
|
+
}
|
|
752
|
+
// A settings section's identity is its own display name.
|
|
753
|
+
if (route.kind === 'settings') return route.id
|
|
754
|
+
return ''
|
|
755
|
+
}
|
|
756
|
+
|
|
203
757
|
/**
|
|
204
758
|
* The conversation view ids the registry publishes, in registration order — the same order
|
|
205
759
|
* the header renders its tabs in, which is what lets a tab position map back to an id.
|
|
@@ -284,12 +838,6 @@ window.__ModuleLoader__.load({
|
|
|
284
838
|
return undefined
|
|
285
839
|
}
|
|
286
840
|
|
|
287
|
-
/**
|
|
288
|
-
* The kind inside a tab id: the bar builds one as `sidebar://<kind>` and appends a uuid for
|
|
289
|
-
* kinds that may be opened more than once, so the kind is right there in the address.
|
|
290
|
-
* @param {string} value - a tab id.
|
|
291
|
-
* @returns {string|undefined} the kind, when the value is one of these addresses.
|
|
292
|
-
*/
|
|
293
841
|
/**
|
|
294
842
|
* The right bar's registry, normalised: one row per page type with its kind, the label
|
|
295
843
|
* the tab shows, whether more than one may be open, and its empty-state card (measured
|
|
@@ -297,36 +845,6 @@ window.__ModuleLoader__.load({
|
|
|
297
845
|
* @param {object} faces - the probed host faces.
|
|
298
846
|
* @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
|
|
299
847
|
*/
|
|
300
|
-
/**
|
|
301
|
-
* The seq of a turn's start event, from the `turnOutline` projection.
|
|
302
|
-
*
|
|
303
|
-
* The projection is a public read on the session controller (the core reads it the same
|
|
304
|
-
* way: `session.projections.faceOf("turnOutline").getSnapshot()`), and each entry carries
|
|
305
|
-
* the `seq` of that turn's `turn/start` — which is exactly what `loadThrough` pages back
|
|
306
|
-
* to. Reading it is what makes a turn link work for a turn that is not loaded yet.
|
|
307
|
-
* @param {object} faces - the probed host faces.
|
|
308
|
-
* @param {string|undefined} sessionId - the session the turn belongs to.
|
|
309
|
-
* @param {string} turn - the turn number.
|
|
310
|
-
* @returns {number|undefined} the seq, when the projection names that turn.
|
|
311
|
-
*/
|
|
312
|
-
function turnSeqOf(faces, sessionId, turn) {
|
|
313
|
-
if (typeof sessionId !== 'string' || sessionId === '') return undefined
|
|
314
|
-
// Reached by retaining the session's scope: `sessions.binding(id)` answers undefined
|
|
315
|
-
// for a session nobody materialised yet (measured), while `retainScope(id, source)`
|
|
316
|
-
// materialises it and returns a reference whose `binding` is the live one. Released here
|
|
317
|
-
// because only the projection is read.
|
|
318
|
-
const reference = attemptValue(() => faces.sessions?.retainScope?.(sessionId, 'url-router'))
|
|
319
|
-
const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
|
|
320
|
-
attempt(() => reference?.release?.())
|
|
321
|
-
if (!Array.isArray(outline)) return undefined
|
|
322
|
-
const wanted = String(turn)
|
|
323
|
-
for (const entry of outline) {
|
|
324
|
-
if (entry === null || typeof entry !== 'object') continue
|
|
325
|
-
if (String(entry.turn) !== wanted) continue
|
|
326
|
-
return typeof entry.seq === 'number' ? entry.seq : undefined
|
|
327
|
-
}
|
|
328
|
-
return undefined
|
|
329
|
-
}
|
|
330
848
|
|
|
331
849
|
function paneRegistryOf(faces) {
|
|
332
850
|
const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
|
|
@@ -392,6 +910,72 @@ window.__ModuleLoader__.load({
|
|
|
392
910
|
warn(`${TAG} ${message}`)
|
|
393
911
|
}
|
|
394
912
|
|
|
913
|
+
/** Notes already given, per check: a compatibility problem is worth saying once. */
|
|
914
|
+
const compatNotes = new Set()
|
|
915
|
+
/** At most one compatibility sweep per this interval: each check reads the page. */
|
|
916
|
+
const COMPAT_EVERY_MS = 1000
|
|
917
|
+
/** The phrase every compatibility note carries, so a log can be searched for it. */
|
|
918
|
+
const COMPAT_MESSAGE = '与当前 DSH 版本不兼容'
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* The screens whose hooks this router reads, and what it expects to find on them.
|
|
922
|
+
*
|
|
923
|
+
* Each check pairs a *weak* signal that the screen is up (something this build still
|
|
924
|
+
* recognises) with a *strong* hook the router actually needs. A screen that is up while
|
|
925
|
+
* its strong hook is gone is exactly the silent breakage this plugin cannot afford: links
|
|
926
|
+
* stop naming that surface and nothing says so. The checks only report — they never change
|
|
927
|
+
* what the router does.
|
|
928
|
+
* @param {object} document - the page.
|
|
929
|
+
* @param {object} context - what the router knows: the active panel, conversation count and registered views.
|
|
930
|
+
* @returns {Array<{name: string, expected: string, found: string}>} the problems, empty when healthy.
|
|
931
|
+
*/
|
|
932
|
+
function compatibilityProblems(document, context) {
|
|
933
|
+
const problems = []
|
|
934
|
+
if (document === undefined || document === null || typeof document.querySelectorAll !== 'function') return problems
|
|
935
|
+
const count = selector => attemptValue(() => document.querySelectorAll?.(selector)?.length) ?? 0
|
|
936
|
+
// 1) The settings overlay. A modal carrying a nav of several buttons is this app's
|
|
937
|
+
// settings page; when neither official marker is on it, the router is reading a page it
|
|
938
|
+
// cannot name (this is the marker that already moved once).
|
|
939
|
+
for (const dialog of attemptValue(() => [...(document.querySelectorAll('[role="dialog"]') ?? [])]) ?? []) {
|
|
940
|
+
if (attemptValue(() => dialog.getAttribute?.('aria-modal')) !== 'true') continue
|
|
941
|
+
let navButtons = 0
|
|
942
|
+
for (const nav of attemptValue(() => [...(dialog.querySelectorAll('nav') ?? [])]) ?? []) {
|
|
943
|
+
navButtons += attemptValue(() => nav.querySelectorAll?.('button')?.length) ?? 0
|
|
944
|
+
}
|
|
945
|
+
if (navButtons < 3) continue
|
|
946
|
+
if (settingsOverlayOf(document) !== undefined) continue
|
|
947
|
+
problems.push({ name: 'settings-overlay', expected: 'data-shortcut-modal="settings"', found: `a modal with a ${navButtons}-button nav and no settings marker` })
|
|
948
|
+
break
|
|
949
|
+
}
|
|
950
|
+
// 2) The plugin manager. Its panel root is stable; its row/detail hooks are what links
|
|
951
|
+
// are built from.
|
|
952
|
+
if (count('[data-plugin-panel]') > 0) {
|
|
953
|
+
const hooks = count('[data-plugin-package]') + count('[data-plugin-item]') + count('[data-plugin-row]')
|
|
954
|
+
+ count('[data-plugin-detail]') + count('[data-plugin-item-detail]') + count('[data-plugin-row-detail]')
|
|
955
|
+
if (hooks === 0) {
|
|
956
|
+
problems.push({ name: 'plugin-manager', expected: 'data-plugin-package / data-plugin-item / data-plugin-row / data-plugin-detail', found: 'a plugin panel with none of them' })
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
// 3) The automation list's status filter: three aria-pressed buttons in a role=group.
|
|
960
|
+
if (context?.activePanelId === 'schedules' || count('[data-plugin-panel="schedules"]') > 0) {
|
|
961
|
+
const groups = attemptValue(() => [...(document.querySelectorAll('[role="group"]') ?? [])]) ?? []
|
|
962
|
+
if (groups.length > 0 && scheduleFilterOf(document) === undefined) {
|
|
963
|
+
problems.push({ name: 'schedules-filter', expected: 'a [role="group"] holding three aria-pressed buttons', found: `${groups.length} group(s), none matching` })
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
// 4) The conversation header's view tabs. Only when the registry really publishes more
|
|
967
|
+
// than one view: a composition with a single view has no tabs to find.
|
|
968
|
+
if ((context?.views ?? 0) >= 2 && count('[data-chat-turn]') > 0 && count('[data-conversation-tabs]') === 0) {
|
|
969
|
+
problems.push({ name: 'conversation-tabs', expected: `[${CONVERSATION_TABS_ATTRIBUTE}] in the header`, found: 'chat turns with no header container' })
|
|
970
|
+
}
|
|
971
|
+
// 5) The sidebar's conversation rows. The settings entry is the "a real sidebar is up"
|
|
972
|
+
// signal, and the catalog says there is something to list.
|
|
973
|
+
if (count(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`) > 0 && (context?.sessions ?? 0) > 0 && count('[data-row-key^="session:"]') === 0) {
|
|
974
|
+
problems.push({ name: 'sidebar-rows', expected: '[data-row-key="session:<id>"] rows', found: `a sidebar with ${context.sessions} conversation(s) and no rows` })
|
|
975
|
+
}
|
|
976
|
+
return problems
|
|
977
|
+
}
|
|
978
|
+
|
|
395
979
|
function activePaneTabOf(document, registry) {
|
|
396
980
|
// Scoped to the right bar's own root: the same dockkit attributes dress the main area
|
|
397
981
|
// too, and reading them unscoped reports the main area's panes, not this bar's tabs.
|
|
@@ -430,7 +1014,11 @@ window.__ModuleLoader__.load({
|
|
|
430
1014
|
if (named !== undefined) {
|
|
431
1015
|
const wanted = flatLabel(named).toLowerCase()
|
|
432
1016
|
const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
|
|
433
|
-
|
|
1017
|
+
if (match !== undefined) return match.kind
|
|
1018
|
+
// With a registry to check against, a label that matches nothing is a *title* — the
|
|
1019
|
+
// open file's name, a terminal's own name — never a kind. Without a registry the
|
|
1020
|
+
// label is the only name there is, so it is taken as the kind.
|
|
1021
|
+
return Array.isArray(registry) && registry.length > 0 ? undefined : named
|
|
434
1022
|
}
|
|
435
1023
|
// 2) an address-shaped id, for a future build that puts one there
|
|
436
1024
|
const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
|
|
@@ -444,6 +1032,206 @@ window.__ModuleLoader__.load({
|
|
|
444
1032
|
return undefined
|
|
445
1033
|
}
|
|
446
1034
|
|
|
1035
|
+
/**
|
|
1036
|
+
* Whether the right-hand bar is collapsed.
|
|
1037
|
+
*
|
|
1038
|
+
* A collapsed bar keeps its tab strip mounted, so "a tab is selected in the strip" is not
|
|
1039
|
+
* the same question as "that pane is on screen" — measured: after closing the bar the
|
|
1040
|
+
* address bar kept naming its last pane, which was no longer anywhere.
|
|
1041
|
+
* @param {object} document - the page.
|
|
1042
|
+
* @returns {boolean} whether the bar is collapsed.
|
|
1043
|
+
*/
|
|
1044
|
+
function rightBarCollapsedOf(document) {
|
|
1045
|
+
const marker = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_COLLAPSED_ATTRIBUTE}]`))
|
|
1046
|
+
if (marker === null || marker === undefined) return false
|
|
1047
|
+
const value = attemptValue(() => marker.getAttribute?.(RIGHT_BAR_COLLAPSED_ATTRIBUTE))
|
|
1048
|
+
return value !== 'false'
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* The status filter the automation-task list is showing.
|
|
1053
|
+
*
|
|
1054
|
+
* Read from the group's own buttons: the pressed one's *position* among the three is the
|
|
1055
|
+
* filter (see `SCHEDULE_FILTERS`). A group is the manager's when it holds exactly three
|
|
1056
|
+
* buttons, all of them pressed-state buttons, and it is not the right bar's own.
|
|
1057
|
+
* @param {object} document - the page.
|
|
1058
|
+
* @returns {string|undefined} `all`, `active` or `inactive`.
|
|
1059
|
+
*/
|
|
1060
|
+
function scheduleFilterOf(document) {
|
|
1061
|
+
const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
|
|
1062
|
+
for (const group of groups) {
|
|
1063
|
+
const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
|
|
1064
|
+
if (inside !== null && inside !== undefined) continue
|
|
1065
|
+
const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1066
|
+
if (buttons.length !== SCHEDULE_FILTERS.length) continue
|
|
1067
|
+
if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
|
|
1068
|
+
const pressed = buttons.findIndex(button => attemptValue(() => button.getAttribute?.('aria-pressed')) === 'true')
|
|
1069
|
+
if (pressed < 0) continue
|
|
1070
|
+
return SCHEDULE_FILTERS[pressed]
|
|
1071
|
+
}
|
|
1072
|
+
return undefined
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* The automation task the manager has open, and the detail tab it shows.
|
|
1077
|
+
*
|
|
1078
|
+
* The row states both facts: `aria-expanded` marks the open one, and its
|
|
1079
|
+
* `aria-describedby` ends with the task id (`<useId>-metadata-<taskId>`). The detail's
|
|
1080
|
+
* tabs carry their own value (`data-detail-tab="rule" | "records"`).
|
|
1081
|
+
* @param {object} document - the page.
|
|
1082
|
+
* @returns {{id: string, tab: string}|undefined} the open task, or undefined when none is.
|
|
1083
|
+
*/
|
|
1084
|
+
function scheduleTaskOf(document) {
|
|
1085
|
+
const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1086
|
+
for (const button of buttons) {
|
|
1087
|
+
const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
|
|
1088
|
+
if (typeof described !== 'string' || described === '') continue
|
|
1089
|
+
const match = /-metadata-(.+)$/u.exec(described)
|
|
1090
|
+
if (match === null) continue
|
|
1091
|
+
if (attemptValue(() => button.getAttribute?.('aria-expanded')) !== 'true') continue
|
|
1092
|
+
const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
|
|
1093
|
+
const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true')
|
|
1094
|
+
const tab = attemptValue(() => chosen?.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE))
|
|
1095
|
+
return { id: match[1], tab: typeof tab === 'string' && tab !== '' ? tab : 'rule' }
|
|
1096
|
+
}
|
|
1097
|
+
return undefined
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* The parts of a settings inner path.
|
|
1102
|
+
*
|
|
1103
|
+
* A settings route may name a tab (`[<tab>]`) and, one level deeper, the model-provider
|
|
1104
|
+
* card whose editor is open (`[<tab>?/provider/<id>]`). Keeping one reader for both shapes
|
|
1105
|
+
* is what lets the link's landing rule and the drive agree.
|
|
1106
|
+
* @param {string|Array<string>} inner - the inner path.
|
|
1107
|
+
* @returns {{tab: string|undefined, provider: string|undefined}} the parts.
|
|
1108
|
+
*/
|
|
1109
|
+
function settingsPartsOf(inner) {
|
|
1110
|
+
const parts = innerPath(inner)
|
|
1111
|
+
const at = parts.indexOf(PROVIDER_SEGMENT)
|
|
1112
|
+
if (at < 0) return { tab: parts[0], provider: undefined }
|
|
1113
|
+
return { tab: at > 0 ? parts[0] : undefined, provider: parts[at + 1] }
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* The model-provider card that is open in the settings overlay, when one is.
|
|
1118
|
+
*
|
|
1119
|
+
* The models section renders a card's editor inline and publishes no data hook for it: the
|
|
1120
|
+
* card is an `li` that suddenly holds a text field, and its identity lives in an action's
|
|
1121
|
+
* accessible name, which the official copy builds as `<label> <displayName>
|
|
1122
|
+
* (<provider>)`. Only the parenthesised provider id is language-independent, so that is
|
|
1123
|
+
* what a link carries.
|
|
1124
|
+
* @param {object} document - the page.
|
|
1125
|
+
* @returns {string|undefined} the open provider id.
|
|
1126
|
+
*/
|
|
1127
|
+
function openSettingsProviderId(document) {
|
|
1128
|
+
const overlay = settingsOverlayOf(document)
|
|
1129
|
+
if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
|
|
1130
|
+
for (const card of overlay.querySelectorAll('li')) {
|
|
1131
|
+
if (typeof card?.querySelector !== 'function') continue
|
|
1132
|
+
if (card.querySelector('input') === null && card.querySelector('textarea') === null) continue
|
|
1133
|
+
for (const button of card.querySelectorAll('button')) {
|
|
1134
|
+
const label = attemptValue(() => button.getAttribute?.('aria-label'))
|
|
1135
|
+
if (typeof label !== 'string' || label === '') continue
|
|
1136
|
+
const match = /\(([^()]*)\)\s*$/u.exec(label)
|
|
1137
|
+
if (match === null || match[1].trim() === '') continue
|
|
1138
|
+
return match[1].trim()
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return undefined
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Open one model-provider card's editor in the settings overlay.
|
|
1146
|
+
* @param {string} id - the provider id the link named.
|
|
1147
|
+
*/
|
|
1148
|
+
function clickSettingsProvider(id) {
|
|
1149
|
+
const wanted = String(id ?? '').trim().toLowerCase()
|
|
1150
|
+
if (wanted === '') return
|
|
1151
|
+
const overlay = settingsOverlayOf(window.document)
|
|
1152
|
+
const scope = overlay ?? window.document
|
|
1153
|
+
const buttons = attemptValue(() => [...(scope?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1154
|
+
for (const button of buttons) {
|
|
1155
|
+
const label = attemptValue(() => button.getAttribute?.('aria-label'))
|
|
1156
|
+
if (typeof label !== 'string') continue
|
|
1157
|
+
const match = /\(([^()]*)\)\s*$/u.exec(label)
|
|
1158
|
+
if (match === null || match[1].trim().toLowerCase() !== wanted) continue
|
|
1159
|
+
// Already open: the next press would close it, and the retry loop would then flip the
|
|
1160
|
+
// card open and shut until the link expired.
|
|
1161
|
+
const card = attemptValue(() => button.closest?.('li'))
|
|
1162
|
+
if (card !== null && card !== undefined) {
|
|
1163
|
+
const open = attemptValue(() => card.querySelector?.('input') !== null || card.querySelector?.('textarea') !== null)
|
|
1164
|
+
if (open === true) return
|
|
1165
|
+
}
|
|
1166
|
+
// The edit control comes before the delete control in a card's actions.
|
|
1167
|
+
if (typeof button.click === 'function') attemptValue(() => button.click())
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Press one status filter of the automation list.
|
|
1174
|
+
* @param {string} token - `all`, `active` or `inactive`.
|
|
1175
|
+
*/
|
|
1176
|
+
function clickScheduleFilter(token) {
|
|
1177
|
+
const order = SCHEDULE_FILTERS.indexOf(String(token ?? ''))
|
|
1178
|
+
if (order < 0) return
|
|
1179
|
+
const document = window.document
|
|
1180
|
+
const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
|
|
1181
|
+
for (const group of groups) {
|
|
1182
|
+
const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
|
|
1183
|
+
if (inside !== null && inside !== undefined) continue
|
|
1184
|
+
const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1185
|
+
if (buttons.length !== SCHEDULE_FILTERS.length) continue
|
|
1186
|
+
if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
|
|
1187
|
+
const button = buttons[order]
|
|
1188
|
+
if (typeof button?.click === 'function') attemptValue(() => button.click())
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Open one automation task's detail, and the detail tab it names.
|
|
1195
|
+
* @param {string} id - the task id.
|
|
1196
|
+
* @param {string} [tab] - `rule` or `records`.
|
|
1197
|
+
*/
|
|
1198
|
+
function clickScheduleTask(id, tab) {
|
|
1199
|
+
const wanted = String(id ?? '')
|
|
1200
|
+
if (wanted === '') return
|
|
1201
|
+
const document = window.document
|
|
1202
|
+
const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1203
|
+
let found = false
|
|
1204
|
+
for (const button of buttons) {
|
|
1205
|
+
const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
|
|
1206
|
+
if (typeof described !== 'string' || !described.endsWith(`-metadata-${wanted}`)) continue
|
|
1207
|
+
found = true
|
|
1208
|
+
if (typeof button.click === 'function') attemptValue(() => button.click())
|
|
1209
|
+
break
|
|
1210
|
+
}
|
|
1211
|
+
// A filter hides the rows it does not match, so a link to a task outside it has nothing
|
|
1212
|
+
// to open. The list is widened to "all" and the next retry finds the row.
|
|
1213
|
+
if (!found) clickScheduleFilter(SCHEDULE_FILTERS[0])
|
|
1214
|
+
if (typeof tab !== 'string' || tab === '' || tab === 'rule') return
|
|
1215
|
+
const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
|
|
1216
|
+
const chosen = tabs.find(candidate => attemptValue(() => candidate.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE)) === tab)
|
|
1217
|
+
if (typeof chosen?.click === 'function') attemptValue(() => chosen.click())
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* `filter/all` names the state a bare automation list is already in, so a link carrying it
|
|
1222
|
+
* is read as the bare panel: the router never writes it, and dropping it here keeps the
|
|
1223
|
+
* link comparable (it lands instead of waiting for a page that will never say `all`).
|
|
1224
|
+
* @param {string} kind - the route kind.
|
|
1225
|
+
* @param {string} id - the route identity.
|
|
1226
|
+
* @param {Array<string>} parts - the inner path segments.
|
|
1227
|
+
* @returns {Array<string>} the segments to use.
|
|
1228
|
+
*/
|
|
1229
|
+
function normalisePanelInner(kind, id, parts) {
|
|
1230
|
+
if (kind !== 'panel' || id !== SCHEDULES_PANEL_ID) return parts
|
|
1231
|
+
if (parts.length >= 2 && parts[0] === FILTER_SEGMENT && parts[1] === SCHEDULE_FILTERS[0]) return parts.slice(2)
|
|
1232
|
+
return parts
|
|
1233
|
+
}
|
|
1234
|
+
|
|
447
1235
|
/**
|
|
448
1236
|
* Read a route out of a location fragment.
|
|
449
1237
|
* @param {string} hash - the raw `window.location.hash`, including the leading `#`.
|
|
@@ -523,7 +1311,7 @@ window.__ModuleLoader__.load({
|
|
|
523
1311
|
}
|
|
524
1312
|
// A panel may have an inner page (a plugin's detail view, for instance): the
|
|
525
1313
|
// rest of the path names it, per panel.
|
|
526
|
-
const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
|
|
1314
|
+
const inner = normalisePanelInner(kind, id, segments.map(decodePart).filter(segment => segment.trim() !== ''))
|
|
527
1315
|
return { kind, id, inner: inner.length === 0 ? undefined : inner }
|
|
528
1316
|
}
|
|
529
1317
|
|
|
@@ -584,6 +1372,27 @@ window.__ModuleLoader__.load({
|
|
|
584
1372
|
return rowId === '' ? undefined : { packageName: undefined, rowId }
|
|
585
1373
|
}
|
|
586
1374
|
|
|
1375
|
+
/**
|
|
1376
|
+
* The control that opens the page a plugin row describes.
|
|
1377
|
+
*
|
|
1378
|
+
* The manager puts the row's own link first and its enable switch beside it, and both
|
|
1379
|
+
* are buttons. Clicking the switch on behalf of a pasted link would silently turn a
|
|
1380
|
+
* plugin off (measured: a component page link did exactly that while the row had no
|
|
1381
|
+
* configuration), so the switch is never picked. A row whose only button is the switch
|
|
1382
|
+
* has no page to open, and its link is left to expire with the usual diagnostic.
|
|
1383
|
+
* @param {object} element - the row, card or list item.
|
|
1384
|
+
* @returns {object|undefined} the control to click.
|
|
1385
|
+
*/
|
|
1386
|
+
function openControlOf(element) {
|
|
1387
|
+
const buttons = attemptValue(() => [...(element?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1388
|
+
for (const button of buttons) {
|
|
1389
|
+
const role = typeof button?.getAttribute === 'function' ? button.getAttribute('role') : null
|
|
1390
|
+
if (role === 'switch') continue
|
|
1391
|
+
if (typeof button?.click === 'function') return button
|
|
1392
|
+
}
|
|
1393
|
+
return undefined
|
|
1394
|
+
}
|
|
1395
|
+
|
|
587
1396
|
function innerPath(value) {
|
|
588
1397
|
const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
|
|
589
1398
|
return parts.map(part => String(part).trim()).filter(part => part !== '')
|
|
@@ -653,7 +1462,7 @@ window.__ModuleLoader__.load({
|
|
|
653
1462
|
let panelScope
|
|
654
1463
|
/** The turn the user last clicked inside a conversation, with its session. */
|
|
655
1464
|
/** What the report can say about this run: fragments it refused, kinds it left alone. */
|
|
656
|
-
const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
|
|
1465
|
+
const observed = { foreignFragment: undefined, ignored: [], warnings: [], compat: [] }
|
|
657
1466
|
let rowAnchor
|
|
658
1467
|
/** The pane kind this claim already opened (one drive per claim, see the pane branch). */
|
|
659
1468
|
let paneOpenedFor
|
|
@@ -665,6 +1474,8 @@ window.__ModuleLoader__.load({
|
|
|
665
1474
|
let innerAttempts = 0
|
|
666
1475
|
/** Whether the current link already asked for a modal standing in front of it to go away. */
|
|
667
1476
|
let overlayDismissed = false
|
|
1477
|
+
/** When the compatibility checks last read the page (`-Infinity`: never swept). */
|
|
1478
|
+
let lastCompatSweep = -Infinity
|
|
668
1479
|
/** The view state observed on the previous notification. */
|
|
669
1480
|
let seen = 'none'
|
|
670
1481
|
let cancelTimer
|
|
@@ -694,22 +1505,32 @@ window.__ModuleLoader__.load({
|
|
|
694
1505
|
if (settingsOverlayOf(window.document) !== undefined) {
|
|
695
1506
|
const section = host.activeSettingsSection() ?? ''
|
|
696
1507
|
const tab = section === '' ? undefined : host.activeSettingsTab()
|
|
697
|
-
|
|
1508
|
+
// A model-provider card whose editor is open is a page of its own: its id rides
|
|
1509
|
+
// after the section (and after the tab, when the section has one).
|
|
1510
|
+
const provider = section === '' ? undefined : host.activeSettingsProvider()
|
|
1511
|
+
const inner = [...(tab === undefined ? [] : [tab]), ...(provider === undefined ? [] : [PROVIDER_SEGMENT, provider])]
|
|
1512
|
+
return inner.length === 0 ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner }
|
|
698
1513
|
}
|
|
699
1514
|
const panelId = host.activePanelId()
|
|
700
1515
|
if (panelId !== undefined && panelId !== null) {
|
|
701
1516
|
const id = String(panelId)
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
1517
|
+
// The panel's own inner page comes first: a panel that reports a page has a page on
|
|
1518
|
+
// screen that the reader can name, and the right-hand pane is a layer *beside* it.
|
|
1519
|
+
// Naming the pane first is what used to hide the automation task's own link behind
|
|
1520
|
+
// `pane/<kind>` (measured live).
|
|
706
1521
|
const page = host.panelPage(id, panelScope)
|
|
707
|
-
if (page === undefined) return { kind: 'panel', id, inner: undefined }
|
|
708
1522
|
const segments = innerPath(page)
|
|
709
1523
|
// Remember the package a page was opened under, so a bare component page (which
|
|
710
1524
|
// reports only its row) can still be named fully.
|
|
711
1525
|
if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
|
|
712
|
-
|
|
1526
|
+
// A panel's own page names what the main column shows, and the right-hand pane is
|
|
1527
|
+
// secondary to it: the reader asked for the task (or the plugin row), not for the
|
|
1528
|
+
// pane standing beside it, so the pane gives way. It is named only on a panel that
|
|
1529
|
+
// has no page of its own. A collapsed bar keeps its tabs mounted, so it never
|
|
1530
|
+
// counts at all (measured: the pane segment outlived its own bar).
|
|
1531
|
+
const pane = segments.length > 0 || rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
|
|
1532
|
+
const inner = pane === undefined ? segments : [PANE_SEGMENT, pane]
|
|
1533
|
+
return inner.length === 0 ? { kind: 'panel', id, inner: undefined } : { kind: 'panel', id, inner }
|
|
713
1534
|
}
|
|
714
1535
|
const session = host.mainViewSessionId()
|
|
715
1536
|
if (session !== undefined && session !== null) {
|
|
@@ -738,7 +1559,8 @@ window.__ModuleLoader__.load({
|
|
|
738
1559
|
// The right-hand pane is its own layer, not a position inside the conversation:
|
|
739
1560
|
// it is named next to whatever the main column shows, never instead of it — so it
|
|
740
1561
|
// is read *before* the workspace branch, or the workspace would swallow it.
|
|
741
|
-
|
|
1562
|
+
// A collapsed bar keeps its tabs mounted, so it only counts while it is on screen.
|
|
1563
|
+
const pane = rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
|
|
742
1564
|
const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
|
|
743
1565
|
const workspaceName = host.workspaceNameOf(id)
|
|
744
1566
|
if (workspaceName !== undefined) {
|
|
@@ -784,6 +1606,27 @@ window.__ModuleLoader__.load({
|
|
|
784
1606
|
// that is no longer in flight is never "the same route" as anything.
|
|
785
1607
|
if (linked === undefined || linked === null) return false
|
|
786
1608
|
if (routeKey(current) === routeKey(linked)) return true
|
|
1609
|
+
// A panel's page may be *more* specific than the link (`plugins/<pkg>/<row>` for a
|
|
1610
|
+
// `plugins/<pkg>` link), and the right-hand pane is a layer beside that page rather
|
|
1611
|
+
// than part of it — so it is compared only when the link names one. Exact comparison
|
|
1612
|
+
// is what left a link waiting on a page that was already open.
|
|
1613
|
+
if (current?.kind === 'panel' || linked?.kind === 'panel') {
|
|
1614
|
+
if (current?.kind !== linked.kind || current.id !== linked.id) return false
|
|
1615
|
+
const pageOf = route => {
|
|
1616
|
+
const parts = innerPath(route?.inner)
|
|
1617
|
+
const at = parts.indexOf(PANE_SEGMENT)
|
|
1618
|
+
return at < 0 ? parts : parts.slice(0, at)
|
|
1619
|
+
}
|
|
1620
|
+
// A link that names a pane asks for that pane, whatever else it names.
|
|
1621
|
+
const wantedPane = paneOf(linked)
|
|
1622
|
+
if (wantedPane !== undefined && paneOf(current) !== wantedPane) return false
|
|
1623
|
+
const wanted = pageOf(linked)
|
|
1624
|
+
if (wanted.length === 0) return true
|
|
1625
|
+
// A page link is satisfied once that page is on screen; a link may be wider than the
|
|
1626
|
+
// view (an open component satisfies a package link) but never narrower.
|
|
1627
|
+
const shown = pageOf(current)
|
|
1628
|
+
return wanted.length <= shown.length && wanted.every((part, index) => shown[index] === part)
|
|
1629
|
+
}
|
|
787
1630
|
const currentSession = sessionIdOfRoute(current)
|
|
788
1631
|
const linkedSession = sessionIdOfRoute(linked)
|
|
789
1632
|
if (currentSession !== undefined && linkedSession !== undefined) {
|
|
@@ -827,8 +1670,12 @@ window.__ModuleLoader__.load({
|
|
|
827
1670
|
if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
|
|
828
1671
|
if (linked.id === '') return true
|
|
829
1672
|
if (current.id !== linked.id) return false
|
|
830
|
-
const
|
|
831
|
-
|
|
1673
|
+
const wanted = settingsPartsOf(linked.inner)
|
|
1674
|
+
const shown = settingsPartsOf(current.inner)
|
|
1675
|
+
if (wanted.tab !== undefined && wanted.tab !== shown.tab) return false
|
|
1676
|
+
// A provider card is a page one level deeper than its section: a link that names one
|
|
1677
|
+
// has to see that one open (the section alone is satisfied by any of its cards).
|
|
1678
|
+
return wanted.provider === undefined || wanted.provider === shown.provider
|
|
832
1679
|
}
|
|
833
1680
|
/** Write the fragment for the current view, leaving path and query untouched. */
|
|
834
1681
|
const reflect = route => {
|
|
@@ -861,6 +1708,7 @@ window.__ModuleLoader__.load({
|
|
|
861
1708
|
*/
|
|
862
1709
|
const reconcile = (source = 'boot') => {
|
|
863
1710
|
if (disposed) return
|
|
1711
|
+
workCounts.reconciles += 1
|
|
864
1712
|
|
|
865
1713
|
const current = canonical()
|
|
866
1714
|
const currentKey = routeKey(current)
|
|
@@ -873,6 +1721,22 @@ window.__ModuleLoader__.load({
|
|
|
873
1721
|
if (paneTabs > 0 && host.activePaneKind() === undefined) {
|
|
874
1722
|
notePaneShape(message => host.warn(message), 'pane-shape', `the right bar shows ${paneTabs} tab(s) but this build cannot read the active one; the first tab is: ${activePaneTabOf.observed ?? 'no attributes read'}`)
|
|
875
1723
|
}
|
|
1724
|
+
// Say each compatibility problem once, before anything else in this step: a screen whose
|
|
1725
|
+
// hooks moved does not throw, it just stops being nameable.
|
|
1726
|
+
const sweepAt = host.now()
|
|
1727
|
+
if (sweepAt - lastCompatSweep >= COMPAT_EVERY_MS) {
|
|
1728
|
+
lastCompatSweep = sweepAt
|
|
1729
|
+
const problems = compatibilityProblems(window.document, {
|
|
1730
|
+
activePanelId: host.activePanelId(),
|
|
1731
|
+
sessions: attemptValue(() => host.listedSessions?.()) ?? 0,
|
|
1732
|
+
views: attemptValue(() => host.conversationViewIds?.())?.length ?? 0,
|
|
1733
|
+
})
|
|
1734
|
+
for (const problem of problems) {
|
|
1735
|
+
if (observed.compat.includes(problem.name)) continue
|
|
1736
|
+
observed.compat.push(problem.name)
|
|
1737
|
+
host.warn(`${TAG} ${COMPAT_MESSAGE}:${problem.name} —— 期望 ${problem.expected},实际 ${problem.found}。这一层的链接会静默失效,请升级 DSH 或本插件`)
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
876
1740
|
// A position the previous step asked for: it is driven here, one step later, so the step
|
|
877
1741
|
// that claimed it never drives while it is still running.
|
|
878
1742
|
if (linked === undefined) {
|
|
@@ -889,8 +1753,18 @@ window.__ModuleLoader__.load({
|
|
|
889
1753
|
// The link has landed: it stops being authoritative right here, so the user
|
|
890
1754
|
// keeps owning the address bar afterwards (walking deeper inside a panel
|
|
891
1755
|
// updates the URL instead of being pulled back to the link).
|
|
1756
|
+
//
|
|
1757
|
+
// A turn link that was already satisfied (its seat is on screen) still names where
|
|
1758
|
+
// the reader is, so it becomes the anchor: writing the plain view instead is how a
|
|
1759
|
+
// pasted `turn/<n>` link used to lose its turn the instant it landed.
|
|
1760
|
+
const parts = innerPath(link.inner)
|
|
1761
|
+
const at = parts.indexOf(TURN_SEGMENT)
|
|
1762
|
+
if (at >= 0 && parts[at + 1] !== undefined && parts[at + 1] !== '') {
|
|
1763
|
+
const session = link.kind === 'workspace' ? parts[1] : link.id
|
|
1764
|
+
turnAnchor = { session, turn: parts[at + 1] }
|
|
1765
|
+
}
|
|
892
1766
|
dropLink()
|
|
893
|
-
reflect(
|
|
1767
|
+
reflect(canonical())
|
|
894
1768
|
return
|
|
895
1769
|
}
|
|
896
1770
|
if (host.now() >= link.deadline) {
|
|
@@ -972,7 +1846,8 @@ window.__ModuleLoader__.load({
|
|
|
972
1846
|
if (link.kind === 'settings') {
|
|
973
1847
|
if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
974
1848
|
innerAttempts += 1
|
|
975
|
-
|
|
1849
|
+
const parts = settingsPartsOf(link.inner)
|
|
1850
|
+
host.openSettings(link.id, parts.tab, parts.provider)
|
|
976
1851
|
}
|
|
977
1852
|
armRetry()
|
|
978
1853
|
return
|
|
@@ -1063,7 +1938,16 @@ window.__ModuleLoader__.load({
|
|
|
1063
1938
|
// with a bound so an unopenable page cannot turn into a click loop.
|
|
1064
1939
|
if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1065
1940
|
innerAttempts += 1
|
|
1066
|
-
|
|
1941
|
+
// A pane tail names the right bar, not the panel's page: handing it to the
|
|
1942
|
+
// panel would ask that panel to open a page called `files` (measured on a
|
|
1943
|
+
// link the router itself had written while both were open).
|
|
1944
|
+
const parts = innerPath(link.inner)
|
|
1945
|
+
const paneAt = parts.indexOf(PANE_SEGMENT)
|
|
1946
|
+
const wantedPane = paneAt < 0 ? undefined : parts[paneAt + 1]
|
|
1947
|
+
host.openPanelPage(link.id, paneAt < 0 ? parts : parts.slice(0, paneAt))
|
|
1948
|
+
if (wantedPane !== undefined && host.activePaneKind() !== wantedPane) {
|
|
1949
|
+
host.openPane(wantedPane, 'panel', message => host.warn(message))
|
|
1950
|
+
}
|
|
1067
1951
|
}
|
|
1068
1952
|
armRetry()
|
|
1069
1953
|
return
|
|
@@ -1266,6 +2150,14 @@ window.__ModuleLoader__.load({
|
|
|
1266
2150
|
paneActive: attemptValue(() => host.activePaneKind()) ?? null,
|
|
1267
2151
|
paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
|
|
1268
2152
|
},
|
|
2153
|
+
// The address bar's own preferences, so a bug report can say what the bar
|
|
2154
|
+
// was told to do without opening the settings page.
|
|
2155
|
+
preferences: {
|
|
2156
|
+
selectAllOnFocus: readSettings().selectAllOnFocus,
|
|
2157
|
+
recentLimit: readSettings().recentLimit,
|
|
2158
|
+
remembered: recentEntries().length,
|
|
2159
|
+
},
|
|
2160
|
+
work: { reconciles: workCounts.reconciles, placements: workCounts.placements },
|
|
1269
2161
|
compat: observed.compat,
|
|
1270
2162
|
foreignFragment: observed.foreignFragment,
|
|
1271
2163
|
ignoredKinds: observed.ignored,
|
|
@@ -1775,6 +2667,13 @@ window.__ModuleLoader__.load({
|
|
|
1775
2667
|
}
|
|
1776
2668
|
const host = {
|
|
1777
2669
|
mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
|
|
2670
|
+
// How many conversations the catalog lists: the compatibility check uses it to tell
|
|
2671
|
+
// "an empty sidebar" from "a sidebar whose rows are no longer where they were".
|
|
2672
|
+
listedSessions: () => attemptValue(() => {
|
|
2673
|
+
const snapshot = faces.catalog.getSnapshot()
|
|
2674
|
+
if (Array.isArray(snapshot?.ids)) return snapshot.ids.length
|
|
2675
|
+
return Object.keys(snapshot?.byId ?? {}).length
|
|
2676
|
+
}, 0),
|
|
1778
2677
|
// The sidebar lists workspaces and their sessions in one tree, in order, so the
|
|
1779
2678
|
// workspace a conversation belongs to is the nearest workspace row before it.
|
|
1780
2679
|
workspaceNameOf: sessionId => {
|
|
@@ -1799,6 +2698,19 @@ window.__ModuleLoader__.load({
|
|
|
1799
2698
|
// The inner page a panel is showing, in that panel's own vocabulary. Each panel
|
|
1800
2699
|
// publishes it with a data attribute; reading it is the whole reflect direction.
|
|
1801
2700
|
panelPage: (panelId, scope) => {
|
|
2701
|
+
if (panelId === SCHEDULES_PANEL_ID) {
|
|
2702
|
+
// Two dimensions, in one path: the status filter the list shows (the default
|
|
2703
|
+
// `all` is not written) and the task whose detail is open (with its tab).
|
|
2704
|
+
const segments = []
|
|
2705
|
+
const filter = scheduleFilterOf(window.document)
|
|
2706
|
+
if (filter !== undefined && filter !== SCHEDULE_FILTERS[0]) segments.push(FILTER_SEGMENT, filter)
|
|
2707
|
+
const task = scheduleTaskOf(window.document)
|
|
2708
|
+
if (task !== undefined) {
|
|
2709
|
+
segments.push(TASK_SEGMENT, task.id)
|
|
2710
|
+
if (task.tab !== 'rule') segments.push(task.tab)
|
|
2711
|
+
}
|
|
2712
|
+
return segments.length === 0 ? undefined : segments
|
|
2713
|
+
}
|
|
1802
2714
|
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
1803
2715
|
// The form and a task are mutually exclusive, and the form wins when both
|
|
1804
2716
|
// would be reported (it is the page the user is looking at).
|
|
@@ -1822,6 +2734,15 @@ window.__ModuleLoader__.load({
|
|
|
1822
2734
|
},
|
|
1823
2735
|
openPanelPage: (panelId, inner) => {
|
|
1824
2736
|
const asked = innerPath(inner)
|
|
2737
|
+
if (panelId === SCHEDULES_PANEL_ID) {
|
|
2738
|
+
// Oldest first: the filter decides which rows the list holds, so it is pressed
|
|
2739
|
+
// before a task in that list is asked for.
|
|
2740
|
+
const filterAt = asked.indexOf(FILTER_SEGMENT)
|
|
2741
|
+
if (filterAt >= 0) clickScheduleFilter(asked[filterAt + 1])
|
|
2742
|
+
const taskAt = asked.indexOf(TASK_SEGMENT)
|
|
2743
|
+
if (taskAt >= 0 && asked[taskAt + 1] !== undefined) clickScheduleTask(asked[taskAt + 1], asked[taskAt + 2])
|
|
2744
|
+
return
|
|
2745
|
+
}
|
|
1825
2746
|
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
1826
2747
|
// The task board owns how its overlays open: we hand over the request on its
|
|
1827
2748
|
// own contract (window events) and nothing else crosses the seam.
|
|
@@ -1847,8 +2768,9 @@ window.__ModuleLoader__.load({
|
|
|
1847
2768
|
const pkg =
|
|
1848
2769
|
window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
|
|
1849
2770
|
window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
|
|
1850
|
-
|
|
1851
|
-
|
|
2771
|
+
// Never the enable switch: a link opens a page, it never changes a plugin.
|
|
2772
|
+
const button = openControlOf(pkg)
|
|
2773
|
+
if (button !== undefined) button.click()
|
|
1852
2774
|
if (segments.length === 1) return
|
|
1853
2775
|
}
|
|
1854
2776
|
const rowId = segments[segments.length - 1]
|
|
@@ -1856,8 +2778,8 @@ window.__ModuleLoader__.load({
|
|
|
1856
2778
|
for (const row of rows) {
|
|
1857
2779
|
const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
|
|
1858
2780
|
if (parsed === undefined || parsed.rowId !== rowId) continue
|
|
1859
|
-
const button = row
|
|
1860
|
-
if (
|
|
2781
|
+
const button = openControlOf(row)
|
|
2782
|
+
if (button !== undefined) button.click()
|
|
1861
2783
|
return
|
|
1862
2784
|
}
|
|
1863
2785
|
},
|
|
@@ -1929,6 +2851,7 @@ window.__ModuleLoader__.load({
|
|
|
1929
2851
|
revealPosition: (parts, wantedSession) => {
|
|
1930
2852
|
const wanted = innerPath(parts)
|
|
1931
2853
|
const document = window.document
|
|
2854
|
+
|
|
1932
2855
|
if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
|
|
1933
2856
|
const key = wanted.slice(1).join('%00')
|
|
1934
2857
|
for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
|
|
@@ -1961,16 +2884,46 @@ window.__ModuleLoader__.load({
|
|
|
1961
2884
|
// from the catalog here is how this step used to look up "no session".
|
|
1962
2885
|
const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
|
|
1963
2886
|
const session = attemptValue(() => reference?.binding?.session)
|
|
1964
|
-
const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
|
|
1965
2887
|
const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
|
|
1966
2888
|
const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
|
|
1967
|
-
|
|
2889
|
+
// The loader is called **on the session**, never through a detached reference:
|
|
2890
|
+
// `loadThrough` is a prototype method in this build and reads `this.openState`, so
|
|
2891
|
+
// destructuring it (`const load = session.loadThrough`) throws on every call.
|
|
2892
|
+
const usable = typeof session?.loadThrough === 'function' && typeof reference?.release === 'function'
|
|
1968
2893
|
if (typeof seq !== 'number' || !usable) {
|
|
1969
2894
|
attemptValue(() => reference?.release?.())
|
|
1970
2895
|
return false
|
|
1971
2896
|
}
|
|
1972
|
-
|
|
2897
|
+
// The loader resolves when the pages are in, but the chat renders them on its own
|
|
2898
|
+
// commit — so the seat shows up a frame or two later, and until something scrolls
|
|
2899
|
+
// to it the virtual list simply trims the freshly loaded turns again. The official
|
|
2900
|
+
// chat does the same two steps (`loadThrough(seq)` then `scrollToTurn(turn)`); this
|
|
2901
|
+
// is the scroll half, without a handle on its navigation object.
|
|
2902
|
+
const scrollToSeat = () => {
|
|
2903
|
+
const seat = attemptValue(() => document?.querySelector?.(`[${TURN_ATTRIBUTE}="${turn}"]`))
|
|
2904
|
+
if (seat === null || seat === undefined) return false
|
|
2905
|
+
attempt(() => seat.scrollIntoView?.({ block: 'center' }))
|
|
2906
|
+
return true
|
|
2907
|
+
}
|
|
2908
|
+
const pending = attemptValue(() => session.loadThrough(seq))
|
|
1973
2909
|
attemptValue(() => reference.release())
|
|
2910
|
+
if (pending !== undefined && pending !== null && typeof pending.then === 'function') {
|
|
2911
|
+
pending.then(
|
|
2912
|
+
() => {
|
|
2913
|
+
// A few frames of patience: the seat is rendered by the next commit.
|
|
2914
|
+
let tries = 0
|
|
2915
|
+
const tick = () => {
|
|
2916
|
+
tries += 1
|
|
2917
|
+
if (tries > 25 || scrollToSeat()) return
|
|
2918
|
+
attemptValue(() => window.setTimeout?.(tick, 50))
|
|
2919
|
+
}
|
|
2920
|
+
tick()
|
|
2921
|
+
},
|
|
2922
|
+
() => {},
|
|
2923
|
+
)
|
|
2924
|
+
} else {
|
|
2925
|
+
scrollToSeat()
|
|
2926
|
+
}
|
|
1974
2927
|
return true
|
|
1975
2928
|
},
|
|
1976
2929
|
// Which conversation view is showing. The registry is the source of truth for ids
|
|
@@ -2031,10 +2984,13 @@ window.__ModuleLoader__.load({
|
|
|
2031
2984
|
const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
|
|
2032
2985
|
return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
|
|
2033
2986
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2987
|
+
// The controller first: it holds the live tab descriptor (`{ id, kind, title }`), so
|
|
2988
|
+
// its kind survives a title that is no longer the kind — a Files tab showing a file
|
|
2989
|
+
// is titled with the file name (`client.js`), and the DOM only carries that title.
|
|
2036
2990
|
const fromController = read(faces.sidebarRight)
|
|
2037
2991
|
if (fromController !== undefined) return fromController
|
|
2992
|
+
const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
|
|
2993
|
+
if (fromStrip !== undefined) return fromStrip
|
|
2038
2994
|
const fromRegistry = read(faces.sidebarRightTabs)
|
|
2039
2995
|
if (fromRegistry !== undefined) return fromRegistry
|
|
2040
2996
|
// DOM fallback, deliberately narrow: the active-pane marker is shared with the
|
|
@@ -2062,6 +3018,8 @@ window.__ModuleLoader__.load({
|
|
|
2062
3018
|
}
|
|
2063
3019
|
return undefined
|
|
2064
3020
|
},
|
|
3021
|
+
// The model-provider card whose editor is open, when one is (see the helper).
|
|
3022
|
+
activeSettingsProvider: () => openSettingsProviderId(window.document),
|
|
2065
3023
|
// The tab a settings section is showing: the same ARIA contract one level deeper.
|
|
2066
3024
|
activeSettingsTab: () => {
|
|
2067
3025
|
const overlay = settingsOverlayOf(window.document)
|
|
@@ -2087,7 +3045,7 @@ window.__ModuleLoader__.load({
|
|
|
2087
3045
|
window.document?.dispatchEvent?.(event)
|
|
2088
3046
|
})
|
|
2089
3047
|
},
|
|
2090
|
-
openSettings: (section, tab) => {
|
|
3048
|
+
openSettings: (section, tab, provider) => {
|
|
2091
3049
|
if (typeof section === 'string' && section !== '') {
|
|
2092
3050
|
// A section is named by the label the user reads, so the request is a label match.
|
|
2093
3051
|
const overlay = settingsOverlayOf(window.document)
|
|
@@ -2100,7 +3058,11 @@ window.__ModuleLoader__.load({
|
|
|
2100
3058
|
const wanted = section.trim().toLowerCase()
|
|
2101
3059
|
const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
|
|
2102
3060
|
if (match !== undefined && typeof match.click === 'function') {
|
|
2103
|
-
|
|
3061
|
+
// The section that is already showing is not pressed again: a section
|
|
3062
|
+
// re-render can close a card editor the link asked for (its drive is
|
|
3063
|
+
// retried every half second, so pressing twice would flap).
|
|
3064
|
+
const current = candidates.find(candidate => attemptValue(() => candidate.getAttribute?.('aria-current')) === 'true')
|
|
3065
|
+
if (current !== match) attempt(() => match.click())
|
|
2104
3066
|
// The section may own tabs: a named one is clicked once the section is up.
|
|
2105
3067
|
if (typeof tab === 'string' && tab !== '') {
|
|
2106
3068
|
const wantedTab = tab.trim().toLowerCase()
|
|
@@ -2108,6 +3070,8 @@ window.__ModuleLoader__.load({
|
|
|
2108
3070
|
const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
|
|
2109
3071
|
if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
|
|
2110
3072
|
}
|
|
3073
|
+
// And one of its cards may be the page a link named.
|
|
3074
|
+
if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
|
|
2111
3075
|
return
|
|
2112
3076
|
}
|
|
2113
3077
|
}
|
|
@@ -2228,7 +3192,22 @@ window.__ModuleLoader__.load({
|
|
|
2228
3192
|
attempt(() => {
|
|
2229
3193
|
const root = window.document?.body ?? window.document?.documentElement
|
|
2230
3194
|
if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
|
|
2231
|
-
|
|
3195
|
+
// One reconcile per frame. A streaming answer mutates the page dozens of times a
|
|
3196
|
+
// second, and every batch re-reads the whole view state (the sidebar's rows
|
|
3197
|
+
// included). A host without frames (tests, a minimal DOM) runs the handler
|
|
3198
|
+
// straight away, which is what the fake DOM relies on.
|
|
3199
|
+
let queued = false
|
|
3200
|
+
const flush = () => {
|
|
3201
|
+
queued = false
|
|
3202
|
+
handler()
|
|
3203
|
+
}
|
|
3204
|
+
const schedule = () => {
|
|
3205
|
+
if (queued) return
|
|
3206
|
+
queued = true
|
|
3207
|
+
const frame = attemptValue(() => window.requestAnimationFrame?.(flush))
|
|
3208
|
+
if (frame === undefined) flush()
|
|
3209
|
+
}
|
|
3210
|
+
const observer = new window.MutationObserver(schedule)
|
|
2232
3211
|
// Every published page hook must be watched: a filter that names only some of
|
|
2233
3212
|
// them silently starves the retry loop of notifications (real bug: the task
|
|
2234
3213
|
// board's drive looked flaky because its attributes were not observed).
|
|
@@ -2247,6 +3226,10 @@ window.__ModuleLoader__.load({
|
|
|
2247
3226
|
'aria-current',
|
|
2248
3227
|
// Switching a settings *tab* moves this one, for the same reason.
|
|
2249
3228
|
'aria-selected',
|
|
3229
|
+
// The automation list's status filter is three buttons whose only state is
|
|
3230
|
+
// this attribute, and pressing one touches nothing else: without it here the
|
|
3231
|
+
// address bar kept the filter it saw first.
|
|
3232
|
+
'aria-pressed',
|
|
2250
3233
|
SETTINGS_SURFACE_ATTRIBUTE,
|
|
2251
3234
|
],
|
|
2252
3235
|
})
|
|
@@ -2383,6 +3366,56 @@ window.__ModuleLoader__.load({
|
|
|
2383
3366
|
return `打不开 ${name}(当前 ${shown},侧栏 ${rows} 行)`
|
|
2384
3367
|
}
|
|
2385
3368
|
|
|
3369
|
+
/**
|
|
3370
|
+
* Whether this window is the macOS desktop build.
|
|
3371
|
+
*
|
|
3372
|
+
* The macOS desktop shell draws a native title bar and publishes no in-page
|
|
3373
|
+
* strip, so the Windows marker is absent there — which used to mean "draw
|
|
3374
|
+
* nothing". Two facts together identify a macOS desktop window: the shell marks
|
|
3375
|
+
* `<html data-platform="darwin">` (or the user agent says Mac), and the window
|
|
3376
|
+
* is the application rather than a browser tab (Electron in the user agent, or
|
|
3377
|
+
* the page served over the app's own `dsh-app:` scheme). The plain web app in a
|
|
3378
|
+
* browser satisfies neither and stays empty, as before.
|
|
3379
|
+
*
|
|
3380
|
+
* @param {object} root - `document.documentElement`.
|
|
3381
|
+
* @returns {boolean} true when the address bar belongs on screen.
|
|
3382
|
+
*/
|
|
3383
|
+
function macDesktopWindow(root) {
|
|
3384
|
+
const ua = typeof navigator === 'object' && navigator !== null ? String(navigator.userAgent ?? '') : ''
|
|
3385
|
+
const platform = typeof root?.getAttribute === 'function' ? root.getAttribute('data-platform') : null
|
|
3386
|
+
const isMac = platform === 'darwin' || /Mac OS X|Macintosh/u.test(ua)
|
|
3387
|
+
if (!isMac) return false
|
|
3388
|
+
const protocol = String(globalThis.location?.protocol ?? '')
|
|
3389
|
+
return /Electron\/\d/u.test(ua) || protocol.startsWith('dsh-app')
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
|
|
3393
|
+
/**
|
|
3394
|
+
* The power-menu trigger, the one element that is guaranteed to sit on the
|
|
3395
|
+
* same row on every screen. The address bar anchors to it.
|
|
3396
|
+
*
|
|
3397
|
+
* @returns {object|undefined} the trigger element, or undefined when absent.
|
|
3398
|
+
*/
|
|
3399
|
+
function powerTrigger() {
|
|
3400
|
+
const page = attemptValue(() => window.document)
|
|
3401
|
+
if (typeof page?.querySelector !== 'function') return undefined
|
|
3402
|
+
return page.querySelector('[data-dshpm-mount] .dshpm-trigger')
|
|
3403
|
+
?? page.querySelector('.dshpm-trigger')
|
|
3404
|
+
?? page.querySelector('[aria-label="电源菜单"]')
|
|
3405
|
+
?? undefined
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
/** An element's own text, ignoring the text of its children. */
|
|
3409
|
+
function ownText(el) {
|
|
3410
|
+
if (el === null || el === undefined || typeof el.childNodes !== 'object') return ''
|
|
3411
|
+
let text = ''
|
|
3412
|
+
for (const node of el.childNodes) {
|
|
3413
|
+
if (node !== null && node.nodeType === 3) text += node.textContent ?? ''
|
|
3414
|
+
}
|
|
3415
|
+
return text.trim()
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
|
|
2386
3419
|
/**
|
|
2387
3420
|
* Draw the address bar into the desktop window's title bar strip.
|
|
2388
3421
|
* @param {object} document - the document to draw into.
|
|
@@ -2393,14 +3426,32 @@ window.__ModuleLoader__.load({
|
|
|
2393
3426
|
if (document === undefined || document === null) return () => {}
|
|
2394
3427
|
const root = document.documentElement
|
|
2395
3428
|
// The marker is the shell's own statement that this page has a title bar drawn
|
|
2396
|
-
// inside it. Without it there is no strip
|
|
2397
|
-
|
|
3429
|
+
// inside it. Without it there is no strip — but the macOS desktop build still
|
|
3430
|
+
// gets a bar, drawn into the session header row instead.
|
|
3431
|
+
const windowsStrip = typeof root?.getAttribute === 'function' && root.getAttribute(WINDOWS_TITLEBAR_ATTRIBUTE) !== null
|
|
3432
|
+
const macInHeader = !windowsStrip && macDesktopWindow(root)
|
|
3433
|
+
if (!windowsStrip && !macInHeader) return () => {}
|
|
2398
3434
|
if (typeof document.createElement !== 'function' || typeof document.body?.appendChild !== 'function') return () => {}
|
|
3435
|
+
// One bar, one stylesheet — however many times this runs.
|
|
3436
|
+
//
|
|
3437
|
+
// The effect that draws the bar can run again in a live window (a hot
|
|
3438
|
+
// reload of this plugin re-applies it), and a second mount would leave a
|
|
3439
|
+
// second box on screen. Both are identified by id, so anything already
|
|
3440
|
+
// there is taken down before the new one goes up: the platform split
|
|
3441
|
+
// decides *where* the one bar lives, never how many there are.
|
|
3442
|
+
try {
|
|
3443
|
+
const staleBar = document.getElementById?.(TITLEBAR_BAR_ID)
|
|
3444
|
+
if (staleBar !== null && staleBar !== undefined) staleBar.remove?.()
|
|
3445
|
+
const staleStyle = document.getElementById?.(TITLEBAR_STYLE_ID)
|
|
3446
|
+
if (staleStyle !== null && staleStyle !== undefined) staleStyle.remove?.()
|
|
3447
|
+
} catch {
|
|
3448
|
+
// A document without getElementById keeps the disposer as its only guard.
|
|
3449
|
+
}
|
|
2399
3450
|
let style
|
|
2400
3451
|
try {
|
|
2401
3452
|
style = document.createElement('style')
|
|
2402
3453
|
style.id = TITLEBAR_STYLE_ID
|
|
2403
|
-
style.textContent = TITLEBAR_STYLE_TEXT
|
|
3454
|
+
style.textContent = `${macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT}\n${RECENT_STYLE_TEXT}`
|
|
2404
3455
|
const parent = document.head ?? document.body
|
|
2405
3456
|
if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
|
|
2406
3457
|
} catch {
|
|
@@ -2408,26 +3459,201 @@ window.__ModuleLoader__.load({
|
|
|
2408
3459
|
}
|
|
2409
3460
|
const bar = document.createElement('div')
|
|
2410
3461
|
bar.id = TITLEBAR_BAR_ID
|
|
2411
|
-
if (typeof bar.setAttribute === 'function')
|
|
3462
|
+
if (typeof bar.setAttribute === 'function') {
|
|
3463
|
+
bar.setAttribute('data-dsh-part', TITLEBAR_PART)
|
|
3464
|
+
bar.setAttribute('data-dsh-url-router-platform', macInHeader ? 'mac' : 'windows')
|
|
3465
|
+
}
|
|
2412
3466
|
const input = document.createElement('input')
|
|
2413
3467
|
input.type = 'text'
|
|
2414
3468
|
input.spellcheck = false
|
|
2415
3469
|
input.placeholder = TITLEBAR_PLACEHOLDER
|
|
2416
3470
|
if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
|
|
2417
3471
|
bar.appendChild(input)
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
3472
|
+
// The recent list: the drop-down a browser shows when its address bar is focused.
|
|
3473
|
+
// It is a child of the bar so it moves with it and can never end up somewhere
|
|
3474
|
+
// else on a screen where the bar is repositioned.
|
|
3475
|
+
const recentList = document.createElement('div')
|
|
3476
|
+
if (typeof recentList.setAttribute === 'function') {
|
|
3477
|
+
recentList.setAttribute(RECENT_LIST_ATTRIBUTE, '')
|
|
3478
|
+
recentList.setAttribute('role', 'listbox')
|
|
3479
|
+
recentList.setAttribute('aria-label', '最近使用的链接 / recent links')
|
|
3480
|
+
}
|
|
3481
|
+
bar.appendChild(recentList)
|
|
3482
|
+
/**
|
|
3483
|
+
* The free stretch the bar was last fitted into.
|
|
3484
|
+
*
|
|
3485
|
+
* A screen with no power button — a panel, the plugins page, settings — has
|
|
3486
|
+
* nothing to align to. The bar keeps this stretch there, so changing views
|
|
3487
|
+
* neither moves it nor makes it vanish. The stretch is also kept on disk, so
|
|
3488
|
+
* a window that starts on such a screen still gets the bar where it belongs;
|
|
3489
|
+
* and because the box is fitted from the stretch each time, a change to the
|
|
3490
|
+
* bar's margins applies there too, without waiting for a conversation.
|
|
3491
|
+
*/
|
|
3492
|
+
let lastPlacement = attemptValue(() => {
|
|
3493
|
+
const raw = globalThis.localStorage?.getItem?.(MAC_PLACEMENT_KEY)
|
|
3494
|
+
if (raw === null || raw === undefined || raw === '') return null
|
|
3495
|
+
const parsed = JSON.parse(raw)
|
|
3496
|
+
const gap = parsed?.gap
|
|
3497
|
+
if (typeof parsed?.top !== 'number' || typeof parsed?.viewport !== 'number') return null
|
|
3498
|
+
if (typeof gap?.left !== 'number' || typeof gap?.right !== 'number') return null
|
|
3499
|
+
return { top: parsed.top, viewport: parsed.viewport, gap: { left: gap.left, right: gap.right } }
|
|
3500
|
+
}) ?? null
|
|
3501
|
+
/**
|
|
3502
|
+
* Fit the bar into a free stretch of the header row.
|
|
3503
|
+
*
|
|
3504
|
+
* The bar takes the whole stretch bar a margin on each side, so it is as
|
|
3505
|
+
* long as the row can spare without touching what stands beside it. A
|
|
3506
|
+
* narrow row keeps the whole stretch rather than a sliver of it.
|
|
3507
|
+
*
|
|
3508
|
+
* @param {{left: number, right: number}} gap - the free stretch.
|
|
3509
|
+
* @param {number} top - the row's line, already centred on the power button.
|
|
3510
|
+
* @returns {{left: number, top: number, width: number}} the box to draw.
|
|
3511
|
+
*/
|
|
3512
|
+
const fitBar = (gap, top) => {
|
|
3513
|
+
const right = gap.right - MAC_BAR_GAP - MAC_BAR_INSET
|
|
3514
|
+
const room = gap.left + MAC_BAR_INSET
|
|
3515
|
+
const available = right - room
|
|
3516
|
+
const width = available >= MAC_BAR_MIN_WIDTH
|
|
3517
|
+
? Math.round(Math.min(MAC_BAR_MAX_WIDTH, available))
|
|
3518
|
+
: Math.round(gap.right - MAC_BAR_GAP - gap.left)
|
|
3519
|
+
return { left: Math.round(right - width), top, width }
|
|
3520
|
+
}
|
|
3521
|
+
/**
|
|
3522
|
+
* Remember the stretch the bar went into, and put it on disk when it moved.
|
|
3523
|
+
* @param {object} placement - the line and the stretch just used.
|
|
3524
|
+
*/
|
|
3525
|
+
const remember = placement => {
|
|
3526
|
+
const previous = lastPlacement
|
|
3527
|
+
lastPlacement = placement
|
|
3528
|
+
if (previous !== null
|
|
3529
|
+
&& previous.top === placement.top
|
|
3530
|
+
&& previous.viewport === placement.viewport
|
|
3531
|
+
&& previous.gap.left === placement.gap.left
|
|
3532
|
+
&& previous.gap.right === placement.gap.right) return
|
|
3533
|
+
attemptValue(() => globalThis.localStorage?.setItem?.(MAC_PLACEMENT_KEY, JSON.stringify(placement)))
|
|
3534
|
+
}
|
|
3535
|
+
/**
|
|
3536
|
+
* Put the bar where it belongs.
|
|
3537
|
+
*
|
|
3538
|
+
* Windows: start after the shell's menu, stop before its window buttons.
|
|
3539
|
+
* macOS: sit in the free stretch of the header row, on the power button's
|
|
3540
|
+
* own line.
|
|
3541
|
+
*/
|
|
3542
|
+
/** The shape the last placement was measured from, so an unchanged header is not re-walked. */
|
|
3543
|
+
let lastShape
|
|
3544
|
+
const place = () => {
|
|
3545
|
+
if (macInHeader) {
|
|
3546
|
+
// The bar lives on the header row, on the same line as the power
|
|
3547
|
+
// button, in the free stretch between the title text and the
|
|
3548
|
+
// header's own buttons. The row's real contents are measured and the
|
|
3549
|
+
// widest free stretch is chosen, so the bar covers nothing — and,
|
|
3550
|
+
// being a fixed-position layer, it pushes nothing either.
|
|
3551
|
+
const trigger = powerTrigger()
|
|
3552
|
+
const rect = trigger?.getBoundingClientRect?.()
|
|
3553
|
+
const row = macHeaderRow(trigger)
|
|
3554
|
+
const rowRect = row?.getBoundingClientRect?.()
|
|
3555
|
+
const anchored = rect !== undefined && rect !== null && rect.width > 0
|
|
3556
|
+
// The observer fires for every mutation on the page, and a streaming answer produces
|
|
3557
|
+
// them constantly: walking the row again when nothing that shaped the last placement
|
|
3558
|
+
// moved is pure waste (the walk reads a box per leaf, i.e. forces layout). A screen
|
|
3559
|
+
// with no row to measure is not cached this way — its band is the page's own content,
|
|
3560
|
+
// which changes without moving the row.
|
|
3561
|
+
const shape = [
|
|
3562
|
+
rowRect?.top, rowRect?.left, rowRect?.width, rowRect?.height,
|
|
3563
|
+
rect?.top, rect?.left, rect?.width,
|
|
3564
|
+
Math.round(globalThis.innerWidth ?? 0),
|
|
3565
|
+
(row?.textContent ?? '').length,
|
|
3566
|
+
].join('|')
|
|
3567
|
+
if (anchored && shape === lastShape && bar.style.display !== 'none') return
|
|
3568
|
+
if (anchored) lastShape = shape
|
|
3569
|
+
workCounts.placements += 1
|
|
3570
|
+
// The row is only walked when there is a button to measure from.
|
|
3571
|
+
const gap = anchored ? macFreeGap(macLeaves(row, 0), rect, rowRect) : null
|
|
3572
|
+
if (gap === null) {
|
|
3573
|
+
// Nothing on this screen to align to. Keep the stretch the
|
|
3574
|
+
// conversation gave the bar, so switching views does not move it
|
|
3575
|
+
// and does not make it disappear.
|
|
3576
|
+
//
|
|
3577
|
+
// Only while the window is still the one that stretch was measured
|
|
3578
|
+
// in: the header's buttons sit at the right end of the window, so a
|
|
3579
|
+
// resize moves them, and reusing an old stretch would draw the bar
|
|
3580
|
+
// straight across the title and the buttons. With no button to
|
|
3581
|
+
// measure from there is nothing to re-derive it from either, so a
|
|
3582
|
+
// resized window hides the bar until a conversation places it again.
|
|
3583
|
+
const viewport = Math.round(globalThis.innerWidth ?? 0)
|
|
3584
|
+
if (lastPlacement === null || viewport <= 0
|
|
3585
|
+
|| Math.abs(viewport - lastPlacement.viewport) > MAC_BAR_RESIZE_SLACK) {
|
|
3586
|
+
bar.style.display = 'none'
|
|
3587
|
+
return
|
|
3588
|
+
}
|
|
3589
|
+
const remembered = fitBar(lastPlacement.gap, lastPlacement.top)
|
|
3590
|
+
// Yield to whatever this screen draws on that line: the bar moves into a free run
|
|
3591
|
+
// of its own instead of being drawn across a control (measured: it sat straight
|
|
3592
|
+
// across the automation task's detail tabs).
|
|
3593
|
+
const runs = []
|
|
3594
|
+
let cursor = 0
|
|
3595
|
+
for (const span of bandSpans(document, bar, remembered.top, MAC_BAR_HEIGHT)) {
|
|
3596
|
+
if (span.left > cursor) runs.push({ left: cursor, right: span.left })
|
|
3597
|
+
cursor = Math.max(cursor, span.right)
|
|
3598
|
+
}
|
|
3599
|
+
if (viewport > cursor) runs.push({ left: cursor, right: viewport })
|
|
3600
|
+
let best = null
|
|
3601
|
+
for (const run of runs) {
|
|
3602
|
+
if (run.right - run.left < MAC_BAR_MIN_WIDTH) continue
|
|
3603
|
+
if (best === null || run.right - run.left > best.right - best.left) best = run
|
|
3604
|
+
}
|
|
3605
|
+
if (best === null) {
|
|
3606
|
+
bar.style.display = 'none'
|
|
3607
|
+
return
|
|
3608
|
+
}
|
|
3609
|
+
const box = fitBar(best, remembered.top)
|
|
3610
|
+
bar.style.display = ''
|
|
3611
|
+
bar.style.top = `${box.top}px`
|
|
3612
|
+
bar.style.left = `${box.left}px`
|
|
3613
|
+
bar.style.right = 'auto'
|
|
3614
|
+
bar.style.width = `${box.width}px`
|
|
3615
|
+
return
|
|
2426
3616
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
3617
|
+
const top = Math.round(rect.top + (rect.height - MAC_BAR_HEIGHT) / 2)
|
|
3618
|
+
const box = fitBar(gap, top)
|
|
3619
|
+
bar.style.display = ''
|
|
3620
|
+
bar.style.top = `${box.top}px`
|
|
3621
|
+
bar.style.left = `${box.left}px`
|
|
3622
|
+
bar.style.right = 'auto'
|
|
3623
|
+
bar.style.width = `${box.width}px`
|
|
3624
|
+
remember({ top, viewport: Math.round(globalThis.innerWidth ?? 0), gap })
|
|
3625
|
+
return
|
|
2429
3626
|
}
|
|
2430
|
-
|
|
3627
|
+
let menuWidth = 0
|
|
3628
|
+
try {
|
|
3629
|
+
const menu = typeof document.querySelector === 'function' ? document.querySelector(`[${WINDOWS_MENU_ATTRIBUTE}]`) : null
|
|
3630
|
+
if (menu !== null && menu !== undefined && typeof menu.getBoundingClientRect === 'function') menuWidth = menu.getBoundingClientRect().width || 0
|
|
3631
|
+
} catch {
|
|
3632
|
+
menuWidth = 0
|
|
3633
|
+
}
|
|
3634
|
+
bar.style.left = `${WINDOWS_MENU_START + menuWidth + TITLEBAR_GAP}px`
|
|
3635
|
+
bar.style.right = `${TITLEBAR_CAPTION_WIDTH}px`
|
|
3636
|
+
bar.style.width = 'auto'
|
|
3637
|
+
}
|
|
3638
|
+
/**
|
|
3639
|
+
* Place the bar at most once per frame.
|
|
3640
|
+
*
|
|
3641
|
+
* A streaming answer mutates the document constantly, and every mutation
|
|
3642
|
+
* would otherwise re-measure the header row. Coalescing the runs into one
|
|
3643
|
+
* frame keeps the bar where it belongs without measuring the header dozens
|
|
3644
|
+
* of times a second.
|
|
3645
|
+
*/
|
|
3646
|
+
let placeFrame
|
|
3647
|
+
const schedulePlace = () => {
|
|
3648
|
+
if (placeFrame !== undefined) return
|
|
3649
|
+
placeFrame = attemptValue(() => window.requestAnimationFrame?.(() => {
|
|
3650
|
+
placeFrame = undefined
|
|
3651
|
+
place()
|
|
3652
|
+
}))
|
|
3653
|
+
// No animation frames in this host: place it straight away.
|
|
3654
|
+
if (placeFrame === undefined) place()
|
|
3655
|
+
}
|
|
3656
|
+
place()
|
|
2431
3657
|
/** How the bar looks right now: idle, waiting for a link to land, or refused. */
|
|
2432
3658
|
const mark = (state, hint) => {
|
|
2433
3659
|
if (typeof bar.setAttribute === 'function') bar.setAttribute('data-dsh-url-router-state', state)
|
|
@@ -2486,14 +3712,291 @@ window.__ModuleLoader__.load({
|
|
|
2486
3712
|
input.value = why
|
|
2487
3713
|
}, TITLEBAR_SETTLE_MS))
|
|
2488
3714
|
}
|
|
3715
|
+
/**
|
|
3716
|
+
* The chip a remembered link is shown with: what kind of screen it names.
|
|
3717
|
+
* @param {object|undefined} route - the decoded route.
|
|
3718
|
+
* @returns {string} the chip's text.
|
|
3719
|
+
*/
|
|
3720
|
+
const recentKind = route => {
|
|
3721
|
+
if (route === undefined || route.unknown !== undefined) return '链接'
|
|
3722
|
+
if (route.kind === 'session') return '会话'
|
|
3723
|
+
if (route.kind === 'workspace') return '工作区'
|
|
3724
|
+
if (route.kind === 'panel') return '面板'
|
|
3725
|
+
if (route.kind === 'settings') return '设置'
|
|
3726
|
+
return '链接'
|
|
3727
|
+
}
|
|
3728
|
+
/** Hide the remembered links. */
|
|
3729
|
+
const hideRecent = () => {
|
|
3730
|
+
if (typeof recentList.removeAttribute === 'function') recentList.removeAttribute('data-open')
|
|
3731
|
+
}
|
|
3732
|
+
/**
|
|
3733
|
+
* Whether the reader has edited the text since the bar was focused.
|
|
3734
|
+
*
|
|
3735
|
+
* The list is narrowed only by text the reader typed. The field always holds the
|
|
3736
|
+
* current link when it is merely focused, and filtering by that would hide every
|
|
3737
|
+
* remembered screen but the one on display.
|
|
3738
|
+
*/
|
|
3739
|
+
let narrowed = false
|
|
3740
|
+
/** Empty the list before it is drawn again. */
|
|
3741
|
+
const emptyRecent = () => {
|
|
3742
|
+
while (recentList.firstChild !== null && recentList.firstChild !== undefined) {
|
|
3743
|
+
if (typeof recentList.removeChild !== 'function') break
|
|
3744
|
+
recentList.removeChild(recentList.firstChild)
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* The absolute URL a remembered link stands for.
|
|
3749
|
+
*
|
|
3750
|
+
* A copied link has to survive the trip out of this window, so it carries the path
|
|
3751
|
+
* and the launch query (`?token=…`) exactly the way a row anchor's own "copy link
|
|
3752
|
+
* address" does — the bare fragment only works inside an already-open page.
|
|
3753
|
+
* @param {string} hash - the fragment.
|
|
3754
|
+
* @returns {string} the URL to copy.
|
|
3755
|
+
*/
|
|
3756
|
+
const absoluteLink = hash => {
|
|
3757
|
+
const location = window.location ?? {}
|
|
3758
|
+
return `${location.origin ?? ''}${location.pathname ?? ''}${location.search ?? ''}${hash}`
|
|
3759
|
+
}
|
|
3760
|
+
/** Set while the copy's throw-away field borrows focus, so the bar ignores the swap. */
|
|
3761
|
+
let quietFocus = false
|
|
3762
|
+
/**
|
|
3763
|
+
* Run something without the bar reading it as the reader leaving or entering the
|
|
3764
|
+
* field: the copy must neither close the list nor re-select the link.
|
|
3765
|
+
* @param {Function} run - what to run.
|
|
3766
|
+
* @returns {*} whatever it returned.
|
|
3767
|
+
*/
|
|
3768
|
+
const quietly = run => {
|
|
3769
|
+
quietFocus = true
|
|
3770
|
+
try {
|
|
3771
|
+
return run()
|
|
3772
|
+
} finally {
|
|
3773
|
+
quietFocus = false
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
/**
|
|
3777
|
+
* Put text on the clipboard the old way: select it in a throw-away field and let the
|
|
3778
|
+
* command copy it. Used when the window has no clipboard service, or refuses the
|
|
3779
|
+
* async call — and the reader is put back in the field afterwards, because the field
|
|
3780
|
+
* was only there to be copied from.
|
|
3781
|
+
* @param {string} text - what to copy.
|
|
3782
|
+
* @returns {boolean} whether the copy was accepted.
|
|
3783
|
+
*/
|
|
3784
|
+
const copyBySelection = text => quietly(() => attemptValue(() => {
|
|
3785
|
+
const wasFocused = attemptValue(() => document.activeElement)
|
|
3786
|
+
const area = document.createElement('textarea')
|
|
3787
|
+
area.value = text
|
|
3788
|
+
area.setAttribute?.('readonly', '')
|
|
3789
|
+
if (area.style !== undefined) {
|
|
3790
|
+
area.style.position = 'fixed'
|
|
3791
|
+
area.style.top = '-1000px'
|
|
3792
|
+
}
|
|
3793
|
+
document.body?.appendChild?.(area)
|
|
3794
|
+
area.focus?.()
|
|
3795
|
+
area.select?.()
|
|
3796
|
+
const done = typeof document.execCommand === 'function' && document.execCommand('copy') === true
|
|
3797
|
+
area.remove?.()
|
|
3798
|
+
if (wasFocused === input) attemptValue(() => input.focus?.())
|
|
3799
|
+
return done
|
|
3800
|
+
}, false) ?? false)
|
|
3801
|
+
/**
|
|
3802
|
+
* Copy a remembered link.
|
|
3803
|
+
* @param {string} hash - the fragment to copy.
|
|
3804
|
+
* @returns {Promise<boolean>|boolean} whether the copy was accepted.
|
|
3805
|
+
*/
|
|
3806
|
+
const copyLink = hash => {
|
|
3807
|
+
const text = absoluteLink(hash)
|
|
3808
|
+
const clipboard = attemptValue(() => globalThis.navigator?.clipboard)
|
|
3809
|
+
if (clipboard !== undefined && clipboard !== null && typeof clipboard.writeText === 'function') {
|
|
3810
|
+
let pending
|
|
3811
|
+
try {
|
|
3812
|
+
pending = Promise.resolve(clipboard.writeText(text))
|
|
3813
|
+
} catch {
|
|
3814
|
+
return copyBySelection(text)
|
|
3815
|
+
}
|
|
3816
|
+
// A window that refuses the async call still has the old path. (The fallback
|
|
3817
|
+
// is a function call inside the handler on purpose: evaluating it up front
|
|
3818
|
+
// would run — and focus a throw-away field — on every single copy.)
|
|
3819
|
+
return pending.then(() => true, () => copyBySelection(text))
|
|
3820
|
+
}
|
|
3821
|
+
return copyBySelection(text)
|
|
3822
|
+
}
|
|
3823
|
+
/** Timers that put a "copied" row back, so disposal can clear them. */
|
|
3824
|
+
const copiedTimers = new Set()
|
|
3825
|
+
/** The bar's own confirmation chip: it stays visible even if the list had to close. */
|
|
3826
|
+
const toast = document.createElement('span')
|
|
3827
|
+
toast.className = 'dshur-toast'
|
|
3828
|
+
if (typeof toast.setAttribute === 'function') {
|
|
3829
|
+
toast.setAttribute(TOAST_ATTRIBUTE, '')
|
|
3830
|
+
toast.setAttribute('aria-live', 'polite')
|
|
3831
|
+
}
|
|
3832
|
+
bar.appendChild(toast)
|
|
3833
|
+
let toastTimer
|
|
3834
|
+
/**
|
|
3835
|
+
* Show one short confirmation in the bar.
|
|
3836
|
+
* @param {string} text - what to say.
|
|
3837
|
+
*/
|
|
3838
|
+
const flashToast = text => {
|
|
3839
|
+
toast.textContent = text
|
|
3840
|
+
toast.setAttribute?.('data-show', '')
|
|
3841
|
+
if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
|
|
3842
|
+
toastTimer = attemptValue(() => window.setTimeout(() => {
|
|
3843
|
+
toastTimer = undefined
|
|
3844
|
+
toast.removeAttribute?.('data-show')
|
|
3845
|
+
toast.textContent = ''
|
|
3846
|
+
}, 1200))
|
|
3847
|
+
}
|
|
3848
|
+
/**
|
|
3849
|
+
* Say so on the row that was copied, then put the link back.
|
|
3850
|
+
* @param {object} item - the remembered-link row.
|
|
3851
|
+
* @param {object} link - that row's link text.
|
|
3852
|
+
*/
|
|
3853
|
+
const flashCopied = (item, link) => {
|
|
3854
|
+
const text = readableFragment(attemptValue(() => item.getAttribute(RECENT_ITEM_ATTRIBUTE)) ?? '')
|
|
3855
|
+
link.textContent = '已复制 ✓'
|
|
3856
|
+
item.setAttribute?.('data-copied', '')
|
|
3857
|
+
const timer = attemptValue(() => window.setTimeout(() => {
|
|
3858
|
+
copiedTimers.delete(timer)
|
|
3859
|
+
// A redraw may have replaced the row already; only a live one is restored.
|
|
3860
|
+
link.textContent = text
|
|
3861
|
+
item.removeAttribute?.('data-copied')
|
|
3862
|
+
}, 1200))
|
|
3863
|
+
if (timer !== undefined) copiedTimers.add(timer)
|
|
3864
|
+
}
|
|
3865
|
+
/**
|
|
3866
|
+
* Draw the remembered links: the screens this bar named and the links pasted into
|
|
3867
|
+
* it, newest first, narrowed by whatever is typed. Nothing to show means no list.
|
|
3868
|
+
*/
|
|
3869
|
+
const drawRecent = () => {
|
|
3870
|
+
const typed = narrowed ? String(input.value ?? '').trim().toLowerCase() : ''
|
|
3871
|
+
// One scan of the sidebar for the whole list: asking per entry re-walked every row
|
|
3872
|
+
// once per item (and twice per item, since the filter asks too).
|
|
3873
|
+
const titles = sessionTitles()
|
|
3874
|
+
// `recentEntries()` already leaves out the screen on display, so a remembered
|
|
3875
|
+
// link is only narrowed here by what the reader typed.
|
|
3876
|
+
const shown = recentEntries().filter(entry => {
|
|
3877
|
+
if (typed === '') return true
|
|
3878
|
+
return `${entry.hash}\n${recentLabel(entry.hash, titles)}`.toLowerCase().includes(typed)
|
|
3879
|
+
})
|
|
3880
|
+
emptyRecent()
|
|
3881
|
+
for (const entry of shown) {
|
|
3882
|
+
const label = recentLabel(entry.hash, titles)
|
|
3883
|
+
const item = document.createElement('button')
|
|
3884
|
+
item.type = 'button'
|
|
3885
|
+
if (typeof item.setAttribute === 'function') {
|
|
3886
|
+
item.setAttribute(RECENT_ITEM_ATTRIBUTE, entry.hash)
|
|
3887
|
+
item.setAttribute('role', 'option')
|
|
3888
|
+
item.setAttribute('title', `${entry.source === 'paste' ? '粘贴的链接' : '用过的界面'}:${readableFragment(entry.hash)}(右键复制链接)`)
|
|
3889
|
+
}
|
|
3890
|
+
const kind = document.createElement('span')
|
|
3891
|
+
kind.className = 'dshur-kind'
|
|
3892
|
+
kind.textContent = recentKind(parseRoute(entry.hash))
|
|
3893
|
+
item.appendChild(kind)
|
|
3894
|
+
if (label !== '') {
|
|
3895
|
+
const title = document.createElement('span')
|
|
3896
|
+
title.className = 'dshur-title'
|
|
3897
|
+
title.textContent = label
|
|
3898
|
+
item.appendChild(title)
|
|
3899
|
+
}
|
|
3900
|
+
const link = document.createElement('span')
|
|
3901
|
+
link.className = 'dshur-link'
|
|
3902
|
+
link.textContent = readableFragment(entry.hash)
|
|
3903
|
+
item.appendChild(link)
|
|
3904
|
+
item.addEventListener('mousedown', event => {
|
|
3905
|
+
// The click belongs to this list: the input has to keep focus, or the
|
|
3906
|
+
// press would blur it first and the list would be gone before the click.
|
|
3907
|
+
attemptValue(() => event?.preventDefault?.())
|
|
3908
|
+
})
|
|
3909
|
+
item.addEventListener('click', () => {
|
|
3910
|
+
narrowed = false
|
|
3911
|
+
hideRecent()
|
|
3912
|
+
mark('pending', undefined)
|
|
3913
|
+
// The box says where it is going at once; the settle below corrects it to
|
|
3914
|
+
// what actually landed (or says why nothing did).
|
|
3915
|
+
input.value = readableFragment(entry.hash)
|
|
3916
|
+
driveTypedFragment(entry.hash)
|
|
3917
|
+
settle(entry.hash, false)
|
|
3918
|
+
})
|
|
3919
|
+
item.addEventListener('contextmenu', event => {
|
|
3920
|
+
// Right-click copies instead of opening a menu: this list is the one place
|
|
3921
|
+
// where "give me this link" is the action a reader wants, and the browser's
|
|
3922
|
+
// own menu would only offer the same thing one level deeper.
|
|
3923
|
+
attemptValue(() => event?.preventDefault?.())
|
|
3924
|
+
const confirm = accepted => {
|
|
3925
|
+
if (accepted !== true) return
|
|
3926
|
+
flashCopied(item, link)
|
|
3927
|
+
flashToast('已复制链接')
|
|
3928
|
+
}
|
|
3929
|
+
const copied = copyLink(entry.hash)
|
|
3930
|
+
if (typeof copied?.then === 'function') copied.then(confirm)
|
|
3931
|
+
else confirm(copied)
|
|
3932
|
+
})
|
|
3933
|
+
recentList.appendChild(item)
|
|
3934
|
+
}
|
|
3935
|
+
if (shown.length === 0) {
|
|
3936
|
+
hideRecent()
|
|
3937
|
+
return
|
|
3938
|
+
}
|
|
3939
|
+
if (typeof recentList.setAttribute === 'function') recentList.setAttribute('data-open', '')
|
|
3940
|
+
}
|
|
3941
|
+
/**
|
|
3942
|
+
* Select everything in the bar, the way a browser's address bar does on its first
|
|
3943
|
+
* click. Off means the reader keeps the caret an ordinary text field gives them.
|
|
3944
|
+
* @returns {boolean} whether the text was selected.
|
|
3945
|
+
*/
|
|
3946
|
+
const selectAll = () => {
|
|
3947
|
+
if (!readSettings().selectAllOnFocus) return false
|
|
3948
|
+
return attemptValue(() => {
|
|
3949
|
+
if (typeof input.select !== 'function') return false
|
|
3950
|
+
input.select()
|
|
3951
|
+
return true
|
|
3952
|
+
}, false)
|
|
3953
|
+
}
|
|
3954
|
+
const onFocus = () => {
|
|
3955
|
+
// The copy's own field came and went: this is not the reader focusing the bar.
|
|
3956
|
+
if (quietFocus) return
|
|
3957
|
+
narrowed = false
|
|
3958
|
+
selectAll()
|
|
3959
|
+
drawRecent()
|
|
3960
|
+
}
|
|
3961
|
+
const onMouseDown = event => {
|
|
3962
|
+
// Already focused: this press is placing a caret inside the link.
|
|
3963
|
+
if (document.activeElement === input) return
|
|
3964
|
+
if (!readSettings().selectAllOnFocus) return
|
|
3965
|
+
narrowed = false
|
|
3966
|
+
// The default of this very press would drop the selection made below.
|
|
3967
|
+
attemptValue(() => event?.preventDefault?.())
|
|
3968
|
+
attemptValue(() => input.focus?.())
|
|
3969
|
+
selectAll()
|
|
3970
|
+
drawRecent()
|
|
3971
|
+
}
|
|
3972
|
+
const onPaste = event => {
|
|
3973
|
+
const pasted = attemptValue(() => event?.clipboardData?.getData?.('text'))
|
|
3974
|
+
const fragment = attemptValue(() => titlebarFragmentOf(pasted))
|
|
3975
|
+
// Remembered as soon as it is a link this bar can open: a link that is already
|
|
3976
|
+
// on screen writes no route later, and would otherwise be forgotten entirely.
|
|
3977
|
+
if (typeof fragment === 'string' && fragment !== '') rememberRecent(fragment, 'paste')
|
|
3978
|
+
}
|
|
3979
|
+
const onInput = () => {
|
|
3980
|
+
narrowed = true
|
|
3981
|
+
drawRecent()
|
|
3982
|
+
}
|
|
3983
|
+
const onBlur = () => {
|
|
3984
|
+
if (quietFocus) return
|
|
3985
|
+
narrowed = false
|
|
3986
|
+
hideRecent()
|
|
3987
|
+
}
|
|
2489
3988
|
const onKeyDown = event => {
|
|
2490
3989
|
if (event?.key === 'Escape') {
|
|
2491
3990
|
stopSettle()
|
|
3991
|
+
narrowed = false
|
|
3992
|
+
hideRecent()
|
|
2492
3993
|
mark('idle', undefined)
|
|
2493
3994
|
input.value = readableFragment(window.location?.hash)
|
|
2494
3995
|
return
|
|
2495
3996
|
}
|
|
2496
3997
|
if (event?.key !== 'Enter') return
|
|
3998
|
+
narrowed = false
|
|
3999
|
+
hideRecent()
|
|
2497
4000
|
const fragment = titlebarFragmentOf(input.value)
|
|
2498
4001
|
if (fragment === undefined) {
|
|
2499
4002
|
// Nothing here names a route: leave the address bar saying what is on screen.
|
|
@@ -2510,11 +4013,39 @@ window.__ModuleLoader__.load({
|
|
|
2510
4013
|
settle(fragment, false)
|
|
2511
4014
|
}
|
|
2512
4015
|
input.addEventListener('keydown', onKeyDown)
|
|
4016
|
+
input.addEventListener('focus', onFocus)
|
|
4017
|
+
input.addEventListener('mousedown', onMouseDown)
|
|
4018
|
+
input.addEventListener('input', onInput)
|
|
4019
|
+
input.addEventListener('paste', onPaste)
|
|
4020
|
+
input.addEventListener('blur', onBlur)
|
|
4021
|
+
// Both platforms mount the bar in the body: Windows pins it into the
|
|
4022
|
+
// title-bar strip, macOS pins it over the resident header row.
|
|
2513
4023
|
document.body.appendChild(bar)
|
|
4024
|
+
// The first placement measured the header before the bar was in the
|
|
4025
|
+
// document. Place it again now that it is, so the bar's own box is laid
|
|
4026
|
+
// out and can be checked, and so the first paint is already correct.
|
|
4027
|
+
place()
|
|
4028
|
+
/**
|
|
4029
|
+
* The shell re-renders the session header whenever the view changes, which
|
|
4030
|
+
* takes the bar with it. A watcher puts it back where it belongs — the same
|
|
4031
|
+
* node, so what was typed into it survives.
|
|
4032
|
+
*/
|
|
4033
|
+
let headerWatcher
|
|
4034
|
+
// The window owns both, so a page that replaces `document` (or a host with its own
|
|
4035
|
+
// observers) still gets the watcher; a global-only read silently found nothing.
|
|
4036
|
+
const Observer = attemptValue(() => window.MutationObserver)
|
|
4037
|
+
if (macInHeader && typeof Observer === 'function') {
|
|
4038
|
+
try {
|
|
4039
|
+
headerWatcher = new Observer(() => schedulePlace())
|
|
4040
|
+
headerWatcher.observe(document.body, { childList: true, subtree: true })
|
|
4041
|
+
} catch {
|
|
4042
|
+
headerWatcher = undefined
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
2514
4045
|
const offRoute = subscribe(show)
|
|
2515
4046
|
let onResize
|
|
2516
4047
|
try {
|
|
2517
|
-
onResize = () =>
|
|
4048
|
+
onResize = () => schedulePlace()
|
|
2518
4049
|
window.addEventListener('resize', onResize)
|
|
2519
4050
|
} catch {
|
|
2520
4051
|
onResize = undefined
|
|
@@ -2522,13 +4053,175 @@ window.__ModuleLoader__.load({
|
|
|
2522
4053
|
return () => {
|
|
2523
4054
|
offRoute?.()
|
|
2524
4055
|
stopSettle()
|
|
4056
|
+
if (placeFrame !== undefined) attemptValue(() => window.cancelAnimationFrame?.(placeFrame))
|
|
2525
4057
|
input.removeEventListener?.('keydown', onKeyDown)
|
|
4058
|
+
input.removeEventListener?.('focus', onFocus)
|
|
4059
|
+
input.removeEventListener?.('mousedown', onMouseDown)
|
|
4060
|
+
input.removeEventListener?.('input', onInput)
|
|
4061
|
+
input.removeEventListener?.('paste', onPaste)
|
|
4062
|
+
input.removeEventListener?.('blur', onBlur)
|
|
4063
|
+
for (const timer of copiedTimers) attemptValue(() => window.clearTimeout(timer))
|
|
4064
|
+
copiedTimers.clear()
|
|
4065
|
+
if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
|
|
2526
4066
|
if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
|
|
4067
|
+
headerWatcher?.disconnect?.()
|
|
2527
4068
|
bar.remove?.()
|
|
2528
4069
|
style?.remove?.()
|
|
2529
4070
|
}
|
|
2530
4071
|
}
|
|
2531
4072
|
|
|
4073
|
+
// ---------------------------------------------------------------------
|
|
4074
|
+
// settings page: the plugin manager's row page is this plugin's settings surface
|
|
4075
|
+
//
|
|
4076
|
+
// The manager dispatches a row's configuration under `<package name>#<row id>` and
|
|
4077
|
+
// renders the entry twice: once as the row's one-line summary and once as the page
|
|
4078
|
+
// the row's configure control opens. Registering here is what turns
|
|
4079
|
+
// `#/panel/plugins/dsh-url-router/dsh-url-router` from a read-only overview into the
|
|
4080
|
+
// page these two preferences are set on — the same shape every other plugin in this
|
|
4081
|
+
// profile has, instead of a settings tab of its own.
|
|
4082
|
+
// ---------------------------------------------------------------------
|
|
4083
|
+
/** The card's stylesheet id, so a re-apply replaces its own rather than stacking copies. */
|
|
4084
|
+
const SETTINGS_STYLE_ID = 'dsh-url-router-settings-style'
|
|
4085
|
+
/** The card's own styles: the manager's class names are hashed, so it cannot borrow them. */
|
|
4086
|
+
const SETTINGS_STYLE_TEXT = [
|
|
4087
|
+
'.dshur{display:flex;flex-direction:column;gap:8px;color:var(--dsw-alias-label-primary,inherit);font-size:13px;line-height:20px}',
|
|
4088
|
+
'.dshur-block{display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.26));border-radius:10px}',
|
|
4089
|
+
'.dshur-row{display:flex;align-items:center;gap:10px;justify-content:space-between}',
|
|
4090
|
+
'.dshur-label{display:flex;align-items:center;gap:8px;font-weight:500;cursor:pointer}',
|
|
4091
|
+
'.dshur-check{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
4092
|
+
'.dshur-number{width:96px;height:28px;box-sizing:border-box;padding:0 8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.34));border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:right}',
|
|
4093
|
+
'.dshur-number:focus{outline:none;border-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
4094
|
+
'.dshur-state{color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
|
|
4095
|
+
'.dshur-hint{margin:0;color:var(--dsw-alias-label-tertiary,#81858c);font-size:12px;line-height:18px}',
|
|
4096
|
+
'.dshur-meta{display:flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
|
|
4097
|
+
'.dshur-clear{height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.34));border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px;cursor:pointer}',
|
|
4098
|
+
'.dshur-clear:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
|
|
4099
|
+
].join('')
|
|
4100
|
+
|
|
4101
|
+
/**
|
|
4102
|
+
* The settings card on this plugin's row page.
|
|
4103
|
+
*
|
|
4104
|
+
* The manager calls it twice: with `view: 'summary'` for the row's one-liner (it must
|
|
4105
|
+
* stay cheap — it renders on every row paint and must not touch the disk) and with the
|
|
4106
|
+
* page's own view for the settings themselves.
|
|
4107
|
+
* @param {object} props - slot props, `view` names which of the two is wanted.
|
|
4108
|
+
* @returns {object|string} the element to draw, or the one-liner.
|
|
4109
|
+
*/
|
|
4110
|
+
function RouterSettingsCard({ view }) {
|
|
4111
|
+
const h = react.createElement
|
|
4112
|
+
const [settings, setSettings] = react.useState(readSettings())
|
|
4113
|
+
const [draft, setDraft] = react.useState(String(readSettings().recentLimit))
|
|
4114
|
+
const [remembered, setRemembered] = react.useState(() => recentEntries().length)
|
|
4115
|
+
react.useEffect(() => subscribeSettings(next => {
|
|
4116
|
+
setSettings(next)
|
|
4117
|
+
setDraft(String(next.recentLimit))
|
|
4118
|
+
setRemembered(recentEntries().length)
|
|
4119
|
+
}), [])
|
|
4120
|
+
if (view === 'summary') {
|
|
4121
|
+
return `点击全选 ${settings.selectAllOnFocus ? '开' : '关'} · 记忆 ${settings.recentLimit} 条`
|
|
4122
|
+
}
|
|
4123
|
+
/**
|
|
4124
|
+
* Take a typed count: an empty field, a minus sign or anything else unusable is a
|
|
4125
|
+
* half-typed value and must not be committed as 0.
|
|
4126
|
+
* @param {string} text - what the field holds.
|
|
4127
|
+
*/
|
|
4128
|
+
const commitCount = text => {
|
|
4129
|
+
setDraft(text)
|
|
4130
|
+
const number = Number(text)
|
|
4131
|
+
if (String(text).trim() === '' || !Number.isFinite(number) || number < 0) return
|
|
4132
|
+
setSettings(updateSettings({ recentLimit: Math.floor(number) }))
|
|
4133
|
+
}
|
|
4134
|
+
return h('div', { className: 'dshur' }, [
|
|
4135
|
+
h('div', { className: 'dshur-block', key: 'select' }, [
|
|
4136
|
+
h('div', { className: 'dshur-row' }, [
|
|
4137
|
+
h('label', { className: 'dshur-label' }, [
|
|
4138
|
+
h('input', {
|
|
4139
|
+
type: 'checkbox',
|
|
4140
|
+
className: 'dshur-check',
|
|
4141
|
+
checked: settings.selectAllOnFocus,
|
|
4142
|
+
onChange: event => setSettings(updateSettings({ selectAllOnFocus: event?.target?.checked === true })),
|
|
4143
|
+
}),
|
|
4144
|
+
h('span', null, '首次点击地址栏时全选'),
|
|
4145
|
+
]),
|
|
4146
|
+
h('span', { className: 'dshur-state' }, settings.selectAllOnFocus ? '开' : '关'),
|
|
4147
|
+
]),
|
|
4148
|
+
h('p', { className: 'dshur-hint' }, '像浏览器地址栏:第一次点进去,框里原来的链接整条选中,直接输入就替换它;已经在框里时再点,才是放光标。'),
|
|
4149
|
+
]),
|
|
4150
|
+
h('div', { className: 'dshur-block', key: 'recent' }, [
|
|
4151
|
+
h('div', { className: 'dshur-row' }, [
|
|
4152
|
+
h('label', { className: 'dshur-label', htmlFor: 'dshur-recent-limit' }, '记住最近链接的条数'),
|
|
4153
|
+
h('input', {
|
|
4154
|
+
id: 'dshur-recent-limit',
|
|
4155
|
+
type: 'number',
|
|
4156
|
+
min: 0,
|
|
4157
|
+
step: 1,
|
|
4158
|
+
inputMode: 'numeric',
|
|
4159
|
+
className: 'dshur-number',
|
|
4160
|
+
value: draft,
|
|
4161
|
+
onChange: event => commitCount(event?.target?.value ?? ''),
|
|
4162
|
+
onBlur: () => setDraft(String(readSettings().recentLimit)),
|
|
4163
|
+
}),
|
|
4164
|
+
]),
|
|
4165
|
+
h('p', { className: 'dshur-hint' }, '默认 8 条,最低 0 条(0 = 不记忆,地址栏也不再弹最近列表),没有上限。这个数就是点地址栏时下拉框里能看到的条数 —— 地址栏里正显示的那个界面不算(它已经在你眼前了,离开之后才会回到列表里)。'),
|
|
4166
|
+
h('p', { className: 'dshur-meta' }, [
|
|
4167
|
+
h('span', { key: 'count' }, `已记住 ${remembered} 条`),
|
|
4168
|
+
h('button', {
|
|
4169
|
+
type: 'button',
|
|
4170
|
+
className: 'dshur-clear',
|
|
4171
|
+
key: 'clear',
|
|
4172
|
+
onClick: () => {
|
|
4173
|
+
clearRecent()
|
|
4174
|
+
setRemembered(0)
|
|
4175
|
+
},
|
|
4176
|
+
}, '清空记录'),
|
|
4177
|
+
]),
|
|
4178
|
+
]),
|
|
4179
|
+
])
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
/**
|
|
4183
|
+
* Register the settings card with the plugin manager.
|
|
4184
|
+
*
|
|
4185
|
+
* A composition without the manager, without the slot service, or without React has
|
|
4186
|
+
* no card and needs none: nothing else in the plugin depends on this.
|
|
4187
|
+
* @param {object} ctx - client context.
|
|
4188
|
+
* @returns {() => void} the disposer.
|
|
4189
|
+
*/
|
|
4190
|
+
function startSettingsCard(ctx) {
|
|
4191
|
+
if (react === undefined || typeof window === 'undefined' || window.document === undefined) return () => {}
|
|
4192
|
+
const slots = ctx?.slots
|
|
4193
|
+
if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') return () => {}
|
|
4194
|
+
const document = window.document
|
|
4195
|
+
let style
|
|
4196
|
+
try {
|
|
4197
|
+
style = document.createElement('style')
|
|
4198
|
+
style.id = SETTINGS_STYLE_ID
|
|
4199
|
+
style.textContent = SETTINGS_STYLE_TEXT
|
|
4200
|
+
const parent = document.head ?? document.body
|
|
4201
|
+
if (typeof parent?.appendChild === 'function') parent.appendChild(style)
|
|
4202
|
+
} catch {
|
|
4203
|
+
style = undefined
|
|
4204
|
+
}
|
|
4205
|
+
let off
|
|
4206
|
+
try {
|
|
4207
|
+
off = slots.inject('plugins.row.config', () => slots.register({
|
|
4208
|
+
name: 'plugins.row.config',
|
|
4209
|
+
key: ROW_CONFIG_KEY,
|
|
4210
|
+
}, RouterSettingsCard))
|
|
4211
|
+
} catch {
|
|
4212
|
+
// A page that never declares the slot is a page without a card, not a failure.
|
|
4213
|
+
off = undefined
|
|
4214
|
+
}
|
|
4215
|
+
return () => {
|
|
4216
|
+
try {
|
|
4217
|
+
if (typeof off === 'function') off()
|
|
4218
|
+
} catch {
|
|
4219
|
+
// A slot service that is already gone needs no undo.
|
|
4220
|
+
}
|
|
4221
|
+
style?.remove?.()
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
|
|
2532
4225
|
function apply(ctx) {
|
|
2533
4226
|
// Fragment routing: the address bar names the view, and a link opens it.
|
|
2534
4227
|
ctx.effect(() => {
|
|
@@ -2583,6 +4276,10 @@ window.__ModuleLoader__.load({
|
|
|
2583
4276
|
if (typeof window === 'undefined' || window.document === undefined) return () => {}
|
|
2584
4277
|
return startTitlebarAddress(window.document, subscribeRoute)
|
|
2585
4278
|
}, 'url-router: desktop address bar')
|
|
4279
|
+
// Settings page: the plugin manager's row page carries the two preferences, so
|
|
4280
|
+
// there is no separate settings tab to find them in.
|
|
4281
|
+
ctx.effect(() => startSettingsCard(ctx), 'url-router: settings card')
|
|
4282
|
+
|
|
2586
4283
|
}
|
|
2587
4284
|
|
|
2588
4285
|
exports.apply = apply
|