dsh-url-router 0.4.1 → 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 +73 -2
- package/README.md +57 -4
- package/lib/client.js +1396 -44
- 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+mac-header-bar.
|
|
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,6 +188,11 @@ 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
198
|
/** Gap between the shell's menu and the bar. */
|
|
@@ -236,6 +276,59 @@ window.__ModuleLoader__.load({
|
|
|
236
276
|
}
|
|
237
277
|
return leaves
|
|
238
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
|
+
}
|
|
239
332
|
/**
|
|
240
333
|
* The header row the bar has to live in: the row that carries the
|
|
241
334
|
* conversation's title on the left and the header's own buttons on the right.
|
|
@@ -248,8 +341,9 @@ window.__ModuleLoader__.load({
|
|
|
248
341
|
* @returns {object|undefined} the row element, or undefined when there is none.
|
|
249
342
|
*/
|
|
250
343
|
function macHeaderRow(trigger) {
|
|
251
|
-
const
|
|
252
|
-
const
|
|
344
|
+
const page = attemptValue(() => window.document)
|
|
345
|
+
const corner = attemptValue(() => page?.querySelector?.('[data-conversation-header-corner]'))
|
|
346
|
+
const named = attemptValue(() => page?.querySelector?.('[class*="titleRow"]'))
|
|
253
347
|
if (named !== undefined && named !== null) {
|
|
254
348
|
// Only the row that actually holds the button is the row to measure.
|
|
255
349
|
if (trigger === undefined || trigger === null) return named
|
|
@@ -331,6 +425,24 @@ window.__ModuleLoader__.load({
|
|
|
331
425
|
`#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
332
426
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
|
|
333
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('')
|
|
334
446
|
/** Listeners told the fragment this router just wrote; the desktop bar reads it. */
|
|
335
447
|
const routeFeed = new Set()
|
|
336
448
|
/**
|
|
@@ -338,6 +450,9 @@ window.__ModuleLoader__.load({
|
|
|
338
450
|
* @param {string} hash - the fragment just written.
|
|
339
451
|
*/
|
|
340
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)
|
|
341
456
|
for (const listener of [...routeFeed]) {
|
|
342
457
|
try {
|
|
343
458
|
listener(hash)
|
|
@@ -363,6 +478,282 @@ window.__ModuleLoader__.load({
|
|
|
363
478
|
*/
|
|
364
479
|
let driveTypedFragment = () => {}
|
|
365
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
|
+
|
|
366
757
|
/**
|
|
367
758
|
* The conversation view ids the registry publishes, in registration order — the same order
|
|
368
759
|
* the header renders its tabs in, which is what lets a tab position map back to an id.
|
|
@@ -519,6 +910,72 @@ window.__ModuleLoader__.load({
|
|
|
519
910
|
warn(`${TAG} ${message}`)
|
|
520
911
|
}
|
|
521
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
|
+
|
|
522
979
|
function activePaneTabOf(document, registry) {
|
|
523
980
|
// Scoped to the right bar's own root: the same dockkit attributes dress the main area
|
|
524
981
|
// too, and reading them unscoped reports the main area's panes, not this bar's tabs.
|
|
@@ -557,7 +1014,11 @@ window.__ModuleLoader__.load({
|
|
|
557
1014
|
if (named !== undefined) {
|
|
558
1015
|
const wanted = flatLabel(named).toLowerCase()
|
|
559
1016
|
const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
|
|
560
|
-
|
|
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
|
|
561
1022
|
}
|
|
562
1023
|
// 2) an address-shaped id, for a future build that puts one there
|
|
563
1024
|
const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
|
|
@@ -571,6 +1032,206 @@ window.__ModuleLoader__.load({
|
|
|
571
1032
|
return undefined
|
|
572
1033
|
}
|
|
573
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
|
+
|
|
574
1235
|
/**
|
|
575
1236
|
* Read a route out of a location fragment.
|
|
576
1237
|
* @param {string} hash - the raw `window.location.hash`, including the leading `#`.
|
|
@@ -650,7 +1311,7 @@ window.__ModuleLoader__.load({
|
|
|
650
1311
|
}
|
|
651
1312
|
// A panel may have an inner page (a plugin's detail view, for instance): the
|
|
652
1313
|
// rest of the path names it, per panel.
|
|
653
|
-
const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
|
|
1314
|
+
const inner = normalisePanelInner(kind, id, segments.map(decodePart).filter(segment => segment.trim() !== ''))
|
|
654
1315
|
return { kind, id, inner: inner.length === 0 ? undefined : inner }
|
|
655
1316
|
}
|
|
656
1317
|
|
|
@@ -711,6 +1372,27 @@ window.__ModuleLoader__.load({
|
|
|
711
1372
|
return rowId === '' ? undefined : { packageName: undefined, rowId }
|
|
712
1373
|
}
|
|
713
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
|
+
|
|
714
1396
|
function innerPath(value) {
|
|
715
1397
|
const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
|
|
716
1398
|
return parts.map(part => String(part).trim()).filter(part => part !== '')
|
|
@@ -780,7 +1462,7 @@ window.__ModuleLoader__.load({
|
|
|
780
1462
|
let panelScope
|
|
781
1463
|
/** The turn the user last clicked inside a conversation, with its session. */
|
|
782
1464
|
/** What the report can say about this run: fragments it refused, kinds it left alone. */
|
|
783
|
-
const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
|
|
1465
|
+
const observed = { foreignFragment: undefined, ignored: [], warnings: [], compat: [] }
|
|
784
1466
|
let rowAnchor
|
|
785
1467
|
/** The pane kind this claim already opened (one drive per claim, see the pane branch). */
|
|
786
1468
|
let paneOpenedFor
|
|
@@ -792,6 +1474,8 @@ window.__ModuleLoader__.load({
|
|
|
792
1474
|
let innerAttempts = 0
|
|
793
1475
|
/** Whether the current link already asked for a modal standing in front of it to go away. */
|
|
794
1476
|
let overlayDismissed = false
|
|
1477
|
+
/** When the compatibility checks last read the page (`-Infinity`: never swept). */
|
|
1478
|
+
let lastCompatSweep = -Infinity
|
|
795
1479
|
/** The view state observed on the previous notification. */
|
|
796
1480
|
let seen = 'none'
|
|
797
1481
|
let cancelTimer
|
|
@@ -821,22 +1505,32 @@ window.__ModuleLoader__.load({
|
|
|
821
1505
|
if (settingsOverlayOf(window.document) !== undefined) {
|
|
822
1506
|
const section = host.activeSettingsSection() ?? ''
|
|
823
1507
|
const tab = section === '' ? undefined : host.activeSettingsTab()
|
|
824
|
-
|
|
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 }
|
|
825
1513
|
}
|
|
826
1514
|
const panelId = host.activePanelId()
|
|
827
1515
|
if (panelId !== undefined && panelId !== null) {
|
|
828
1516
|
const id = String(panelId)
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
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).
|
|
833
1521
|
const page = host.panelPage(id, panelScope)
|
|
834
|
-
if (page === undefined) return { kind: 'panel', id, inner: undefined }
|
|
835
1522
|
const segments = innerPath(page)
|
|
836
1523
|
// Remember the package a page was opened under, so a bare component page (which
|
|
837
1524
|
// reports only its row) can still be named fully.
|
|
838
1525
|
if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
|
|
839
|
-
|
|
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 }
|
|
840
1534
|
}
|
|
841
1535
|
const session = host.mainViewSessionId()
|
|
842
1536
|
if (session !== undefined && session !== null) {
|
|
@@ -865,7 +1559,8 @@ window.__ModuleLoader__.load({
|
|
|
865
1559
|
// The right-hand pane is its own layer, not a position inside the conversation:
|
|
866
1560
|
// it is named next to whatever the main column shows, never instead of it — so it
|
|
867
1561
|
// is read *before* the workspace branch, or the workspace would swallow it.
|
|
868
|
-
|
|
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()
|
|
869
1564
|
const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
|
|
870
1565
|
const workspaceName = host.workspaceNameOf(id)
|
|
871
1566
|
if (workspaceName !== undefined) {
|
|
@@ -911,6 +1606,27 @@ window.__ModuleLoader__.load({
|
|
|
911
1606
|
// that is no longer in flight is never "the same route" as anything.
|
|
912
1607
|
if (linked === undefined || linked === null) return false
|
|
913
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
|
+
}
|
|
914
1630
|
const currentSession = sessionIdOfRoute(current)
|
|
915
1631
|
const linkedSession = sessionIdOfRoute(linked)
|
|
916
1632
|
if (currentSession !== undefined && linkedSession !== undefined) {
|
|
@@ -954,8 +1670,12 @@ window.__ModuleLoader__.load({
|
|
|
954
1670
|
if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
|
|
955
1671
|
if (linked.id === '') return true
|
|
956
1672
|
if (current.id !== linked.id) return false
|
|
957
|
-
const
|
|
958
|
-
|
|
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
|
|
959
1679
|
}
|
|
960
1680
|
/** Write the fragment for the current view, leaving path and query untouched. */
|
|
961
1681
|
const reflect = route => {
|
|
@@ -988,6 +1708,7 @@ window.__ModuleLoader__.load({
|
|
|
988
1708
|
*/
|
|
989
1709
|
const reconcile = (source = 'boot') => {
|
|
990
1710
|
if (disposed) return
|
|
1711
|
+
workCounts.reconciles += 1
|
|
991
1712
|
|
|
992
1713
|
const current = canonical()
|
|
993
1714
|
const currentKey = routeKey(current)
|
|
@@ -1000,6 +1721,22 @@ window.__ModuleLoader__.load({
|
|
|
1000
1721
|
if (paneTabs > 0 && host.activePaneKind() === undefined) {
|
|
1001
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'}`)
|
|
1002
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
|
+
}
|
|
1003
1740
|
// A position the previous step asked for: it is driven here, one step later, so the step
|
|
1004
1741
|
// that claimed it never drives while it is still running.
|
|
1005
1742
|
if (linked === undefined) {
|
|
@@ -1016,8 +1753,18 @@ window.__ModuleLoader__.load({
|
|
|
1016
1753
|
// The link has landed: it stops being authoritative right here, so the user
|
|
1017
1754
|
// keeps owning the address bar afterwards (walking deeper inside a panel
|
|
1018
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
|
+
}
|
|
1019
1766
|
dropLink()
|
|
1020
|
-
reflect(
|
|
1767
|
+
reflect(canonical())
|
|
1021
1768
|
return
|
|
1022
1769
|
}
|
|
1023
1770
|
if (host.now() >= link.deadline) {
|
|
@@ -1099,7 +1846,8 @@ window.__ModuleLoader__.load({
|
|
|
1099
1846
|
if (link.kind === 'settings') {
|
|
1100
1847
|
if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1101
1848
|
innerAttempts += 1
|
|
1102
|
-
|
|
1849
|
+
const parts = settingsPartsOf(link.inner)
|
|
1850
|
+
host.openSettings(link.id, parts.tab, parts.provider)
|
|
1103
1851
|
}
|
|
1104
1852
|
armRetry()
|
|
1105
1853
|
return
|
|
@@ -1190,7 +1938,16 @@ window.__ModuleLoader__.load({
|
|
|
1190
1938
|
// with a bound so an unopenable page cannot turn into a click loop.
|
|
1191
1939
|
if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1192
1940
|
innerAttempts += 1
|
|
1193
|
-
|
|
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
|
+
}
|
|
1194
1951
|
}
|
|
1195
1952
|
armRetry()
|
|
1196
1953
|
return
|
|
@@ -1393,6 +2150,14 @@ window.__ModuleLoader__.load({
|
|
|
1393
2150
|
paneActive: attemptValue(() => host.activePaneKind()) ?? null,
|
|
1394
2151
|
paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
|
|
1395
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 },
|
|
1396
2161
|
compat: observed.compat,
|
|
1397
2162
|
foreignFragment: observed.foreignFragment,
|
|
1398
2163
|
ignoredKinds: observed.ignored,
|
|
@@ -1902,6 +2667,13 @@ window.__ModuleLoader__.load({
|
|
|
1902
2667
|
}
|
|
1903
2668
|
const host = {
|
|
1904
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),
|
|
1905
2677
|
// The sidebar lists workspaces and their sessions in one tree, in order, so the
|
|
1906
2678
|
// workspace a conversation belongs to is the nearest workspace row before it.
|
|
1907
2679
|
workspaceNameOf: sessionId => {
|
|
@@ -1926,6 +2698,19 @@ window.__ModuleLoader__.load({
|
|
|
1926
2698
|
// The inner page a panel is showing, in that panel's own vocabulary. Each panel
|
|
1927
2699
|
// publishes it with a data attribute; reading it is the whole reflect direction.
|
|
1928
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
|
+
}
|
|
1929
2714
|
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
1930
2715
|
// The form and a task are mutually exclusive, and the form wins when both
|
|
1931
2716
|
// would be reported (it is the page the user is looking at).
|
|
@@ -1949,6 +2734,15 @@ window.__ModuleLoader__.load({
|
|
|
1949
2734
|
},
|
|
1950
2735
|
openPanelPage: (panelId, inner) => {
|
|
1951
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
|
+
}
|
|
1952
2746
|
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
1953
2747
|
// The task board owns how its overlays open: we hand over the request on its
|
|
1954
2748
|
// own contract (window events) and nothing else crosses the seam.
|
|
@@ -1974,8 +2768,9 @@ window.__ModuleLoader__.load({
|
|
|
1974
2768
|
const pkg =
|
|
1975
2769
|
window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
|
|
1976
2770
|
window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
|
|
1977
|
-
|
|
1978
|
-
|
|
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()
|
|
1979
2774
|
if (segments.length === 1) return
|
|
1980
2775
|
}
|
|
1981
2776
|
const rowId = segments[segments.length - 1]
|
|
@@ -1983,8 +2778,8 @@ window.__ModuleLoader__.load({
|
|
|
1983
2778
|
for (const row of rows) {
|
|
1984
2779
|
const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
|
|
1985
2780
|
if (parsed === undefined || parsed.rowId !== rowId) continue
|
|
1986
|
-
const button = row
|
|
1987
|
-
if (
|
|
2781
|
+
const button = openControlOf(row)
|
|
2782
|
+
if (button !== undefined) button.click()
|
|
1988
2783
|
return
|
|
1989
2784
|
}
|
|
1990
2785
|
},
|
|
@@ -2056,6 +2851,7 @@ window.__ModuleLoader__.load({
|
|
|
2056
2851
|
revealPosition: (parts, wantedSession) => {
|
|
2057
2852
|
const wanted = innerPath(parts)
|
|
2058
2853
|
const document = window.document
|
|
2854
|
+
|
|
2059
2855
|
if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
|
|
2060
2856
|
const key = wanted.slice(1).join('%00')
|
|
2061
2857
|
for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
|
|
@@ -2088,16 +2884,46 @@ window.__ModuleLoader__.load({
|
|
|
2088
2884
|
// from the catalog here is how this step used to look up "no session".
|
|
2089
2885
|
const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
|
|
2090
2886
|
const session = attemptValue(() => reference?.binding?.session)
|
|
2091
|
-
const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
|
|
2092
2887
|
const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
|
|
2093
2888
|
const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
|
|
2094
|
-
|
|
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'
|
|
2095
2893
|
if (typeof seq !== 'number' || !usable) {
|
|
2096
2894
|
attemptValue(() => reference?.release?.())
|
|
2097
2895
|
return false
|
|
2098
2896
|
}
|
|
2099
|
-
|
|
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))
|
|
2100
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
|
+
}
|
|
2101
2927
|
return true
|
|
2102
2928
|
},
|
|
2103
2929
|
// Which conversation view is showing. The registry is the source of truth for ids
|
|
@@ -2158,10 +2984,13 @@ window.__ModuleLoader__.load({
|
|
|
2158
2984
|
const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
|
|
2159
2985
|
return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
|
|
2160
2986
|
}
|
|
2161
|
-
|
|
2162
|
-
|
|
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.
|
|
2163
2990
|
const fromController = read(faces.sidebarRight)
|
|
2164
2991
|
if (fromController !== undefined) return fromController
|
|
2992
|
+
const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
|
|
2993
|
+
if (fromStrip !== undefined) return fromStrip
|
|
2165
2994
|
const fromRegistry = read(faces.sidebarRightTabs)
|
|
2166
2995
|
if (fromRegistry !== undefined) return fromRegistry
|
|
2167
2996
|
// DOM fallback, deliberately narrow: the active-pane marker is shared with the
|
|
@@ -2189,6 +3018,8 @@ window.__ModuleLoader__.load({
|
|
|
2189
3018
|
}
|
|
2190
3019
|
return undefined
|
|
2191
3020
|
},
|
|
3021
|
+
// The model-provider card whose editor is open, when one is (see the helper).
|
|
3022
|
+
activeSettingsProvider: () => openSettingsProviderId(window.document),
|
|
2192
3023
|
// The tab a settings section is showing: the same ARIA contract one level deeper.
|
|
2193
3024
|
activeSettingsTab: () => {
|
|
2194
3025
|
const overlay = settingsOverlayOf(window.document)
|
|
@@ -2214,7 +3045,7 @@ window.__ModuleLoader__.load({
|
|
|
2214
3045
|
window.document?.dispatchEvent?.(event)
|
|
2215
3046
|
})
|
|
2216
3047
|
},
|
|
2217
|
-
openSettings: (section, tab) => {
|
|
3048
|
+
openSettings: (section, tab, provider) => {
|
|
2218
3049
|
if (typeof section === 'string' && section !== '') {
|
|
2219
3050
|
// A section is named by the label the user reads, so the request is a label match.
|
|
2220
3051
|
const overlay = settingsOverlayOf(window.document)
|
|
@@ -2227,7 +3058,11 @@ window.__ModuleLoader__.load({
|
|
|
2227
3058
|
const wanted = section.trim().toLowerCase()
|
|
2228
3059
|
const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
|
|
2229
3060
|
if (match !== undefined && typeof match.click === 'function') {
|
|
2230
|
-
|
|
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())
|
|
2231
3066
|
// The section may own tabs: a named one is clicked once the section is up.
|
|
2232
3067
|
if (typeof tab === 'string' && tab !== '') {
|
|
2233
3068
|
const wantedTab = tab.trim().toLowerCase()
|
|
@@ -2235,6 +3070,8 @@ window.__ModuleLoader__.load({
|
|
|
2235
3070
|
const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
|
|
2236
3071
|
if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
|
|
2237
3072
|
}
|
|
3073
|
+
// And one of its cards may be the page a link named.
|
|
3074
|
+
if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
|
|
2238
3075
|
return
|
|
2239
3076
|
}
|
|
2240
3077
|
}
|
|
@@ -2355,7 +3192,22 @@ window.__ModuleLoader__.load({
|
|
|
2355
3192
|
attempt(() => {
|
|
2356
3193
|
const root = window.document?.body ?? window.document?.documentElement
|
|
2357
3194
|
if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
|
|
2358
|
-
|
|
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)
|
|
2359
3211
|
// Every published page hook must be watched: a filter that names only some of
|
|
2360
3212
|
// them silently starves the retry loop of notifications (real bug: the task
|
|
2361
3213
|
// board's drive looked flaky because its attributes were not observed).
|
|
@@ -2374,6 +3226,10 @@ window.__ModuleLoader__.load({
|
|
|
2374
3226
|
'aria-current',
|
|
2375
3227
|
// Switching a settings *tab* moves this one, for the same reason.
|
|
2376
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',
|
|
2377
3233
|
SETTINGS_SURFACE_ATTRIBUTE,
|
|
2378
3234
|
],
|
|
2379
3235
|
})
|
|
@@ -2541,10 +3397,11 @@ window.__ModuleLoader__.load({
|
|
|
2541
3397
|
* @returns {object|undefined} the trigger element, or undefined when absent.
|
|
2542
3398
|
*/
|
|
2543
3399
|
function powerTrigger() {
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
??
|
|
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="电源菜单"]')
|
|
2548
3405
|
?? undefined
|
|
2549
3406
|
}
|
|
2550
3407
|
|
|
@@ -2594,7 +3451,7 @@ window.__ModuleLoader__.load({
|
|
|
2594
3451
|
try {
|
|
2595
3452
|
style = document.createElement('style')
|
|
2596
3453
|
style.id = TITLEBAR_STYLE_ID
|
|
2597
|
-
style.textContent = macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT
|
|
3454
|
+
style.textContent = `${macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT}\n${RECENT_STYLE_TEXT}`
|
|
2598
3455
|
const parent = document.head ?? document.body
|
|
2599
3456
|
if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
|
|
2600
3457
|
} catch {
|
|
@@ -2612,6 +3469,16 @@ window.__ModuleLoader__.load({
|
|
|
2612
3469
|
input.placeholder = TITLEBAR_PLACEHOLDER
|
|
2613
3470
|
if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
|
|
2614
3471
|
bar.appendChild(input)
|
|
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)
|
|
2615
3482
|
/**
|
|
2616
3483
|
* The free stretch the bar was last fitted into.
|
|
2617
3484
|
*
|
|
@@ -2672,6 +3539,8 @@ window.__ModuleLoader__.load({
|
|
|
2672
3539
|
* macOS: sit in the free stretch of the header row, on the power button's
|
|
2673
3540
|
* own line.
|
|
2674
3541
|
*/
|
|
3542
|
+
/** The shape the last placement was measured from, so an unchanged header is not re-walked. */
|
|
3543
|
+
let lastShape
|
|
2675
3544
|
const place = () => {
|
|
2676
3545
|
if (macInHeader) {
|
|
2677
3546
|
// The bar lives on the header row, on the same line as the power
|
|
@@ -2684,6 +3553,20 @@ window.__ModuleLoader__.load({
|
|
|
2684
3553
|
const row = macHeaderRow(trigger)
|
|
2685
3554
|
const rowRect = row?.getBoundingClientRect?.()
|
|
2686
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
|
|
2687
3570
|
// The row is only walked when there is a button to measure from.
|
|
2688
3571
|
const gap = anchored ? macFreeGap(macLeaves(row, 0), rect, rowRect) : null
|
|
2689
3572
|
if (gap === null) {
|
|
@@ -2703,11 +3586,30 @@ window.__ModuleLoader__.load({
|
|
|
2703
3586
|
bar.style.display = 'none'
|
|
2704
3587
|
return
|
|
2705
3588
|
}
|
|
2706
|
-
const
|
|
2707
|
-
|
|
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)
|
|
2708
3610
|
bar.style.display = ''
|
|
2709
3611
|
bar.style.top = `${box.top}px`
|
|
2710
|
-
bar.style.left = `${left}px`
|
|
3612
|
+
bar.style.left = `${box.left}px`
|
|
2711
3613
|
bar.style.right = 'auto'
|
|
2712
3614
|
bar.style.width = `${box.width}px`
|
|
2713
3615
|
return
|
|
@@ -2744,7 +3646,7 @@ window.__ModuleLoader__.load({
|
|
|
2744
3646
|
let placeFrame
|
|
2745
3647
|
const schedulePlace = () => {
|
|
2746
3648
|
if (placeFrame !== undefined) return
|
|
2747
|
-
placeFrame = attemptValue(() =>
|
|
3649
|
+
placeFrame = attemptValue(() => window.requestAnimationFrame?.(() => {
|
|
2748
3650
|
placeFrame = undefined
|
|
2749
3651
|
place()
|
|
2750
3652
|
}))
|
|
@@ -2810,14 +3712,291 @@ window.__ModuleLoader__.load({
|
|
|
2810
3712
|
input.value = why
|
|
2811
3713
|
}, TITLEBAR_SETTLE_MS))
|
|
2812
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
|
+
}
|
|
2813
3988
|
const onKeyDown = event => {
|
|
2814
3989
|
if (event?.key === 'Escape') {
|
|
2815
3990
|
stopSettle()
|
|
3991
|
+
narrowed = false
|
|
3992
|
+
hideRecent()
|
|
2816
3993
|
mark('idle', undefined)
|
|
2817
3994
|
input.value = readableFragment(window.location?.hash)
|
|
2818
3995
|
return
|
|
2819
3996
|
}
|
|
2820
3997
|
if (event?.key !== 'Enter') return
|
|
3998
|
+
narrowed = false
|
|
3999
|
+
hideRecent()
|
|
2821
4000
|
const fragment = titlebarFragmentOf(input.value)
|
|
2822
4001
|
if (fragment === undefined) {
|
|
2823
4002
|
// Nothing here names a route: leave the address bar saying what is on screen.
|
|
@@ -2834,6 +4013,11 @@ window.__ModuleLoader__.load({
|
|
|
2834
4013
|
settle(fragment, false)
|
|
2835
4014
|
}
|
|
2836
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)
|
|
2837
4021
|
// Both platforms mount the bar in the body: Windows pins it into the
|
|
2838
4022
|
// title-bar strip, macOS pins it over the resident header row.
|
|
2839
4023
|
document.body.appendChild(bar)
|
|
@@ -2847,9 +4031,12 @@ window.__ModuleLoader__.load({
|
|
|
2847
4031
|
* node, so what was typed into it survives.
|
|
2848
4032
|
*/
|
|
2849
4033
|
let headerWatcher
|
|
2850
|
-
|
|
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') {
|
|
2851
4038
|
try {
|
|
2852
|
-
headerWatcher = new
|
|
4039
|
+
headerWatcher = new Observer(() => schedulePlace())
|
|
2853
4040
|
headerWatcher.observe(document.body, { childList: true, subtree: true })
|
|
2854
4041
|
} catch {
|
|
2855
4042
|
headerWatcher = undefined
|
|
@@ -2866,7 +4053,16 @@ window.__ModuleLoader__.load({
|
|
|
2866
4053
|
return () => {
|
|
2867
4054
|
offRoute?.()
|
|
2868
4055
|
stopSettle()
|
|
4056
|
+
if (placeFrame !== undefined) attemptValue(() => window.cancelAnimationFrame?.(placeFrame))
|
|
2869
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))
|
|
2870
4066
|
if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
|
|
2871
4067
|
headerWatcher?.disconnect?.()
|
|
2872
4068
|
bar.remove?.()
|
|
@@ -2874,6 +4070,158 @@ window.__ModuleLoader__.load({
|
|
|
2874
4070
|
}
|
|
2875
4071
|
}
|
|
2876
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
|
+
|
|
2877
4225
|
function apply(ctx) {
|
|
2878
4226
|
// Fragment routing: the address bar names the view, and a link opens it.
|
|
2879
4227
|
ctx.effect(() => {
|
|
@@ -2928,6 +4276,10 @@ window.__ModuleLoader__.load({
|
|
|
2928
4276
|
if (typeof window === 'undefined' || window.document === undefined) return () => {}
|
|
2929
4277
|
return startTitlebarAddress(window.document, subscribeRoute)
|
|
2930
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
|
+
|
|
2931
4283
|
}
|
|
2932
4284
|
|
|
2933
4285
|
exports.apply = apply
|