dsh-url-router 0.4.1 → 0.4.3
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 +137 -5
- package/README.md +66 -5
- package/lib/client.js +2313 -104
- 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
|
|
@@ -63,7 +78,69 @@ window.__ModuleLoader__.load({
|
|
|
63
78
|
// another router's route may legitimately be pasted in.
|
|
64
79
|
// ---------------------------------------------------------------------
|
|
65
80
|
/** Route kind aliases accepted on input. */
|
|
66
|
-
const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel', settings: 'settings', workspace: 'workspace', workspaces: 'workspace' }
|
|
81
|
+
const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel', settings: 'settings', power: 'power', workspace: 'workspace', workspaces: 'workspace' }
|
|
82
|
+
/**
|
|
83
|
+
* The power actions a link may name, and the endpoint the power plugin publishes for each.
|
|
84
|
+
*
|
|
85
|
+
* These are *commands*, not places: a URL that carries one ends the whole application (or
|
|
86
|
+
* restarts it), so a fragment that names one is honoured only when the reader typed it into
|
|
87
|
+
* the address bar themselves — never when it arrived as a hash change from a link somebody
|
|
88
|
+
* else wrote. `dsh-power-menu` already exposes both endpoints (`POST
|
|
89
|
+
* /api/dsh-power-menu/{shutdown,restart}`), so no click and no cooperation is needed.
|
|
90
|
+
*/
|
|
91
|
+
const POWER_ACTIONS = { shutdown: 'shutdown', restart: 'restart' }
|
|
92
|
+
/**
|
|
93
|
+
* The bases the power actions live under, in the order they are tried.
|
|
94
|
+
*
|
|
95
|
+
* Two packages publish the same two actions under different paths: the desktop build ships
|
|
96
|
+
* `dsh-power-menu-mac` (`/api/dsh-power-menu-mac`) while the plain package uses
|
|
97
|
+
* `/api/dsh-power-menu`. Measured 2026-10-29: posting to the wrong one is a silent 404, which
|
|
98
|
+
* is exactly how "cannot restart" looked — so both are tried, and a wrong base costs nothing.
|
|
99
|
+
*/
|
|
100
|
+
const POWER_ACTION_BASES = ['/api/dsh-power-menu-mac', '/api/dsh-power-menu']
|
|
101
|
+
/**
|
|
102
|
+
* How many drive steps the plugin's own menu gets before the endpoint is used instead.
|
|
103
|
+
*
|
|
104
|
+
* The drive retries every half second, so this is a couple of seconds: long enough for the
|
|
105
|
+
* menu to render, short enough that a plugin which never publishes the item still acts.
|
|
106
|
+
*/
|
|
107
|
+
const POWER_ACTION_MENU_LIMIT = 4
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The power plugin's own menu, when it is on screen.
|
|
111
|
+
*
|
|
112
|
+
* Contract attribute first (`data-dshpm-menu`), then the class the plugin ships. Neither is
|
|
113
|
+
* guessed at: an unreadable menu sends the drive down the endpoint fallback instead.
|
|
114
|
+
* @param {object} document - the document.
|
|
115
|
+
* @returns {object|undefined} the menu root.
|
|
116
|
+
*/
|
|
117
|
+
function powerMenuOf(document) {
|
|
118
|
+
const contracted = attemptValue(() => document?.querySelector?.('[data-dshpm-menu]'))
|
|
119
|
+
if (contracted !== undefined && contracted !== null) return contracted
|
|
120
|
+
const known = attemptValue(() => document?.getElementsByClassName?.('dshpm-menu')?.[0])
|
|
121
|
+
return known === undefined || known === null ? undefined : known
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The power action a route names, if any.
|
|
126
|
+
* @param {string} id - the route's identity segment.
|
|
127
|
+
* @returns {string|undefined} the action.
|
|
128
|
+
*/
|
|
129
|
+
function powerActionOf(id) {
|
|
130
|
+
if (typeof id !== 'string') return undefined
|
|
131
|
+
return Object.prototype.hasOwnProperty.call(POWER_ACTIONS, id) ? POWER_ACTIONS[id] : undefined
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The power menu: a transient popover rendered by a separate plugin, and the one surface
|
|
135
|
+
* this router does not own. Its contract is an attribute; the class names are the fallback
|
|
136
|
+
* for a build that has not added it yet.
|
|
137
|
+
*/
|
|
138
|
+
/** How many times one link may press the settings launcher while no menu appears. */
|
|
139
|
+
const SETTINGS_PRESS_LIMIT = 3
|
|
140
|
+
const POWER_MENU_ATTRIBUTE = 'data-dsh-power-menu'
|
|
141
|
+
const POWER_MENU_CLASS = 'dshpm-menu'
|
|
142
|
+
const POWER_TRIGGER_ATTRIBUTE = 'data-dsh-power-trigger'
|
|
143
|
+
const POWER_TRIGGER_CLASS = 'dshpm-trigger'
|
|
67
144
|
/** Attribute the settings overlay puts on its root element. */
|
|
68
145
|
const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
|
|
69
146
|
/**
|
|
@@ -78,6 +155,8 @@ window.__ModuleLoader__.load({
|
|
|
78
155
|
const SETTINGS_SURFACE_VALUE = 'settings'
|
|
79
156
|
/** Slot the settings entry is rendered into (its button opens the overlay). */
|
|
80
157
|
const SETTINGS_TRIGGER_SLOT = 'sidebar.settings'
|
|
158
|
+
/** Slot the settings sections are registered into — the same list the shell renders. */
|
|
159
|
+
const SETTINGS_SECTION_SLOT = 'settings.section'
|
|
81
160
|
/** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
|
|
82
161
|
const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
|
|
83
162
|
/** How often an unsatisfied link is retried while it is authoritative. */
|
|
@@ -122,23 +201,85 @@ window.__ModuleLoader__.load({
|
|
|
122
201
|
const TURN_SEGMENT = 'turn'
|
|
123
202
|
/** Inner segment that names the right-hand pane in front. */
|
|
124
203
|
const PANE_SEGMENT = 'pane'
|
|
204
|
+
/**
|
|
205
|
+
* The parent of a subagent session, as a decoration after the session id.
|
|
206
|
+
*
|
|
207
|
+
* A subagent session is a session of its own (it opens, it has turns and links), but the
|
|
208
|
+
* printed id says nothing about *whose* subagent it is. The catalog knows: the session
|
|
209
|
+
* controller resolves a child's durable (parent, child) address without opening anything.
|
|
210
|
+
* The parent is written like a workspace name — decoration, never the thing that lands.
|
|
211
|
+
*/
|
|
212
|
+
const SUBAGENT_SEGMENT = 'subagent-of'
|
|
213
|
+
/**
|
|
214
|
+
* Segment names other parsers look for, so a decoration never becomes one.
|
|
215
|
+
*
|
|
216
|
+
* The parent of a subagent is written as a readable segment when the catalog has a title.
|
|
217
|
+
* Those parsers (`viewIdOfRoute`, the turn/row/pane lookups) search by segment name, so a
|
|
218
|
+
* title that *is* one of these names would be read as a position. Such a title falls back
|
|
219
|
+
* to the parent id instead.
|
|
220
|
+
*/
|
|
221
|
+
const ROUTE_RESERVED_TOKENS = new Set(['turn', 'row', 'view', 'pane', 'subagent-of', 'provider', 'filter', 'task', 'rule', 'records', 'session', 'workspace', 'settings', 'panel'])
|
|
222
|
+
/** How long a title may be inside a route before it is clipped. */
|
|
223
|
+
const SUBAGENT_TITLE_LIMIT = 80
|
|
224
|
+
/**
|
|
225
|
+
* Whether a route segment is a session id (a uuid, with or without the `session-` prefix).
|
|
226
|
+
*
|
|
227
|
+
* The subagent decoration is a title when the catalog has one and the parent id otherwise,
|
|
228
|
+
* so the drive has to tell the two apart: an id is opened by address, anything else is a
|
|
229
|
+
* label and the child is opened by its own id.
|
|
230
|
+
* @param {unknown} value - the segment.
|
|
231
|
+
* @returns {boolean} whether it reads as a session id.
|
|
232
|
+
*/
|
|
233
|
+
function isSessionIdLike(value) {
|
|
234
|
+
return typeof value === 'string' && /^(?:session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value.trim())
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The readable name of a subagent's parent, or its id when there is no usable title.
|
|
238
|
+
* @param {object} parent - `{ id, title }` from the catalog.
|
|
239
|
+
* @returns {string} the segment to write.
|
|
240
|
+
*/
|
|
241
|
+
function subagentDecorationOf(parent) {
|
|
242
|
+
const title = typeof parent?.title === 'string' ? parent.title.trim().replace(/\s+/gu, ' ') : ''
|
|
243
|
+
const usable = title !== '' && !ROUTE_RESERVED_TOKENS.has(title.toLowerCase())
|
|
244
|
+
return usable ? title.slice(0, SUBAGENT_TITLE_LIMIT) : String(parent?.id ?? '')
|
|
245
|
+
}
|
|
125
246
|
/** Service the right-hand pane registers, whose snapshot names the active tab. */
|
|
126
247
|
const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
|
|
127
248
|
/** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
|
|
128
249
|
const PLUGINS_PANEL_ID = 'plugins'
|
|
250
|
+
/** Panel id of the automation-task manager, whose status filter and open task are routed. */
|
|
251
|
+
const SCHEDULES_PANEL_ID = 'schedules'
|
|
252
|
+
/** Inner segment that names the automation task a list has open. */
|
|
253
|
+
const TASK_SEGMENT = 'task'
|
|
254
|
+
/** Inner segment that names the model-provider card an open editor belongs to. */
|
|
255
|
+
const PROVIDER_SEGMENT = 'provider'
|
|
256
|
+
/** Inner segment that names the automation list's status filter. */
|
|
257
|
+
const FILTER_SEGMENT = 'filter'
|
|
258
|
+
/**
|
|
259
|
+
* The status filters, in the fixed order the manager renders them.
|
|
260
|
+
*
|
|
261
|
+
* Its own source maps `["all", "active", "inactive"]` over the three buttons, and the
|
|
262
|
+
* buttons carry no value of their own (only `aria-pressed`), so the position is the
|
|
263
|
+
* identity — the same positional contract the conversation view tabs already use.
|
|
264
|
+
*/
|
|
265
|
+
const SCHEDULE_FILTERS = ['all', 'active', 'inactive']
|
|
266
|
+
/** Attribute the manager puts on a task-detail tab, whose value is `rule` or `records`. */
|
|
267
|
+
const SCHEDULE_TAB_ATTRIBUTE = 'data-detail-tab'
|
|
268
|
+
/** The right bar's own "collapsed" marker: its tabs stay mounted while the bar is shut. */
|
|
269
|
+
const RIGHT_BAR_COLLAPSED_ATTRIBUTE = 'data-rightbar-collapsed'
|
|
129
270
|
/** Log prefix for every diagnostic. */
|
|
130
271
|
/**
|
|
131
272
|
* The router's own version, for the report a human files. A test keeps it equal to the
|
|
132
273
|
* package version, so a release cannot forget to bump it.
|
|
133
274
|
*/
|
|
134
|
-
const ROUTER_VERSION = '0.4.
|
|
275
|
+
const ROUTER_VERSION = '0.4.3'
|
|
135
276
|
/**
|
|
136
277
|
* Hand-maintained build marker, reported alongside the version. A working copy that is
|
|
137
278
|
* linked into a profile serves this file straight from disk, so the package version
|
|
138
279
|
* cannot say *which* copy a live window loaded — this can, and `report().build` is how a
|
|
139
280
|
* real-machine check answers that. Bump it whenever a live window must be told apart.
|
|
140
281
|
*/
|
|
141
|
-
const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar.
|
|
282
|
+
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+subagent-title+hover+view-labels+power+settings-launcher+settings-menu-visible+settings-seat+settings-once+landed-forms+settings-press+settings-store+settings-depth+settings-class-fallback+node-list+power-actions+power-action-lands+power-bases+power-menu-first+typed-rearms+power-contract-only+power-typed-gate+power-leave-focus+power-records+power-gate-precise+power-echo.39'
|
|
142
283
|
|
|
143
284
|
const TAG = '[url-router]'
|
|
144
285
|
|
|
@@ -153,6 +294,11 @@ window.__ModuleLoader__.load({
|
|
|
153
294
|
/** The bar's structural hook, the way the row anchors carry theirs. */
|
|
154
295
|
const TITLEBAR_PART = 'url-router-address-bar'
|
|
155
296
|
const TITLEBAR_STYLE_ID = 'dsh-url-router-titlebar-style'
|
|
297
|
+
/** Structural hooks the bar's recent list publishes (tests and the observer read them). */
|
|
298
|
+
const RECENT_LIST_ATTRIBUTE = 'data-dsh-url-router-recent'
|
|
299
|
+
const RECENT_ITEM_ATTRIBUTE = 'data-dsh-url-router-recent-item'
|
|
300
|
+
/** The bar's copy-confirmation chip. */
|
|
301
|
+
const TOAST_ATTRIBUTE = 'data-dsh-url-router-toast'
|
|
156
302
|
/** Width the window's caption buttons take at the right end of the strip. */
|
|
157
303
|
const TITLEBAR_CAPTION_WIDTH = 138
|
|
158
304
|
/** Gap between the shell's menu and the bar. */
|
|
@@ -188,11 +334,22 @@ window.__ModuleLoader__.load({
|
|
|
188
334
|
* has no power button. A couple of pixels is rounding, more is a resize.
|
|
189
335
|
*/
|
|
190
336
|
const MAC_BAR_RESIZE_SLACK = 8
|
|
337
|
+
/**
|
|
338
|
+
* The bar is hidden until the pointer is over it, when the preference asks for it.
|
|
339
|
+
*
|
|
340
|
+
* The bar keeps its box (only its opacity drops), so hovering the place where it lives is
|
|
341
|
+
* what reveals it — no extra hit area, and nothing else on the row moves.
|
|
342
|
+
*/
|
|
343
|
+
const HOVER_ATTRIBUTE = 'data-dsh-url-router-hover'
|
|
344
|
+
/** The reveal itself, set by the pointer/focus handlers and read by the stylesheet. */
|
|
345
|
+
const SHOWN_ATTRIBUTE = 'data-dsh-url-router-shown'
|
|
191
346
|
const MAC_STYLE_TEXT = [
|
|
192
347
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"]{position:fixed;display:flex;align-items:center;height:${MAC_BAR_HEIGHT}px;z-index:1200;-webkit-app-region:no-drag}`,
|
|
193
348
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input{height:${MAC_BAR_HEIGHT}px;width:100%;box-sizing:border-box;border:1px solid rgba(127,127,127,.34);border-radius:8px;padding:0 10px;font:inherit;font-size:12px;color:inherit;background:rgba(127,127,127,.16);outline:none;-webkit-app-region:no-drag}`,
|
|
194
349
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
195
350
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"][data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
|
|
351
|
+
`#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}]{opacity:0;transition:opacity .14s ease}`,
|
|
352
|
+
`#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}][${SHOWN_ATTRIBUTE}]{opacity:1}`,
|
|
196
353
|
].join('')
|
|
197
354
|
/** Where the bar's last spot is kept, so a screen without a power button still gets it. */
|
|
198
355
|
const MAC_PLACEMENT_KEY = 'dsh-url-router-placement'
|
|
@@ -236,6 +393,59 @@ window.__ModuleLoader__.load({
|
|
|
236
393
|
}
|
|
237
394
|
return leaves
|
|
238
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* The things whose presence on a line means "do not draw over this".
|
|
398
|
+
*
|
|
399
|
+
* Controls only: a page heading or an icon is decoration the bar may sit on, while a tab, a
|
|
400
|
+
* button or a field is something to click or type into (measured: the automation task's detail
|
|
401
|
+
* tabs were exactly what the bar covered).
|
|
402
|
+
*/
|
|
403
|
+
const BAND_SELECTORS = ['button', '[role="tab"]', '[role="button"]', 'a', 'input', 'textarea', 'select', '[contenteditable="true"]', 'h1', 'h2', 'h3']
|
|
404
|
+
/**
|
|
405
|
+
* The page's own contents that stand on one line of the window, as merged spans.
|
|
406
|
+
*
|
|
407
|
+
* A screen with no header row to measure (a panel, the settings overlay) still draws things
|
|
408
|
+
* at the bar's line: the automation task's detail tabs, a page heading, a toolbar. Reusing a
|
|
409
|
+
* stretch measured on a conversation put the bar straight across those tabs (measured on the
|
|
410
|
+
* automation panel). Only controls, headings and icons count — a background or a plain
|
|
411
|
+
* container is not something to give way to.
|
|
412
|
+
* @param {object} document - the page.
|
|
413
|
+
* @param {object} own - the bar itself, whose box is never an obstacle.
|
|
414
|
+
* @param {number} top - the line's top edge.
|
|
415
|
+
* @param {number} height - the line's height.
|
|
416
|
+
* @returns {Array<{left: number, right: number}>} merged spans, left to right.
|
|
417
|
+
*/
|
|
418
|
+
function bandSpans(document, own, top, height) {
|
|
419
|
+
const bottom = top + height
|
|
420
|
+
const seen = new Set()
|
|
421
|
+
const spans = []
|
|
422
|
+
// One selector at a time: a comma list is beyond a small DOM stub.
|
|
423
|
+
for (const selector of BAND_SELECTORS) {
|
|
424
|
+
for (const node of attemptValue(() => [...(document?.querySelectorAll?.(selector) ?? [])]) ?? []) {
|
|
425
|
+
if (node === null || node === undefined || seen.has(node)) continue
|
|
426
|
+
seen.add(node)
|
|
427
|
+
const mine = node === own || (typeof own?.contains === 'function' && own.contains(node) === true)
|
|
428
|
+
if (mine) continue
|
|
429
|
+
const rect = attemptValue(() => node.getBoundingClientRect?.())
|
|
430
|
+
if (rect === undefined || rect === null) continue
|
|
431
|
+
if (!(rect.height > 0) || rect.top >= bottom || rect.top + rect.height <= top) continue
|
|
432
|
+
const left = Math.round(rect.left)
|
|
433
|
+
const right = Math.round(rect.left + rect.width)
|
|
434
|
+
if (right > left) spans.push({ left, right })
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
spans.sort((first, second) => first.left - second.left)
|
|
438
|
+
const merged = []
|
|
439
|
+
for (const span of spans) {
|
|
440
|
+
const last = merged[merged.length - 1]
|
|
441
|
+
if (last !== undefined && span.left <= last.right) {
|
|
442
|
+
if (span.right > last.right) last.right = span.right
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
merged.push({ left: span.left, right: span.right })
|
|
446
|
+
}
|
|
447
|
+
return merged
|
|
448
|
+
}
|
|
239
449
|
/**
|
|
240
450
|
* The header row the bar has to live in: the row that carries the
|
|
241
451
|
* conversation's title on the left and the header's own buttons on the right.
|
|
@@ -248,8 +458,9 @@ window.__ModuleLoader__.load({
|
|
|
248
458
|
* @returns {object|undefined} the row element, or undefined when there is none.
|
|
249
459
|
*/
|
|
250
460
|
function macHeaderRow(trigger) {
|
|
251
|
-
const
|
|
252
|
-
const
|
|
461
|
+
const page = attemptValue(() => window.document)
|
|
462
|
+
const corner = attemptValue(() => page?.querySelector?.('[data-conversation-header-corner]'))
|
|
463
|
+
const named = attemptValue(() => page?.querySelector?.('[class*="titleRow"]'))
|
|
253
464
|
if (named !== undefined && named !== null) {
|
|
254
465
|
// Only the row that actually holds the button is the row to measure.
|
|
255
466
|
if (trigger === undefined || trigger === null) return named
|
|
@@ -330,6 +541,26 @@ window.__ModuleLoader__.load({
|
|
|
330
541
|
`#${TITLEBAR_BAR_ID} input{height:26px;width:100%;box-sizing:border-box;border:1px solid transparent;border-radius:6px;padding:0 8px;font:inherit;font-size:12px;color:inherit;background:var(--dsw-specific-sidebar-fill,rgba(127,127,127,.14));outline:none;-webkit-app-region:no-drag}`,
|
|
331
542
|
`#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
332
543
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-state="refused"] input{border-color:rgba(214,69,69,.85);color:rgba(214,69,69,1)}`,
|
|
544
|
+
`#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}]{opacity:0;transition:opacity .14s ease}`,
|
|
545
|
+
`#${TITLEBAR_BAR_ID}[${HOVER_ATTRIBUTE}][${SHOWN_ATTRIBUTE}]{opacity:1}`,
|
|
546
|
+
].join('')
|
|
547
|
+
/**
|
|
548
|
+
* The recent list's own stylesheet: a browser-style drop-down under the bar.
|
|
549
|
+
*
|
|
550
|
+
* Both platforms share it — the bar is the same fixed-position element in both — so
|
|
551
|
+
* the picker can never drift into two different-looking lists.
|
|
552
|
+
*/
|
|
553
|
+
const RECENT_STYLE_TEXT = [
|
|
554
|
+
`#${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}`,
|
|
555
|
+
`#${TITLEBAR_BAR_ID} [${RECENT_LIST_ATTRIBUTE}][data-open]{display:block}`,
|
|
556
|
+
`#${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}`,
|
|
557
|
+
`#${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}`,
|
|
558
|
+
`#${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}`,
|
|
559
|
+
`#${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)}`,
|
|
560
|
+
`#${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}`,
|
|
561
|
+
`#${TITLEBAR_BAR_ID} [${RECENT_ITEM_ATTRIBUTE}][data-copied] .dshur-link{color:var(--dsw-alias-brand-primary,#4d6bfe);font-weight:600}`,
|
|
562
|
+
`#${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}`,
|
|
563
|
+
`#${TITLEBAR_BAR_ID} [${TOAST_ATTRIBUTE}][data-show]{opacity:1}`,
|
|
333
564
|
].join('')
|
|
334
565
|
/** Listeners told the fragment this router just wrote; the desktop bar reads it. */
|
|
335
566
|
const routeFeed = new Set()
|
|
@@ -338,6 +569,9 @@ window.__ModuleLoader__.load({
|
|
|
338
569
|
* @param {string} hash - the fragment just written.
|
|
339
570
|
*/
|
|
340
571
|
function publishRoute(hash) {
|
|
572
|
+
// A fragment this router just wrote names the screen the reader moved to, so it is
|
|
573
|
+
// remembered (the bar's recent list). An empty write names nothing and is skipped.
|
|
574
|
+
rememberRecent(hash)
|
|
341
575
|
for (const listener of [...routeFeed]) {
|
|
342
576
|
try {
|
|
343
577
|
listener(hash)
|
|
@@ -363,6 +597,285 @@ window.__ModuleLoader__.load({
|
|
|
363
597
|
*/
|
|
364
598
|
let driveTypedFragment = () => {}
|
|
365
599
|
|
|
600
|
+
// ---------------------------------------------------------------------
|
|
601
|
+
// preferences: how the address bar behaves, remembered on this machine
|
|
602
|
+
//
|
|
603
|
+
// Two small preferences make the bar act like a browser's: the first click
|
|
604
|
+
// selects all of the old link, and the bar remembers the screens it has named
|
|
605
|
+
// plus the links pasted into it. They belong to this window rather than to the
|
|
606
|
+
// profile, so they live in localStorage; the plugin manager page writes them
|
|
607
|
+
// (`#/panel/plugins/dsh-url-router/dsh-url-router`).
|
|
608
|
+
// ---------------------------------------------------------------------
|
|
609
|
+
/** Preference record: the two switches the settings page owns. */
|
|
610
|
+
const SETTINGS_KEY = 'dsh-url-router-settings'
|
|
611
|
+
/** Remembered links, newest first. */
|
|
612
|
+
const RECENT_KEY = 'dsh-url-router-recent'
|
|
613
|
+
/** What the bar does before the settings page has ever been opened. */
|
|
614
|
+
const DEFAULT_SETTINGS = { selectAllOnFocus: true, hoverReveal: false, recentLimit: 8 }
|
|
615
|
+
/**
|
|
616
|
+
* The key the plugin manager dispatches a row's configuration under:
|
|
617
|
+
* `<package name>#<row id>`. This package declares one row whose package name and
|
|
618
|
+
* row id are the same string, so the key names it twice — and the page it opens is
|
|
619
|
+
* exactly `#/panel/plugins/dsh-url-router/dsh-url-router`.
|
|
620
|
+
*/
|
|
621
|
+
const ROW_CONFIG_KEY = 'dsh-url-router#dsh-url-router'
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* How much work the two observers have caused, for the report.
|
|
625
|
+
*
|
|
626
|
+
* A streaming answer mutates the page dozens of times a second; these counters are what a
|
|
627
|
+
* real-machine check reads to tell "coalesced" from "reconciled on every batch".
|
|
628
|
+
*/
|
|
629
|
+
const workCounts = { reconciles: 0, placements: 0 }
|
|
630
|
+
/** Listeners told the preferences changed; the settings card redraws from them. */
|
|
631
|
+
const settingsListeners = new Set()
|
|
632
|
+
/** The parsed record, read once and kept in step with every write. */
|
|
633
|
+
let settingsCache
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The settings record, with anything unusable replaced by its default.
|
|
637
|
+
* @param {unknown} raw - the parsed record, or anything that was on disk.
|
|
638
|
+
* @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record.
|
|
639
|
+
*/
|
|
640
|
+
function normaliseSettings(raw) {
|
|
641
|
+
const source = raw !== null && typeof raw === 'object' ? raw : {}
|
|
642
|
+
const limit = Number(source.recentLimit)
|
|
643
|
+
return {
|
|
644
|
+
selectAllOnFocus: typeof source.selectAllOnFocus === 'boolean' ? source.selectAllOnFocus : DEFAULT_SETTINGS.selectAllOnFocus,
|
|
645
|
+
hoverReveal: typeof source.hoverReveal === 'boolean' ? source.hoverReveal : DEFAULT_SETTINGS.hoverReveal,
|
|
646
|
+
// A count of remembered links: whole, never negative, and with no upper bound
|
|
647
|
+
// the reader did not ask for.
|
|
648
|
+
recentLimit: Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : DEFAULT_SETTINGS.recentLimit,
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Read the settings, once.
|
|
654
|
+
* @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record in force.
|
|
655
|
+
*/
|
|
656
|
+
function readSettings() {
|
|
657
|
+
if (settingsCache !== undefined) return settingsCache
|
|
658
|
+
settingsCache = normaliseSettings(attemptValue(() => {
|
|
659
|
+
const raw = globalThis.localStorage?.getItem?.(SETTINGS_KEY)
|
|
660
|
+
return raw === null || raw === undefined || raw === '' ? undefined : JSON.parse(raw)
|
|
661
|
+
}))
|
|
662
|
+
return settingsCache
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Change the settings, keep them, and tell the card.
|
|
667
|
+
* @param {object} patch - the fields to change.
|
|
668
|
+
* @returns {object} the record now in force.
|
|
669
|
+
*/
|
|
670
|
+
function updateSettings(patch) {
|
|
671
|
+
const next = normaliseSettings({ ...readSettings(), ...(patch !== null && typeof patch === 'object' ? patch : {}) })
|
|
672
|
+
settingsCache = next
|
|
673
|
+
attemptValue(() => globalThis.localStorage?.setItem?.(SETTINGS_KEY, JSON.stringify(next)))
|
|
674
|
+
// A lower count takes effect now, not at the next remembered link.
|
|
675
|
+
trimRecent()
|
|
676
|
+
for (const listener of [...settingsListeners]) {
|
|
677
|
+
try {
|
|
678
|
+
listener(next)
|
|
679
|
+
} catch {
|
|
680
|
+
// A card that throws must not stop the bar from reading the new record.
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return next
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Follow the settings from something that draws them.
|
|
688
|
+
* @param {Function} listener - called with the record after every change.
|
|
689
|
+
* @returns {() => void} the unsubscribe.
|
|
690
|
+
*/
|
|
691
|
+
function subscribeSettings(listener) {
|
|
692
|
+
settingsListeners.add(listener)
|
|
693
|
+
return () => settingsListeners.delete(listener)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* One remembered link, exactly as it was stored.
|
|
698
|
+
* @param {unknown} raw - one entry off disk.
|
|
699
|
+
* @returns {{hash: string, at: number, source: string}|undefined} the entry, or undefined when unusable.
|
|
700
|
+
*/
|
|
701
|
+
function recentEntryOf(raw) {
|
|
702
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
703
|
+
const hash = typeof raw.hash === 'string' ? raw.hash.trim() : ''
|
|
704
|
+
if (!hash.startsWith('#') || hash === '#') return undefined
|
|
705
|
+
const at = Number(raw.at)
|
|
706
|
+
return { hash, at: Number.isFinite(at) ? at : 0, source: raw.source === 'paste' ? 'paste' : 'visit' }
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The remembered links, newest first.
|
|
711
|
+
* @returns {Array<object>} the entries, in the order they are shown.
|
|
712
|
+
*/
|
|
713
|
+
function readRecent() {
|
|
714
|
+
const raw = attemptValue(() => {
|
|
715
|
+
const text = globalThis.localStorage?.getItem?.(RECENT_KEY)
|
|
716
|
+
return text === null || text === undefined || text === '' ? [] : JSON.parse(text)
|
|
717
|
+
})
|
|
718
|
+
if (!Array.isArray(raw)) return []
|
|
719
|
+
return raw.map(recentEntryOf).filter(entry => entry !== undefined)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Put the remembered links on disk.
|
|
724
|
+
* @param {Array<object>} entries - the entries to keep, newest first.
|
|
725
|
+
*/
|
|
726
|
+
function writeRecent(entries) {
|
|
727
|
+
attemptValue(() => globalThis.localStorage?.setItem?.(RECENT_KEY, JSON.stringify(entries)))
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The screen a remembered link stands for.
|
|
732
|
+
*
|
|
733
|
+
* A link may name a position inside a screen (`turn/…`, `row/…`, a panel's inner
|
|
734
|
+
* page); the memory is of *screens*, so the identity stops at the surface. Without
|
|
735
|
+
* this every click inside a conversation would push another entry of the same
|
|
736
|
+
* conversation and crowd the list out.
|
|
737
|
+
* @param {string} hash - the fragment.
|
|
738
|
+
* @returns {string} the identity two links of the same screen share.
|
|
739
|
+
*/
|
|
740
|
+
function recentIdentity(hash) {
|
|
741
|
+
const route = parseRoute(String(hash ?? ''))
|
|
742
|
+
if (route === undefined || route.unknown !== undefined) return String(hash ?? '')
|
|
743
|
+
// A command has no place to come back to, so it never enters the recent list.
|
|
744
|
+
if (route.kind === 'power' && powerActionOf(route.id) !== undefined) return ''
|
|
745
|
+
if (route.kind === 'workspace') {
|
|
746
|
+
const inner = innerPath(route.inner)
|
|
747
|
+
return inner.length >= 2 ? `${ROW_KEY_PREFIX}${inner[1]}` : `${route.kind}:${route.id}`
|
|
748
|
+
}
|
|
749
|
+
return `${route.kind}:${route.id}`
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The screen the address bar names right now.
|
|
754
|
+
* @returns {string} the current fragment, or an empty string off a page.
|
|
755
|
+
*/
|
|
756
|
+
function currentScreenHash() {
|
|
757
|
+
return typeof window?.location?.hash === 'string' ? window.location.hash : ''
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* How many entries are kept on disk.
|
|
762
|
+
*
|
|
763
|
+
* The count the reader sets is the number of links the drop-down offers — the screen in
|
|
764
|
+
* the address bar is not one of them, because that is where the reader already is. That
|
|
765
|
+
* screen is still remembered (leave it and it comes back in the list), so it may not eat
|
|
766
|
+
* one of the slots: one spare slot covers it, since a screen has exactly one entry.
|
|
767
|
+
* @returns {number} the entries to keep.
|
|
768
|
+
*/
|
|
769
|
+
function recentKeep() {
|
|
770
|
+
const limit = readSettings().recentLimit
|
|
771
|
+
return limit <= 0 ? 0 : limit + 1
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Keep the remembered links at the count in force, newest first.
|
|
776
|
+
* @returns {Array<object>} the entries that are kept.
|
|
777
|
+
*/
|
|
778
|
+
function trimRecent() {
|
|
779
|
+
const keep = recentKeep()
|
|
780
|
+
const stored = readRecent()
|
|
781
|
+
const kept = keep <= 0 ? [] : stored.slice(0, keep)
|
|
782
|
+
if (kept.length !== stored.length) writeRecent(kept)
|
|
783
|
+
return kept
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The remembered links the drop-down offers: the count in force, without the screen the
|
|
788
|
+
* address bar already names, newest first.
|
|
789
|
+
* @returns {Array<object>} the entries, in the order they are shown.
|
|
790
|
+
*/
|
|
791
|
+
function recentEntries() {
|
|
792
|
+
const limit = readSettings().recentLimit
|
|
793
|
+
// The memory is off: nothing is kept and nothing is shown.
|
|
794
|
+
if (limit <= 0) return []
|
|
795
|
+
const keep = recentKeep()
|
|
796
|
+
const stored = readRecent()
|
|
797
|
+
const kept = stored.length > keep ? stored.slice(0, keep) : stored
|
|
798
|
+
// Reading never writes: `report()` calls this and it must stay side-effect free.
|
|
799
|
+
// The disc is trimmed when a link is written and when the count changes.
|
|
800
|
+
const current = currentScreenHash()
|
|
801
|
+
return kept.filter(entry => entry.hash !== current).slice(0, limit)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Remember a link: a screen the view just moved to, or a link pasted into the bar.
|
|
806
|
+
* @param {string} hash - the fragment.
|
|
807
|
+
* @param {string} [source] - `paste` for a pasted link, `visit` (the default) otherwise.
|
|
808
|
+
*/
|
|
809
|
+
function rememberRecent(hash, source) {
|
|
810
|
+
const text = typeof hash === 'string' ? hash.trim() : ''
|
|
811
|
+
if (!text.startsWith('#') || text === '#') return
|
|
812
|
+
// Nothing is remembered while the memory is off, so turning it on later starts
|
|
813
|
+
// from an empty list instead of resurrecting links from before.
|
|
814
|
+
const limit = readSettings().recentLimit
|
|
815
|
+
if (limit <= 0) return
|
|
816
|
+
const identity = recentIdentity(text)
|
|
817
|
+
const entries = readRecent().filter(entry => recentIdentity(entry.hash) !== identity)
|
|
818
|
+
entries.unshift({ hash: text, at: attemptValue(() => Date.now(), 0), source: source === 'paste' ? 'paste' : 'visit' })
|
|
819
|
+
writeRecent(entries.slice(0, recentKeep()))
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/** Forget every remembered link. */
|
|
823
|
+
function clearRecent() {
|
|
824
|
+
writeRecent([])
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The conversation title the sidebar shows for an id, when it is rendered.
|
|
829
|
+
* @param {string} sessionId - the conversation id.
|
|
830
|
+
* @returns {string} the title, or an empty string when the row is not on screen.
|
|
831
|
+
*/
|
|
832
|
+
function sessionTitles() {
|
|
833
|
+
const titles = new Map()
|
|
834
|
+
const rows = attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []
|
|
835
|
+
for (const row of rows) {
|
|
836
|
+
const key = typeof row?.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
|
|
837
|
+
if (typeof key !== 'string' || !key.startsWith(ROW_KEY_PREFIX)) continue
|
|
838
|
+
const text = String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
|
|
839
|
+
if (text !== '') titles.set(key, text)
|
|
840
|
+
}
|
|
841
|
+
return titles
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* The title of one conversation, read from a map made once for the whole list.
|
|
846
|
+
* @param {string} sessionId - the conversation id.
|
|
847
|
+
* @param {Map<string, string>} [titles] - the row titles, when the caller already has them.
|
|
848
|
+
* @returns {string} the title, or an empty string when the row is not on screen.
|
|
849
|
+
*/
|
|
850
|
+
function sessionTitleOf(sessionId, titles) {
|
|
851
|
+
const key = `${ROW_KEY_PREFIX}${sessionId}`
|
|
852
|
+
if (titles !== undefined) return titles.get(key) ?? ''
|
|
853
|
+
for (const row of attemptValue(() => window.document?.querySelectorAll?.('[data-row-key]')) ?? []) {
|
|
854
|
+
if (typeof row?.getAttribute !== 'function' || row.getAttribute('data-row-key') !== key) continue
|
|
855
|
+
return String(row.textContent ?? '').trim().replace(/\s+/gu, ' ').slice(0, 80)
|
|
856
|
+
}
|
|
857
|
+
return ''
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* The words a remembered link reads as: the conversation's own title when the
|
|
862
|
+
* sidebar has it, a settings section's label, or nothing (the fragment then says it).
|
|
863
|
+
* @param {string} hash - the fragment.
|
|
864
|
+
* @returns {string} the label, or an empty string.
|
|
865
|
+
*/
|
|
866
|
+
function recentLabel(hash, titles) {
|
|
867
|
+
const route = parseRoute(String(hash ?? ''))
|
|
868
|
+
if (route === undefined || route.unknown !== undefined) return ''
|
|
869
|
+
if (route.kind === 'session') return sessionTitleOf(route.id, titles)
|
|
870
|
+
if (route.kind === 'workspace') {
|
|
871
|
+
const inner = innerPath(route.inner)
|
|
872
|
+
return inner.length >= 2 ? sessionTitleOf(inner[1], titles) : route.id
|
|
873
|
+
}
|
|
874
|
+
// A settings section's identity is its own display name.
|
|
875
|
+
if (route.kind === 'settings') return route.id
|
|
876
|
+
return ''
|
|
877
|
+
}
|
|
878
|
+
|
|
366
879
|
/**
|
|
367
880
|
* The conversation view ids the registry publishes, in registration order — the same order
|
|
368
881
|
* the header renders its tabs in, which is what lets a tab position map back to an id.
|
|
@@ -387,6 +900,30 @@ window.__ModuleLoader__.load({
|
|
|
387
900
|
return ids.length === 0 ? undefined : ids
|
|
388
901
|
}
|
|
389
902
|
|
|
903
|
+
/**
|
|
904
|
+
* The conversation views the slot contributes, with the labels the header prints.
|
|
905
|
+
*
|
|
906
|
+
* The header renders only some of these tabs (the rest go into the "…" menu), so a tab's
|
|
907
|
+
* *text* is the only reliable identity a reader can be mapped by — the registry ids alone
|
|
908
|
+
* can only be matched positionally, and only while every view is rendered.
|
|
909
|
+
* @param {object} slots - the slot service.
|
|
910
|
+
* @returns {Array<{id: string, label: string}>} contributions in registration order.
|
|
911
|
+
*/
|
|
912
|
+
function conversationViewLabelsOf(slots) {
|
|
913
|
+
const rows = attemptValue(() => slots?.entries?.('conversation.view'))
|
|
914
|
+
if (!Array.isArray(rows)) return []
|
|
915
|
+
const out = []
|
|
916
|
+
for (const row of rows) {
|
|
917
|
+
const options = row === null || typeof row !== 'object' ? undefined : row.options
|
|
918
|
+
const id = options?.id
|
|
919
|
+
if (typeof id !== 'string' || id === '') continue
|
|
920
|
+
let label = options?.label
|
|
921
|
+
if (typeof label === 'function') label = attemptValue(() => label())
|
|
922
|
+
out.push({ id, label: typeof label === 'string' ? label.trim() : '' })
|
|
923
|
+
}
|
|
924
|
+
return out
|
|
925
|
+
}
|
|
926
|
+
|
|
390
927
|
/**
|
|
391
928
|
* Read a value, or undefined when reading it throws.
|
|
392
929
|
* @param {Function} read - the reader.
|
|
@@ -519,6 +1056,72 @@ window.__ModuleLoader__.load({
|
|
|
519
1056
|
warn(`${TAG} ${message}`)
|
|
520
1057
|
}
|
|
521
1058
|
|
|
1059
|
+
/** Notes already given, per check: a compatibility problem is worth saying once. */
|
|
1060
|
+
const compatNotes = new Set()
|
|
1061
|
+
/** At most one compatibility sweep per this interval: each check reads the page. */
|
|
1062
|
+
const COMPAT_EVERY_MS = 1000
|
|
1063
|
+
/** The phrase every compatibility note carries, so a log can be searched for it. */
|
|
1064
|
+
const COMPAT_MESSAGE = '与当前 DSH 版本不兼容'
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* The screens whose hooks this router reads, and what it expects to find on them.
|
|
1068
|
+
*
|
|
1069
|
+
* Each check pairs a *weak* signal that the screen is up (something this build still
|
|
1070
|
+
* recognises) with a *strong* hook the router actually needs. A screen that is up while
|
|
1071
|
+
* its strong hook is gone is exactly the silent breakage this plugin cannot afford: links
|
|
1072
|
+
* stop naming that surface and nothing says so. The checks only report — they never change
|
|
1073
|
+
* what the router does.
|
|
1074
|
+
* @param {object} document - the page.
|
|
1075
|
+
* @param {object} context - what the router knows: the active panel, conversation count and registered views.
|
|
1076
|
+
* @returns {Array<{name: string, expected: string, found: string}>} the problems, empty when healthy.
|
|
1077
|
+
*/
|
|
1078
|
+
function compatibilityProblems(document, context) {
|
|
1079
|
+
const problems = []
|
|
1080
|
+
if (document === undefined || document === null || typeof document.querySelectorAll !== 'function') return problems
|
|
1081
|
+
const count = selector => attemptValue(() => document.querySelectorAll?.(selector)?.length) ?? 0
|
|
1082
|
+
// 1) The settings overlay. A modal carrying a nav of several buttons is this app's
|
|
1083
|
+
// settings page; when neither official marker is on it, the router is reading a page it
|
|
1084
|
+
// cannot name (this is the marker that already moved once).
|
|
1085
|
+
for (const dialog of attemptValue(() => [...(document.querySelectorAll('[role="dialog"]') ?? [])]) ?? []) {
|
|
1086
|
+
if (attemptValue(() => dialog.getAttribute?.('aria-modal')) !== 'true') continue
|
|
1087
|
+
let navButtons = 0
|
|
1088
|
+
for (const nav of attemptValue(() => [...(dialog.querySelectorAll('nav') ?? [])]) ?? []) {
|
|
1089
|
+
navButtons += attemptValue(() => nav.querySelectorAll?.('button')?.length) ?? 0
|
|
1090
|
+
}
|
|
1091
|
+
if (navButtons < 3) continue
|
|
1092
|
+
if (settingsOverlayOf(document) !== undefined) continue
|
|
1093
|
+
problems.push({ name: 'settings-overlay', expected: 'data-shortcut-modal="settings"', found: `a modal with a ${navButtons}-button nav and no settings marker` })
|
|
1094
|
+
break
|
|
1095
|
+
}
|
|
1096
|
+
// 2) The plugin manager. Its panel root is stable; its row/detail hooks are what links
|
|
1097
|
+
// are built from.
|
|
1098
|
+
if (count('[data-plugin-panel]') > 0) {
|
|
1099
|
+
const hooks = count('[data-plugin-package]') + count('[data-plugin-item]') + count('[data-plugin-row]')
|
|
1100
|
+
+ count('[data-plugin-detail]') + count('[data-plugin-item-detail]') + count('[data-plugin-row-detail]')
|
|
1101
|
+
if (hooks === 0) {
|
|
1102
|
+
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' })
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// 3) The automation list's status filter: three aria-pressed buttons in a role=group.
|
|
1106
|
+
if (context?.activePanelId === 'schedules' || count('[data-plugin-panel="schedules"]') > 0) {
|
|
1107
|
+
const groups = attemptValue(() => [...(document.querySelectorAll('[role="group"]') ?? [])]) ?? []
|
|
1108
|
+
if (groups.length > 0 && scheduleFilterOf(document) === undefined) {
|
|
1109
|
+
problems.push({ name: 'schedules-filter', expected: 'a [role="group"] holding three aria-pressed buttons', found: `${groups.length} group(s), none matching` })
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
// 4) The conversation header's view tabs. Only when the registry really publishes more
|
|
1113
|
+
// than one view: a composition with a single view has no tabs to find.
|
|
1114
|
+
if ((context?.views ?? 0) >= 2 && count('[data-chat-turn]') > 0 && count('[data-conversation-tabs]') === 0) {
|
|
1115
|
+
problems.push({ name: 'conversation-tabs', expected: `[${CONVERSATION_TABS_ATTRIBUTE}] in the header`, found: 'chat turns with no header container' })
|
|
1116
|
+
}
|
|
1117
|
+
// 5) The sidebar's conversation rows. The settings entry is the "a real sidebar is up"
|
|
1118
|
+
// signal, and the catalog says there is something to list.
|
|
1119
|
+
if (count(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`) > 0 && (context?.sessions ?? 0) > 0 && count('[data-row-key^="session:"]') === 0) {
|
|
1120
|
+
problems.push({ name: 'sidebar-rows', expected: '[data-row-key="session:<id>"] rows', found: `a sidebar with ${context.sessions} conversation(s) and no rows` })
|
|
1121
|
+
}
|
|
1122
|
+
return problems
|
|
1123
|
+
}
|
|
1124
|
+
|
|
522
1125
|
function activePaneTabOf(document, registry) {
|
|
523
1126
|
// Scoped to the right bar's own root: the same dockkit attributes dress the main area
|
|
524
1127
|
// too, and reading them unscoped reports the main area's panes, not this bar's tabs.
|
|
@@ -557,7 +1160,11 @@ window.__ModuleLoader__.load({
|
|
|
557
1160
|
if (named !== undefined) {
|
|
558
1161
|
const wanted = flatLabel(named).toLowerCase()
|
|
559
1162
|
const match = Array.isArray(registry) ? registry.find(row => typeof row.label === 'string' && flatLabel(row.label).toLowerCase() === wanted) : undefined
|
|
560
|
-
|
|
1163
|
+
if (match !== undefined) return match.kind
|
|
1164
|
+
// With a registry to check against, a label that matches nothing is a *title* — the
|
|
1165
|
+
// open file's name, a terminal's own name — never a kind. Without a registry the
|
|
1166
|
+
// label is the only name there is, so it is taken as the kind.
|
|
1167
|
+
return Array.isArray(registry) && registry.length > 0 ? undefined : named
|
|
561
1168
|
}
|
|
562
1169
|
// 2) an address-shaped id, for a future build that puts one there
|
|
563
1170
|
const fromTab = kindOfTabId(attemptValue(() => chosen.getAttribute?.('data-dockkit-tab')))
|
|
@@ -571,6 +1178,329 @@ window.__ModuleLoader__.load({
|
|
|
571
1178
|
return undefined
|
|
572
1179
|
}
|
|
573
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Whether the right-hand bar is collapsed.
|
|
1183
|
+
*
|
|
1184
|
+
* A collapsed bar keeps its tab strip mounted, so "a tab is selected in the strip" is not
|
|
1185
|
+
* the same question as "that pane is on screen" — measured: after closing the bar the
|
|
1186
|
+
* address bar kept naming its last pane, which was no longer anywhere.
|
|
1187
|
+
* @param {object} document - the page.
|
|
1188
|
+
* @returns {boolean} whether the bar is collapsed.
|
|
1189
|
+
*/
|
|
1190
|
+
function rightBarCollapsedOf(document) {
|
|
1191
|
+
const marker = attemptValue(() => document?.querySelector?.(`[${RIGHT_BAR_COLLAPSED_ATTRIBUTE}]`))
|
|
1192
|
+
if (marker === null || marker === undefined) return false
|
|
1193
|
+
const value = attemptValue(() => marker.getAttribute?.(RIGHT_BAR_COLLAPSED_ATTRIBUTE))
|
|
1194
|
+
return value !== 'false'
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* The status filter the automation-task list is showing.
|
|
1199
|
+
*
|
|
1200
|
+
* Read from the group's own buttons: the pressed one's *position* among the three is the
|
|
1201
|
+
* filter (see `SCHEDULE_FILTERS`). A group is the manager's when it holds exactly three
|
|
1202
|
+
* buttons, all of them pressed-state buttons, and it is not the right bar's own.
|
|
1203
|
+
* @param {object} document - the page.
|
|
1204
|
+
* @returns {string|undefined} `all`, `active` or `inactive`.
|
|
1205
|
+
*/
|
|
1206
|
+
function scheduleFilterOf(document) {
|
|
1207
|
+
const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
|
|
1208
|
+
for (const group of groups) {
|
|
1209
|
+
const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
|
|
1210
|
+
if (inside !== null && inside !== undefined) continue
|
|
1211
|
+
const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1212
|
+
if (buttons.length !== SCHEDULE_FILTERS.length) continue
|
|
1213
|
+
if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
|
|
1214
|
+
const pressed = buttons.findIndex(button => attemptValue(() => button.getAttribute?.('aria-pressed')) === 'true')
|
|
1215
|
+
if (pressed < 0) continue
|
|
1216
|
+
return SCHEDULE_FILTERS[pressed]
|
|
1217
|
+
}
|
|
1218
|
+
return undefined
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* The automation task the manager has open, and the detail tab it shows.
|
|
1223
|
+
*
|
|
1224
|
+
* The row states both facts: `aria-expanded` marks the open one, and its
|
|
1225
|
+
* `aria-describedby` ends with the task id (`<useId>-metadata-<taskId>`). The detail's
|
|
1226
|
+
* tabs carry their own value (`data-detail-tab="rule" | "records"`).
|
|
1227
|
+
* @param {object} document - the page.
|
|
1228
|
+
* @returns {{id: string, tab: string}|undefined} the open task, or undefined when none is.
|
|
1229
|
+
*/
|
|
1230
|
+
function scheduleTaskOf(document) {
|
|
1231
|
+
const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1232
|
+
for (const button of buttons) {
|
|
1233
|
+
const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
|
|
1234
|
+
if (typeof described !== 'string' || described === '') continue
|
|
1235
|
+
const match = /-metadata-(.+)$/u.exec(described)
|
|
1236
|
+
if (match === null) continue
|
|
1237
|
+
if (attemptValue(() => button.getAttribute?.('aria-expanded')) !== 'true') continue
|
|
1238
|
+
const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
|
|
1239
|
+
const chosen = tabs.find(tab => attemptValue(() => tab.getAttribute?.('aria-selected')) === 'true')
|
|
1240
|
+
const tab = attemptValue(() => chosen?.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE))
|
|
1241
|
+
return { id: match[1], tab: typeof tab === 'string' && tab !== '' ? tab : 'rule' }
|
|
1242
|
+
}
|
|
1243
|
+
return undefined
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* The parts of a settings inner path.
|
|
1248
|
+
*
|
|
1249
|
+
* A settings route may name a tab (`[<tab>]`) and, one level deeper, the model-provider
|
|
1250
|
+
* card whose editor is open (`[<tab>?/provider/<id>]`). Keeping one reader for both shapes
|
|
1251
|
+
* is what lets the link's landing rule and the drive agree.
|
|
1252
|
+
* @param {string|Array<string>} inner - the inner path.
|
|
1253
|
+
* @returns {{tab: string|undefined, provider: string|undefined}} the parts.
|
|
1254
|
+
*/
|
|
1255
|
+
/** A row whose state lives in a styling class: the class has to end in one of these words. */
|
|
1256
|
+
const ROW_ON_CLASS = /(?:On|Active|Selected|Current|Checked)$/
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* The label a control is known by: its own text, without a nested badge.
|
|
1260
|
+
*
|
|
1261
|
+
* A chip can read `宠物 12`, where `12` is a count inside a nested element (measured on the
|
|
1262
|
+
* market's filter row). A count is not identity — it moves — so only the control's own text
|
|
1263
|
+
* names it, and a link stays valid when the number changes.
|
|
1264
|
+
* @param {object} control - the control.
|
|
1265
|
+
* @returns {string} its label.
|
|
1266
|
+
*/
|
|
1267
|
+
function controlLabelOf(control) {
|
|
1268
|
+
let own = ''
|
|
1269
|
+
for (const node of control?.childNodes ?? []) {
|
|
1270
|
+
if (node?.nodeType === 3) own += String(node.textContent ?? '')
|
|
1271
|
+
}
|
|
1272
|
+
const text = own.trim() !== '' ? own : String(control?.textContent ?? '')
|
|
1273
|
+
return text.trim().replace(/\s+/g, ' ')
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Whether one settings row's control is the one selected.
|
|
1278
|
+
* @param {object} row - a row from `settingsRowsOf`.
|
|
1279
|
+
* @param {object} control - a control of that row.
|
|
1280
|
+
* @returns {boolean} whether it is on.
|
|
1281
|
+
*/
|
|
1282
|
+
function controlIsOn(row, control) {
|
|
1283
|
+
if (row.marker === 'class') return typeof control.className === 'string' && ROW_ON_CLASS.test(control.className)
|
|
1284
|
+
return control.getAttribute(row.marker) === 'true'
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* The rows of selectable controls inside the settings overlay, in document order.
|
|
1289
|
+
*
|
|
1290
|
+
* The overlay is deeper than one tab row: a section owns tabs, a tab may own another row
|
|
1291
|
+
* (measured 2026-10-28: `Web 扩展 / 创意工坊` holds `编辑推荐 / 皮肤 / 宠物 / 插件 / 预设`),
|
|
1292
|
+
* and that row may hold filters of its own. A row is a parent whose children all carry the
|
|
1293
|
+
* same marker — `aria-selected` for tabs, `aria-pressed` for toggles — with exactly one of
|
|
1294
|
+
* them true, and the row's place is its first control's place in the document.
|
|
1295
|
+
* @param {object} overlay - the settings overlay.
|
|
1296
|
+
* @returns {Array<{node: object, marker: string}>} the rows.
|
|
1297
|
+
*/
|
|
1298
|
+
function settingsRowsOf(overlay) {
|
|
1299
|
+
if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return []
|
|
1300
|
+
const rows = []
|
|
1301
|
+
const seen = new Set()
|
|
1302
|
+
for (const marker of ['aria-selected', 'aria-pressed']) {
|
|
1303
|
+
// A query result is a NodeList, not an array: spread it *inside* the attempt, or the
|
|
1304
|
+
// guard below silently skips the whole scan in a real DOM (measured 2026-10-28).
|
|
1305
|
+
const nodes = attemptValue(() => [...overlay.querySelectorAll(`[${marker}]`)])
|
|
1306
|
+
if (!Array.isArray(nodes)) continue
|
|
1307
|
+
for (const node of nodes) {
|
|
1308
|
+
const parent = node?.parentElement
|
|
1309
|
+
if (parent === null || parent === undefined || seen.has(parent)) continue
|
|
1310
|
+
const children = attemptValue(() => [...parent.children]) ?? []
|
|
1311
|
+
const marked = children.filter(child => typeof child.getAttribute === 'function' && child.getAttribute(marker) !== null)
|
|
1312
|
+
if (marked.length < 2) continue
|
|
1313
|
+
const on = marked.find(child => child.getAttribute(marker) === 'true')
|
|
1314
|
+
if (on === undefined) continue
|
|
1315
|
+
if (controlLabelOf(on) === '') continue
|
|
1316
|
+
seen.add(parent)
|
|
1317
|
+
rows.push({ node: parent, marker })
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// A row with no ARIA at all: some plugins only toggle a styling class (measured on the
|
|
1321
|
+
// market's filter chips: `…_filterChipOn`, no aria-* anywhere). It is a last resort and it
|
|
1322
|
+
// is deliberately narrow — the class has to *end* in one of these words, exactly one
|
|
1323
|
+
// sibling may carry it, and the row must have at least two controls.
|
|
1324
|
+
const buttons = attemptValue(() => [...overlay.querySelectorAll('button')])
|
|
1325
|
+
if (Array.isArray(buttons)) {
|
|
1326
|
+
for (const node of buttons) {
|
|
1327
|
+
const parent = node?.parentElement
|
|
1328
|
+
if (parent === null || parent === undefined || seen.has(parent)) continue
|
|
1329
|
+
const children = (attemptValue(() => [...parent.children]) ?? []).filter(child => typeof child.className === 'string')
|
|
1330
|
+
if (children.length < 2) continue
|
|
1331
|
+
const marked = children.filter(child => ROW_ON_CLASS.test(child.className))
|
|
1332
|
+
if (marked.length !== 1) continue
|
|
1333
|
+
if (controlLabelOf(marked[0]) === '') continue
|
|
1334
|
+
seen.add(parent)
|
|
1335
|
+
rows.push({ node: parent, marker: 'class' })
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return rows.sort((a, b) => {
|
|
1339
|
+
if (a.node === b.node) return 0
|
|
1340
|
+
const position = attemptValue(() => a.node.compareDocumentPosition?.(b.node)) ?? 0
|
|
1341
|
+
// 4 is DOCUMENT_POSITION_FOLLOWING: `a` comes first.
|
|
1342
|
+
return (position & 4) !== 0 ? -1 : 1
|
|
1343
|
+
})
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* What each settings row has selected, in row order — the path a link can name.
|
|
1348
|
+
* @param {object} overlay - the settings overlay.
|
|
1349
|
+
* @returns {string[]} the labels.
|
|
1350
|
+
*/
|
|
1351
|
+
function settingsSelectedLabelsOf(overlay) {
|
|
1352
|
+
const out = []
|
|
1353
|
+
for (const row of settingsRowsOf(overlay)) {
|
|
1354
|
+
const on = [...row.node.children].find(child => controlIsOn(row, child))
|
|
1355
|
+
const label = on === undefined ? '' : controlLabelOf(on)
|
|
1356
|
+
if (label !== '') out.push(label)
|
|
1357
|
+
}
|
|
1358
|
+
return out
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* The control inside one settings row whose label is the one a link names.
|
|
1363
|
+
* @param {object} row - a row from `settingsRowsOf`.
|
|
1364
|
+
* @param {string} label - the wanted label.
|
|
1365
|
+
* @returns {object|undefined} the control.
|
|
1366
|
+
*/
|
|
1367
|
+
function settingsControlOf(row, label) {
|
|
1368
|
+
const wanted = label.trim().toLowerCase()
|
|
1369
|
+
for (const child of attemptValue(() => [...row.node.children]) ?? []) {
|
|
1370
|
+
if (typeof child.getAttribute !== 'function') continue
|
|
1371
|
+
if (row.marker !== 'class' && child.getAttribute(row.marker) === null) continue
|
|
1372
|
+
if (controlLabelOf(child).toLowerCase() !== wanted) continue
|
|
1373
|
+
return child
|
|
1374
|
+
}
|
|
1375
|
+
return undefined
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function settingsPartsOf(inner) {
|
|
1379
|
+
const parts = innerPath(inner)
|
|
1380
|
+
const at = parts.indexOf(PROVIDER_SEGMENT)
|
|
1381
|
+
const levels = at < 0 ? parts : parts.slice(0, at)
|
|
1382
|
+
return { levels, provider: at < 0 ? undefined : parts[at + 1] }
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* The model-provider card that is open in the settings overlay, when one is.
|
|
1387
|
+
*
|
|
1388
|
+
* The models section renders a card's editor inline and publishes no data hook for it: the
|
|
1389
|
+
* card is an `li` that suddenly holds a text field, and its identity lives in an action's
|
|
1390
|
+
* accessible name, which the official copy builds as `<label> <displayName>
|
|
1391
|
+
* (<provider>)`. Only the parenthesised provider id is language-independent, so that is
|
|
1392
|
+
* what a link carries.
|
|
1393
|
+
* @param {object} document - the page.
|
|
1394
|
+
* @returns {string|undefined} the open provider id.
|
|
1395
|
+
*/
|
|
1396
|
+
function openSettingsProviderId(document) {
|
|
1397
|
+
const overlay = settingsOverlayOf(document)
|
|
1398
|
+
if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
|
|
1399
|
+
for (const card of overlay.querySelectorAll('li')) {
|
|
1400
|
+
if (typeof card?.querySelector !== 'function') continue
|
|
1401
|
+
if (card.querySelector('input') === null && card.querySelector('textarea') === null) continue
|
|
1402
|
+
for (const button of card.querySelectorAll('button')) {
|
|
1403
|
+
const label = attemptValue(() => button.getAttribute?.('aria-label'))
|
|
1404
|
+
if (typeof label !== 'string' || label === '') continue
|
|
1405
|
+
const match = /\(([^()]*)\)\s*$/u.exec(label)
|
|
1406
|
+
if (match === null || match[1].trim() === '') continue
|
|
1407
|
+
return match[1].trim()
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return undefined
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Open one model-provider card's editor in the settings overlay.
|
|
1415
|
+
* @param {string} id - the provider id the link named.
|
|
1416
|
+
*/
|
|
1417
|
+
function clickSettingsProvider(id) {
|
|
1418
|
+
const wanted = String(id ?? '').trim().toLowerCase()
|
|
1419
|
+
if (wanted === '') return
|
|
1420
|
+
const overlay = settingsOverlayOf(window.document)
|
|
1421
|
+
const scope = overlay ?? window.document
|
|
1422
|
+
const buttons = attemptValue(() => [...(scope?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1423
|
+
for (const button of buttons) {
|
|
1424
|
+
const label = attemptValue(() => button.getAttribute?.('aria-label'))
|
|
1425
|
+
if (typeof label !== 'string') continue
|
|
1426
|
+
const match = /\(([^()]*)\)\s*$/u.exec(label)
|
|
1427
|
+
if (match === null || match[1].trim().toLowerCase() !== wanted) continue
|
|
1428
|
+
// Already open: the next press would close it, and the retry loop would then flip the
|
|
1429
|
+
// card open and shut until the link expired.
|
|
1430
|
+
const card = attemptValue(() => button.closest?.('li'))
|
|
1431
|
+
if (card !== null && card !== undefined) {
|
|
1432
|
+
const open = attemptValue(() => card.querySelector?.('input') !== null || card.querySelector?.('textarea') !== null)
|
|
1433
|
+
if (open === true) return
|
|
1434
|
+
}
|
|
1435
|
+
// The edit control comes before the delete control in a card's actions.
|
|
1436
|
+
if (typeof button.click === 'function') attemptValue(() => button.click())
|
|
1437
|
+
return
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Press one status filter of the automation list.
|
|
1443
|
+
* @param {string} token - `all`, `active` or `inactive`.
|
|
1444
|
+
*/
|
|
1445
|
+
function clickScheduleFilter(token) {
|
|
1446
|
+
const order = SCHEDULE_FILTERS.indexOf(String(token ?? ''))
|
|
1447
|
+
if (order < 0) return
|
|
1448
|
+
const document = window.document
|
|
1449
|
+
const groups = attemptValue(() => [...(document?.querySelectorAll?.('[role="group"]') ?? [])]) ?? []
|
|
1450
|
+
for (const group of groups) {
|
|
1451
|
+
const inside = attemptValue(() => group?.closest?.(`[${RIGHT_BAR_ATTRIBUTE}]`))
|
|
1452
|
+
if (inside !== null && inside !== undefined) continue
|
|
1453
|
+
const buttons = attemptValue(() => [...(group?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1454
|
+
if (buttons.length !== SCHEDULE_FILTERS.length) continue
|
|
1455
|
+
if (!buttons.every(button => attemptValue(() => button.getAttribute?.('aria-pressed')) !== null)) continue
|
|
1456
|
+
const button = buttons[order]
|
|
1457
|
+
if (typeof button?.click === 'function') attemptValue(() => button.click())
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Open one automation task's detail, and the detail tab it names.
|
|
1464
|
+
* @param {string} id - the task id.
|
|
1465
|
+
* @param {string} [tab] - `rule` or `records`.
|
|
1466
|
+
*/
|
|
1467
|
+
function clickScheduleTask(id, tab) {
|
|
1468
|
+
const wanted = String(id ?? '')
|
|
1469
|
+
if (wanted === '') return
|
|
1470
|
+
const document = window.document
|
|
1471
|
+
const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1472
|
+
let found = false
|
|
1473
|
+
for (const button of buttons) {
|
|
1474
|
+
const described = attemptValue(() => button.getAttribute?.('aria-describedby'))
|
|
1475
|
+
if (typeof described !== 'string' || !described.endsWith(`-metadata-${wanted}`)) continue
|
|
1476
|
+
found = true
|
|
1477
|
+
if (typeof button.click === 'function') attemptValue(() => button.click())
|
|
1478
|
+
break
|
|
1479
|
+
}
|
|
1480
|
+
// A filter hides the rows it does not match, so a link to a task outside it has nothing
|
|
1481
|
+
// to open. The list is widened to "all" and the next retry finds the row.
|
|
1482
|
+
if (!found) clickScheduleFilter(SCHEDULE_FILTERS[0])
|
|
1483
|
+
if (typeof tab !== 'string' || tab === '' || tab === 'rule') return
|
|
1484
|
+
const tabs = attemptValue(() => [...(document?.querySelectorAll?.(`[${SCHEDULE_TAB_ATTRIBUTE}]`) ?? [])]) ?? []
|
|
1485
|
+
const chosen = tabs.find(candidate => attemptValue(() => candidate.getAttribute?.(SCHEDULE_TAB_ATTRIBUTE)) === tab)
|
|
1486
|
+
if (typeof chosen?.click === 'function') attemptValue(() => chosen.click())
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* `filter/all` names the state a bare automation list is already in, so a link carrying it
|
|
1491
|
+
* is read as the bare panel: the router never writes it, and dropping it here keeps the
|
|
1492
|
+
* link comparable (it lands instead of waiting for a page that will never say `all`).
|
|
1493
|
+
* @param {string} kind - the route kind.
|
|
1494
|
+
* @param {string} id - the route identity.
|
|
1495
|
+
* @param {Array<string>} parts - the inner path segments.
|
|
1496
|
+
* @returns {Array<string>} the segments to use.
|
|
1497
|
+
*/
|
|
1498
|
+
function normalisePanelInner(kind, id, parts) {
|
|
1499
|
+
if (kind !== 'panel' || id !== SCHEDULES_PANEL_ID) return parts
|
|
1500
|
+
if (parts.length >= 2 && parts[0] === FILTER_SEGMENT && parts[1] === SCHEDULE_FILTERS[0]) return parts.slice(2)
|
|
1501
|
+
return parts
|
|
1502
|
+
}
|
|
1503
|
+
|
|
574
1504
|
/**
|
|
575
1505
|
* Read a route out of a location fragment.
|
|
576
1506
|
* @param {string} hash - the raw `window.location.hash`, including the leading `#`.
|
|
@@ -608,7 +1538,7 @@ window.__ModuleLoader__.load({
|
|
|
608
1538
|
const kind = ROUTE_ALIASES[match[1]]
|
|
609
1539
|
if (kind === undefined) return { unknown: match[1] }
|
|
610
1540
|
// A modal surface needs no identity: `#/settings` is the whole route.
|
|
611
|
-
if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
|
|
1541
|
+
if (match[2] === undefined) return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
|
|
612
1542
|
// A conversation can be named inside its workspace — the shape the sidebar itself shows,
|
|
613
1543
|
// and the only way a link says *where* a conversation lives.
|
|
614
1544
|
if (kind === 'workspace') {
|
|
@@ -635,9 +1565,9 @@ window.__ModuleLoader__.load({
|
|
|
635
1565
|
}
|
|
636
1566
|
const segments = match[2].replace(/\/+$/, '').split('/')
|
|
637
1567
|
const head = segments.shift() ?? ''
|
|
638
|
-
if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
|
|
1568
|
+
if (head === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
|
|
639
1569
|
const id = decodePart(head).trim()
|
|
640
|
-
if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
|
|
1570
|
+
if (id === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
|
|
641
1571
|
// A settings section has no stable id in the DOM (measured: the nav item carries only
|
|
642
1572
|
// aria-current), so its identity is its *label* — the string the user actually sees and
|
|
643
1573
|
// clicks. Round-tripping the label keeps the link self-consistent; a locale change
|
|
@@ -650,7 +1580,7 @@ window.__ModuleLoader__.load({
|
|
|
650
1580
|
}
|
|
651
1581
|
// A panel may have an inner page (a plugin's detail view, for instance): the
|
|
652
1582
|
// rest of the path names it, per panel.
|
|
653
|
-
const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
|
|
1583
|
+
const inner = normalisePanelInner(kind, id, segments.map(decodePart).filter(segment => segment.trim() !== ''))
|
|
654
1584
|
return { kind, id, inner: inner.length === 0 ? undefined : inner }
|
|
655
1585
|
}
|
|
656
1586
|
|
|
@@ -663,6 +1593,10 @@ window.__ModuleLoader__.load({
|
|
|
663
1593
|
function routeHash(kind, id, inner) {
|
|
664
1594
|
// A modal surface has no identity: `#/settings`, not `#/settings/`.
|
|
665
1595
|
if (kind === 'settings' && (id === undefined || id === '')) return '#/settings'
|
|
1596
|
+
// The power menu has no identity of its own — there is one of it — but an *action* does:
|
|
1597
|
+
// dropping it here would make the bar compare a typed `#/power/restart` against a bare
|
|
1598
|
+
// `#/power` and call a command it had already sent "打不开" (measured 2026-10-29).
|
|
1599
|
+
if (kind === 'power') return powerActionOf(id) === undefined ? '#/power' : `#/power/${encodeURIComponent(id)}`
|
|
666
1600
|
if (kind === 'workspace') {
|
|
667
1601
|
const rest = innerPath(inner)
|
|
668
1602
|
const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
|
|
@@ -711,6 +1645,27 @@ window.__ModuleLoader__.load({
|
|
|
711
1645
|
return rowId === '' ? undefined : { packageName: undefined, rowId }
|
|
712
1646
|
}
|
|
713
1647
|
|
|
1648
|
+
/**
|
|
1649
|
+
* The control that opens the page a plugin row describes.
|
|
1650
|
+
*
|
|
1651
|
+
* The manager puts the row's own link first and its enable switch beside it, and both
|
|
1652
|
+
* are buttons. Clicking the switch on behalf of a pasted link would silently turn a
|
|
1653
|
+
* plugin off (measured: a component page link did exactly that while the row had no
|
|
1654
|
+
* configuration), so the switch is never picked. A row whose only button is the switch
|
|
1655
|
+
* has no page to open, and its link is left to expire with the usual diagnostic.
|
|
1656
|
+
* @param {object} element - the row, card or list item.
|
|
1657
|
+
* @returns {object|undefined} the control to click.
|
|
1658
|
+
*/
|
|
1659
|
+
function openControlOf(element) {
|
|
1660
|
+
const buttons = attemptValue(() => [...(element?.querySelectorAll?.('button') ?? [])]) ?? []
|
|
1661
|
+
for (const button of buttons) {
|
|
1662
|
+
const role = typeof button?.getAttribute === 'function' ? button.getAttribute('role') : null
|
|
1663
|
+
if (role === 'switch') continue
|
|
1664
|
+
if (typeof button?.click === 'function') return button
|
|
1665
|
+
}
|
|
1666
|
+
return undefined
|
|
1667
|
+
}
|
|
1668
|
+
|
|
714
1669
|
function innerPath(value) {
|
|
715
1670
|
const parts = Array.isArray(value) ? value : String(value ?? '').split('/')
|
|
716
1671
|
return parts.map(part => String(part).trim()).filter(part => part !== '')
|
|
@@ -780,7 +1735,7 @@ window.__ModuleLoader__.load({
|
|
|
780
1735
|
let panelScope
|
|
781
1736
|
/** The turn the user last clicked inside a conversation, with its session. */
|
|
782
1737
|
/** What the report can say about this run: fragments it refused, kinds it left alone. */
|
|
783
|
-
const observed = { foreignFragment: undefined, ignored: [], warnings: [] }
|
|
1738
|
+
const observed = { foreignFragment: undefined, ignored: [], warnings: [], compat: [] }
|
|
784
1739
|
let rowAnchor
|
|
785
1740
|
/** The pane kind this claim already opened (one drive per claim, see the pane branch). */
|
|
786
1741
|
let paneOpenedFor
|
|
@@ -792,6 +1747,8 @@ window.__ModuleLoader__.load({
|
|
|
792
1747
|
let innerAttempts = 0
|
|
793
1748
|
/** Whether the current link already asked for a modal standing in front of it to go away. */
|
|
794
1749
|
let overlayDismissed = false
|
|
1750
|
+
/** When the compatibility checks last read the page (`-Infinity`: never swept). */
|
|
1751
|
+
let lastCompatSweep = -Infinity
|
|
795
1752
|
/** The view state observed on the previous notification. */
|
|
796
1753
|
let seen = 'none'
|
|
797
1754
|
let cancelTimer
|
|
@@ -809,6 +1766,10 @@ window.__ModuleLoader__.load({
|
|
|
809
1766
|
panelAttempts = 0
|
|
810
1767
|
innerAttempts = 0
|
|
811
1768
|
paneOpenedFor = undefined
|
|
1769
|
+
powerActionSent = false
|
|
1770
|
+
powerActionAttempts = 0
|
|
1771
|
+
linkedTyped = false
|
|
1772
|
+
attemptValue(() => host.resetSettingsDrive?.())
|
|
812
1773
|
dropTimer()
|
|
813
1774
|
}
|
|
814
1775
|
/**
|
|
@@ -816,27 +1777,41 @@ window.__ModuleLoader__.load({
|
|
|
816
1777
|
* conversation behind it, because the panel is what fills the screen.
|
|
817
1778
|
*/
|
|
818
1779
|
const canonical = () => {
|
|
1780
|
+
// The power menu is a popover, not a surface: it is on top of everything, it closes
|
|
1781
|
+
// the moment the reader touches anything else, and a link can only ever *open* it.
|
|
1782
|
+
// While it is up it names itself, the same way the settings overlay does.
|
|
1783
|
+
if (powerMenuOpenOf(window.document)) return { kind: 'power', id: '' }
|
|
819
1784
|
// The settings overlay covers the main column, so while it is up it is what is on
|
|
820
1785
|
// screen; when it closes the ordinary surface takes the address bar back.
|
|
821
1786
|
if (settingsOverlayOf(window.document) !== undefined) {
|
|
822
1787
|
const section = host.activeSettingsSection() ?? ''
|
|
823
|
-
const
|
|
824
|
-
|
|
1788
|
+
const levels = section === '' ? [] : (attemptValue(() => host.activeSettingsLevels?.()) ?? [])
|
|
1789
|
+
// A model-provider card whose editor is open is a page of its own: its id rides
|
|
1790
|
+
// after the section (and after the tab, when the section has one).
|
|
1791
|
+
const provider = section === '' ? undefined : host.activeSettingsProvider()
|
|
1792
|
+
const inner = [...levels, ...(provider === undefined ? [] : [PROVIDER_SEGMENT, provider])]
|
|
1793
|
+
return inner.length === 0 ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner }
|
|
825
1794
|
}
|
|
826
1795
|
const panelId = host.activePanelId()
|
|
827
1796
|
if (panelId !== undefined && panelId !== null) {
|
|
828
1797
|
const id = String(panelId)
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
1798
|
+
// The panel's own inner page comes first: a panel that reports a page has a page on
|
|
1799
|
+
// screen that the reader can name, and the right-hand pane is a layer *beside* it.
|
|
1800
|
+
// Naming the pane first is what used to hide the automation task's own link behind
|
|
1801
|
+
// `pane/<kind>` (measured live).
|
|
833
1802
|
const page = host.panelPage(id, panelScope)
|
|
834
|
-
if (page === undefined) return { kind: 'panel', id, inner: undefined }
|
|
835
1803
|
const segments = innerPath(page)
|
|
836
1804
|
// Remember the package a page was opened under, so a bare component page (which
|
|
837
1805
|
// reports only its row) can still be named fully.
|
|
838
1806
|
if (id === PLUGINS_PANEL_ID && segments.length === 1) panelScope = segments[0]
|
|
839
|
-
|
|
1807
|
+
// A panel's own page names what the main column shows, and the right-hand pane is
|
|
1808
|
+
// secondary to it: the reader asked for the task (or the plugin row), not for the
|
|
1809
|
+
// pane standing beside it, so the pane gives way. It is named only on a panel that
|
|
1810
|
+
// has no page of its own. A collapsed bar keeps its tabs mounted, so it never
|
|
1811
|
+
// counts at all (measured: the pane segment outlived its own bar).
|
|
1812
|
+
const pane = segments.length > 0 || rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
|
|
1813
|
+
const inner = pane === undefined ? segments : [PANE_SEGMENT, pane]
|
|
1814
|
+
return inner.length === 0 ? { kind: 'panel', id, inner: undefined } : { kind: 'panel', id, inner }
|
|
840
1815
|
}
|
|
841
1816
|
const session = host.mainViewSessionId()
|
|
842
1817
|
if (session !== undefined && session !== null) {
|
|
@@ -857,7 +1832,14 @@ window.__ModuleLoader__.load({
|
|
|
857
1832
|
// but never while a turn link is in flight: that link owns the address bar until it lands,
|
|
858
1833
|
// and naming the turn here too is what made it look "landed" before it ever drove.
|
|
859
1834
|
const anchoredTurn = linked === undefined && turnAnchor !== undefined && turnAnchor.session === id && turnAnchor.turn !== undefined && turnAnchor.turn !== '' ? [TURN_SEGMENT, turnAnchor.turn] : []
|
|
1835
|
+
// Whose subagent this conversation is (empty for an ordinary session). It sits right
|
|
1836
|
+
// after the id: everything after it is a position *inside* the conversation. The
|
|
1837
|
+
// parent is decoration — landing compares the child id — so it is written as the
|
|
1838
|
+
// parent's *title* when the catalog has one, and as its id otherwise.
|
|
1839
|
+
const subagentOf = host.subagentParentOf(id)
|
|
1840
|
+
const subagentLabel = subagentOf === undefined ? undefined : subagentDecorationOf(subagentOf)
|
|
860
1841
|
const position = [
|
|
1842
|
+
...(subagentLabel === undefined || subagentLabel === '' ? [] : [SUBAGENT_SEGMENT, subagentLabel]),
|
|
861
1843
|
...(rowAnchor !== undefined && rowAnchor.session === id ? (rowAnchor.turn === undefined || rowAnchor.turn === '' ? [ROW_SEGMENT, ...rowAnchor.parts] : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]) : []),
|
|
862
1844
|
...(anchoredTurn.length > 0 && rowAnchor !== undefined && rowAnchor.session === id ? [] : anchoredTurn),
|
|
863
1845
|
...(view !== undefined && view.index !== 0 ? [VIEW_SEGMENT, view.id] : []),
|
|
@@ -865,7 +1847,8 @@ window.__ModuleLoader__.load({
|
|
|
865
1847
|
// The right-hand pane is its own layer, not a position inside the conversation:
|
|
866
1848
|
// it is named next to whatever the main column shows, never instead of it — so it
|
|
867
1849
|
// is read *before* the workspace branch, or the workspace would swallow it.
|
|
868
|
-
|
|
1850
|
+
// A collapsed bar keeps its tabs mounted, so it only counts while it is on screen.
|
|
1851
|
+
const pane = rightBarCollapsedOf(window.document) ? undefined : host.activePaneKind()
|
|
869
1852
|
const tail = pane === undefined ? [] : [PANE_SEGMENT, pane]
|
|
870
1853
|
const workspaceName = host.workspaceNameOf(id)
|
|
871
1854
|
if (workspaceName !== undefined) {
|
|
@@ -895,6 +1878,11 @@ window.__ModuleLoader__.load({
|
|
|
895
1878
|
if (route?.kind === 'session') return route.id
|
|
896
1879
|
return undefined
|
|
897
1880
|
}
|
|
1881
|
+
const subagentParentOfRoute = route => {
|
|
1882
|
+
const parts = innerPath(route?.inner)
|
|
1883
|
+
const at = parts.indexOf(SUBAGENT_SEGMENT)
|
|
1884
|
+
return at < 0 ? undefined : parts[at + 1]
|
|
1885
|
+
}
|
|
898
1886
|
const viewIdOfRoute = route => {
|
|
899
1887
|
const parts = innerPath(route?.inner)
|
|
900
1888
|
const at = parts.indexOf(VIEW_SEGMENT)
|
|
@@ -911,6 +1899,27 @@ window.__ModuleLoader__.load({
|
|
|
911
1899
|
// that is no longer in flight is never "the same route" as anything.
|
|
912
1900
|
if (linked === undefined || linked === null) return false
|
|
913
1901
|
if (routeKey(current) === routeKey(linked)) return true
|
|
1902
|
+
// A panel's page may be *more* specific than the link (`plugins/<pkg>/<row>` for a
|
|
1903
|
+
// `plugins/<pkg>` link), and the right-hand pane is a layer beside that page rather
|
|
1904
|
+
// than part of it — so it is compared only when the link names one. Exact comparison
|
|
1905
|
+
// is what left a link waiting on a page that was already open.
|
|
1906
|
+
if (current?.kind === 'panel' || linked?.kind === 'panel') {
|
|
1907
|
+
if (current?.kind !== linked.kind || current.id !== linked.id) return false
|
|
1908
|
+
const pageOf = route => {
|
|
1909
|
+
const parts = innerPath(route?.inner)
|
|
1910
|
+
const at = parts.indexOf(PANE_SEGMENT)
|
|
1911
|
+
return at < 0 ? parts : parts.slice(0, at)
|
|
1912
|
+
}
|
|
1913
|
+
// A link that names a pane asks for that pane, whatever else it names.
|
|
1914
|
+
const wantedPane = paneOf(linked)
|
|
1915
|
+
if (wantedPane !== undefined && paneOf(current) !== wantedPane) return false
|
|
1916
|
+
const wanted = pageOf(linked)
|
|
1917
|
+
if (wanted.length === 0) return true
|
|
1918
|
+
// A page link is satisfied once that page is on screen; a link may be wider than the
|
|
1919
|
+
// view (an open component satisfies a package link) but never narrower.
|
|
1920
|
+
const shown = pageOf(current)
|
|
1921
|
+
return wanted.length <= shown.length && wanted.every((part, index) => shown[index] === part)
|
|
1922
|
+
}
|
|
914
1923
|
const currentSession = sessionIdOfRoute(current)
|
|
915
1924
|
const linkedSession = sessionIdOfRoute(linked)
|
|
916
1925
|
if (currentSession !== undefined && linkedSession !== undefined) {
|
|
@@ -954,8 +1963,15 @@ window.__ModuleLoader__.load({
|
|
|
954
1963
|
if (current?.kind !== 'settings' || linked?.kind !== 'settings') return false
|
|
955
1964
|
if (linked.id === '') return true
|
|
956
1965
|
if (current.id !== linked.id) return false
|
|
957
|
-
const
|
|
958
|
-
|
|
1966
|
+
const wanted = settingsPartsOf(linked.inner)
|
|
1967
|
+
const shown = settingsPartsOf(current.inner)
|
|
1968
|
+
// Every level a link names has to be showing; extra levels on screen are a refinement
|
|
1969
|
+
// of it (the same prefix rule the panel pages use, one row deeper each time).
|
|
1970
|
+
if (wanted.levels.length > shown.levels.length) return false
|
|
1971
|
+
if (!wanted.levels.every((level, index) => level === shown.levels[index])) return false
|
|
1972
|
+
// A provider card is a page one level deeper than its section: a link that names one
|
|
1973
|
+
// has to see that one open (the section alone is satisfied by any of its cards).
|
|
1974
|
+
return wanted.provider === undefined || wanted.provider === shown.provider
|
|
959
1975
|
}
|
|
960
1976
|
/** Write the fragment for the current view, leaving path and query untouched. */
|
|
961
1977
|
const reflect = route => {
|
|
@@ -988,6 +2004,7 @@ window.__ModuleLoader__.load({
|
|
|
988
2004
|
*/
|
|
989
2005
|
const reconcile = (source = 'boot') => {
|
|
990
2006
|
if (disposed) return
|
|
2007
|
+
workCounts.reconciles += 1
|
|
991
2008
|
|
|
992
2009
|
const current = canonical()
|
|
993
2010
|
const currentKey = routeKey(current)
|
|
@@ -1000,6 +2017,22 @@ window.__ModuleLoader__.load({
|
|
|
1000
2017
|
if (paneTabs > 0 && host.activePaneKind() === undefined) {
|
|
1001
2018
|
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
2019
|
}
|
|
2020
|
+
// Say each compatibility problem once, before anything else in this step: a screen whose
|
|
2021
|
+
// hooks moved does not throw, it just stops being nameable.
|
|
2022
|
+
const sweepAt = host.now()
|
|
2023
|
+
if (sweepAt - lastCompatSweep >= COMPAT_EVERY_MS) {
|
|
2024
|
+
lastCompatSweep = sweepAt
|
|
2025
|
+
const problems = compatibilityProblems(window.document, {
|
|
2026
|
+
activePanelId: host.activePanelId(),
|
|
2027
|
+
sessions: attemptValue(() => host.listedSessions?.()) ?? 0,
|
|
2028
|
+
views: attemptValue(() => host.conversationViewIds?.())?.length ?? 0,
|
|
2029
|
+
})
|
|
2030
|
+
for (const problem of problems) {
|
|
2031
|
+
if (observed.compat.includes(problem.name)) continue
|
|
2032
|
+
observed.compat.push(problem.name)
|
|
2033
|
+
host.warn(`${TAG} ${COMPAT_MESSAGE}:${problem.name} —— 期望 ${problem.expected},实际 ${problem.found}。这一层的链接会静默失效,请升级 DSH 或本插件`)
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
1003
2036
|
// A position the previous step asked for: it is driven here, one step later, so the step
|
|
1004
2037
|
// that claimed it never drives while it is still running.
|
|
1005
2038
|
if (linked === undefined) {
|
|
@@ -1010,14 +2043,26 @@ window.__ModuleLoader__.load({
|
|
|
1010
2043
|
// (and re-entry can drop the link), so every read below must use the snapshot: reading the
|
|
1011
2044
|
// live binding again is how a drive turned into "Cannot read properties of undefined".
|
|
1012
2045
|
const link = linked
|
|
2046
|
+
// The parent a subagent link names (decoration: the child alone is what opens).
|
|
2047
|
+
const linkParent = subagentParentOfRoute(link)
|
|
1013
2048
|
|
|
1014
2049
|
if (sameRoute(current, link)) {
|
|
1015
2050
|
dropRetry()
|
|
1016
2051
|
// The link has landed: it stops being authoritative right here, so the user
|
|
1017
2052
|
// keeps owning the address bar afterwards (walking deeper inside a panel
|
|
1018
2053
|
// updates the URL instead of being pulled back to the link).
|
|
2054
|
+
//
|
|
2055
|
+
// A turn link that was already satisfied (its seat is on screen) still names where
|
|
2056
|
+
// the reader is, so it becomes the anchor: writing the plain view instead is how a
|
|
2057
|
+
// pasted `turn/<n>` link used to lose its turn the instant it landed.
|
|
2058
|
+
const parts = innerPath(link.inner)
|
|
2059
|
+
const at = parts.indexOf(TURN_SEGMENT)
|
|
2060
|
+
if (at >= 0 && parts[at + 1] !== undefined && parts[at + 1] !== '') {
|
|
2061
|
+
const session = link.kind === 'workspace' ? parts[1] : link.id
|
|
2062
|
+
turnAnchor = { session, turn: parts[at + 1] }
|
|
2063
|
+
}
|
|
1019
2064
|
dropLink()
|
|
1020
|
-
reflect(
|
|
2065
|
+
reflect(canonical())
|
|
1021
2066
|
return
|
|
1022
2067
|
}
|
|
1023
2068
|
if (host.now() >= link.deadline) {
|
|
@@ -1060,7 +2105,7 @@ window.__ModuleLoader__.load({
|
|
|
1060
2105
|
const shown = host.mainViewSessionId()
|
|
1061
2106
|
if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1062
2107
|
panelAttempts += 1
|
|
1063
|
-
host.openSession(rowSession)
|
|
2108
|
+
host.openSession(rowSession, linkParent)
|
|
1064
2109
|
} else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1065
2110
|
innerAttempts += 1
|
|
1066
2111
|
if (!host.revealPosition(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealPosition(rowParts.slice(wantedTurn, wantedTurn + 2), rowSession)
|
|
@@ -1076,7 +2121,7 @@ window.__ModuleLoader__.load({
|
|
|
1076
2121
|
const shown = host.mainViewSessionId()
|
|
1077
2122
|
if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1078
2123
|
panelAttempts += 1
|
|
1079
|
-
host.openSession(wantedSession)
|
|
2124
|
+
host.openSession(wantedSession, linkParent)
|
|
1080
2125
|
} else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1081
2126
|
innerAttempts += 1
|
|
1082
2127
|
host.openConversationView(wantedView)
|
|
@@ -1091,7 +2136,88 @@ window.__ModuleLoader__.load({
|
|
|
1091
2136
|
const shown = host.mainViewSessionId()
|
|
1092
2137
|
if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1093
2138
|
panelAttempts += 1
|
|
1094
|
-
host.openSession(target)
|
|
2139
|
+
host.openSession(target, linkParent)
|
|
2140
|
+
}
|
|
2141
|
+
armRetry()
|
|
2142
|
+
return
|
|
2143
|
+
}
|
|
2144
|
+
if (link.kind === 'power') {
|
|
2145
|
+
// `#/power/shutdown` and `#/power/restart` are commands, and they are the one route
|
|
2146
|
+
// that must not be reachable by a link somebody else wrote: a URL is easy to click
|
|
2147
|
+
// by accident, and this one ends the application. So the action runs only for a
|
|
2148
|
+
// fragment the reader typed into the address bar (`typedClaim`), exactly once per
|
|
2149
|
+
// claim — and a hash read lowers the flag, which is what makes a link in a chat
|
|
2150
|
+
// message, a README or an AI reply harmless.
|
|
2151
|
+
const action = powerActionOf(link.id)
|
|
2152
|
+
if (action !== undefined) {
|
|
2153
|
+
if (!linkedTyped || powerActionSent) {
|
|
2154
|
+
// This branch is the one that used to answer a typed command with nothing at
|
|
2155
|
+
// all, and it left no trace: a desktop window has no console, so say it in
|
|
2156
|
+
// the report instead of guessing next time.
|
|
2157
|
+
if (!linkedTyped && !powerIgnored) {
|
|
2158
|
+
powerIgnored = true
|
|
2159
|
+
note(`${TAG} ignoring power action "${action}": this fragment did not come from the address bar`)
|
|
2160
|
+
}
|
|
2161
|
+
armRetry()
|
|
2162
|
+
return
|
|
2163
|
+
}
|
|
2164
|
+
// Refuse only when the address bar names a *different* power action: that is the
|
|
2165
|
+
// claim having gone stale, and running it would fire the wrong command (a shutdown
|
|
2166
|
+
// fired by a link that asked for a restart is not something to be clever about).
|
|
2167
|
+
//
|
|
2168
|
+
// It must not refuse merely because the bar does not name *this* action: the router
|
|
2169
|
+
// itself rewrites the bar to the canonical of what is on screen, and opening the
|
|
2170
|
+
// menu makes that canonical `#/power` — so a stricter rule refused our own first
|
|
2171
|
+
// drive step, and the reader had to press Enter twice (measured 2026-10-29).
|
|
2172
|
+
const typed = parseRoute(String(host.readLocation().hash ?? ''))
|
|
2173
|
+
const namesOtherAction = typed !== undefined && typed.kind === 'power' && typed.id !== '' && typed.id !== link.id
|
|
2174
|
+
if (namesOtherAction) {
|
|
2175
|
+
note(`${TAG} refusing power action "${action}": the address bar names "${String(typed.id)}"`)
|
|
2176
|
+
powerActionSent = true
|
|
2177
|
+
armRetry()
|
|
2178
|
+
return
|
|
2179
|
+
}
|
|
2180
|
+
// The plugin's own path first: its menu, then the item it publishes for this
|
|
2181
|
+
// action — that way its own flow runs (its overlay, and any confirmation it may
|
|
2182
|
+
// add later). The endpoint is the fallback for a menu that never yields the
|
|
2183
|
+
// item, so a command can never end in a silent no-op.
|
|
2184
|
+
if (!powerMenuOpenOf(window.document)) {
|
|
2185
|
+
if (powerActionAttempts < POWER_ACTION_MENU_LIMIT) {
|
|
2186
|
+
powerActionAttempts += 1
|
|
2187
|
+
innerAttempts += 1
|
|
2188
|
+
host.openPowerMenu()
|
|
2189
|
+
// The focus is deliberately left alone: the plugin keeps it on its own
|
|
2190
|
+
// trigger and hangs its arrow-key navigation there (fixed 2026-10-29 by
|
|
2191
|
+
// removing its `first.focus()`), so stealing it back would kill that.
|
|
2192
|
+
} else {
|
|
2193
|
+
note(`${TAG} running power action "${action}" through the plugin endpoint (its menu published no item)`)
|
|
2194
|
+
powerActionSent = true
|
|
2195
|
+
host.powerAction(action)
|
|
2196
|
+
}
|
|
2197
|
+
armRetry()
|
|
2198
|
+
return
|
|
2199
|
+
}
|
|
2200
|
+
if (host.clickPowerItem(action)) {
|
|
2201
|
+
note(`${TAG} running power action "${action}" through the plugin's own menu item`)
|
|
2202
|
+
powerActionSent = true
|
|
2203
|
+
armRetry()
|
|
2204
|
+
return
|
|
2205
|
+
}
|
|
2206
|
+
powerActionAttempts += 1
|
|
2207
|
+
if (powerActionAttempts >= POWER_ACTION_MENU_LIMIT) {
|
|
2208
|
+
note(`${TAG} running power action "${action}" through the plugin endpoint (no menu item published it)`)
|
|
2209
|
+
powerActionSent = true
|
|
2210
|
+
host.powerAction(action)
|
|
2211
|
+
}
|
|
2212
|
+
armRetry()
|
|
2213
|
+
return
|
|
2214
|
+
}
|
|
2215
|
+
// A bare `#/power` only opens the menu. It is a toggle: it is clicked only while it
|
|
2216
|
+
// is closed, so a retry can never open it and close it again. The items inside it
|
|
2217
|
+
// are never clicked — opening a menu is a place, acting on it is a person's call.
|
|
2218
|
+
if (innerAttempts < INNER_ATTEMPT_LIMIT && !powerMenuOpenOf(window.document)) {
|
|
2219
|
+
innerAttempts += 1
|
|
2220
|
+
host.openPowerMenu()
|
|
1095
2221
|
}
|
|
1096
2222
|
armRetry()
|
|
1097
2223
|
return
|
|
@@ -1099,7 +2225,8 @@ window.__ModuleLoader__.load({
|
|
|
1099
2225
|
if (link.kind === 'settings') {
|
|
1100
2226
|
if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1101
2227
|
innerAttempts += 1
|
|
1102
|
-
|
|
2228
|
+
const parts = settingsPartsOf(link.inner)
|
|
2229
|
+
host.openSettings(link.id, parts.levels, parts.provider)
|
|
1103
2230
|
}
|
|
1104
2231
|
armRetry()
|
|
1105
2232
|
return
|
|
@@ -1143,7 +2270,7 @@ window.__ModuleLoader__.load({
|
|
|
1143
2270
|
// idempotent on a conversation that is not shown yet (no seats, nothing to scroll to, and
|
|
1144
2271
|
// the paging path is a no-op too), and waiting for the main-view counter to catch up is
|
|
1145
2272
|
// what left a cold-opened turn link doing nothing at all.
|
|
1146
|
-
host.openSession(wantedSession)
|
|
2273
|
+
host.openSession(wantedSession, linkParent)
|
|
1147
2274
|
host.revealPosition(innerPath([TURN_SEGMENT, wantedTurn]), wantedSession)
|
|
1148
2275
|
// A row may follow the turn (`turn/<n>/row/…`): reveal it after the turn.
|
|
1149
2276
|
const rowAt = innerPath(link.inner).indexOf(ROW_SEGMENT)
|
|
@@ -1190,7 +2317,16 @@ window.__ModuleLoader__.load({
|
|
|
1190
2317
|
// with a bound so an unopenable page cannot turn into a click loop.
|
|
1191
2318
|
if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1192
2319
|
innerAttempts += 1
|
|
1193
|
-
|
|
2320
|
+
// A pane tail names the right bar, not the panel's page: handing it to the
|
|
2321
|
+
// panel would ask that panel to open a page called `files` (measured on a
|
|
2322
|
+
// link the router itself had written while both were open).
|
|
2323
|
+
const parts = innerPath(link.inner)
|
|
2324
|
+
const paneAt = parts.indexOf(PANE_SEGMENT)
|
|
2325
|
+
const wantedPane = paneAt < 0 ? undefined : parts[paneAt + 1]
|
|
2326
|
+
host.openPanelPage(link.id, paneAt < 0 ? parts : parts.slice(0, paneAt))
|
|
2327
|
+
if (wantedPane !== undefined && host.activePaneKind() !== wantedPane) {
|
|
2328
|
+
host.openPane(wantedPane, 'panel', message => host.warn(message))
|
|
2329
|
+
}
|
|
1194
2330
|
}
|
|
1195
2331
|
armRetry()
|
|
1196
2332
|
return
|
|
@@ -1205,7 +2341,7 @@ window.__ModuleLoader__.load({
|
|
|
1205
2341
|
}
|
|
1206
2342
|
if ((!claimed || moved) && host.isOpenable(link.id)) {
|
|
1207
2343
|
claimed = true
|
|
1208
|
-
host.openSession(link.id)
|
|
2344
|
+
host.openSession(link.id, linkParent)
|
|
1209
2345
|
}
|
|
1210
2346
|
}
|
|
1211
2347
|
/** Adopt the surface the address bar names, if it names one. */
|
|
@@ -1239,6 +2375,7 @@ window.__ModuleLoader__.load({
|
|
|
1239
2375
|
return
|
|
1240
2376
|
}
|
|
1241
2377
|
if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
|
|
2378
|
+
linkedTyped = typedClaim
|
|
1242
2379
|
linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
|
|
1243
2380
|
if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
|
|
1244
2381
|
claimed = false
|
|
@@ -1291,7 +2428,43 @@ window.__ModuleLoader__.load({
|
|
|
1291
2428
|
retryTimer?.()
|
|
1292
2429
|
retryTimer = undefined
|
|
1293
2430
|
}
|
|
2431
|
+
// A power action is honoured only for a fragment the reader typed themselves: the flag is
|
|
2432
|
+
// raised by the address bar's own hand-off and lowered by every browser-driven hash read.
|
|
2433
|
+
let typedClaim = false
|
|
2434
|
+
/**
|
|
2435
|
+
* The fragment the reader last drove by hand.
|
|
2436
|
+
*
|
|
2437
|
+
* Writing the hash makes the browser deliver a `hashchange` of our own write, one task
|
|
2438
|
+
* later. That echo arrives after the link has already been dropped (a command lands
|
|
2439
|
+
* immediately, by definition), so it would create a *fresh* claim that is not typed —
|
|
2440
|
+
* and the command the reader typed was then refused, silently, until they pressed Enter
|
|
2441
|
+
* a second time (measured 2026-10-29). Recognising the echo keeps the claim theirs.
|
|
2442
|
+
*/
|
|
2443
|
+
let typedEcho = ''
|
|
2444
|
+
/** Whether this claim already said out loud that it was not the reader's. */
|
|
2445
|
+
let powerIgnored = false
|
|
2446
|
+
// Whether *the claim in flight* came from the address bar's own hand-off. The flag above
|
|
2447
|
+
// only survives one read; a command needs its authority for the whole claim, because its
|
|
2448
|
+
// drive retries (that is how the plugin's menu gets its budget).
|
|
2449
|
+
let linkedTyped = false
|
|
2450
|
+
// How many steps the plugin's own menu has had for this claim, before the endpoint takes over.
|
|
2451
|
+
let powerActionAttempts = 0
|
|
2452
|
+
// One action per claim: a POST that is retried because the app is slow to die would
|
|
2453
|
+
// otherwise fire twice.
|
|
2454
|
+
let powerActionSent = false
|
|
1294
2455
|
const safeReadLink = () => guard(readLink)
|
|
2456
|
+
/**
|
|
2457
|
+
* Record a line about a command the reader typed.
|
|
2458
|
+
*
|
|
2459
|
+
* A desktop window has no console, so `host.warn` alone leaves no trace anyone can read:
|
|
2460
|
+
* the observation report is where a person can see what a destructive command actually
|
|
2461
|
+
* did. Both get the line.
|
|
2462
|
+
* @param {string} message - the line.
|
|
2463
|
+
*/
|
|
2464
|
+
const note = message => {
|
|
2465
|
+
if (!observed.warnings.includes(message)) observed.warnings.push(message)
|
|
2466
|
+
host.warn(message)
|
|
2467
|
+
}
|
|
1295
2468
|
// The desktop address bar hands its fragment over here instead of relying on the
|
|
1296
2469
|
// browser: a programmatic hash write fires `hashchange` in a browser, but a hash
|
|
1297
2470
|
// equal to the current one fires nothing at all, and the desktop shell serves the
|
|
@@ -1299,15 +2472,34 @@ window.__ModuleLoader__.load({
|
|
|
1299
2472
|
// listener uses makes the bar work in both cases. The hash is still written, so the
|
|
1300
2473
|
// address bar, Back/Forward and copied links all agree with what is on screen.
|
|
1301
2474
|
driveTypedFragment = fragment => {
|
|
2475
|
+
typedClaim = true
|
|
2476
|
+
typedEcho = fragment
|
|
2477
|
+
// A fresh command gets the full menu budget, however many times the reader pastes it.
|
|
2478
|
+
powerActionSent = false
|
|
2479
|
+
powerActionAttempts = 0
|
|
2480
|
+
powerIgnored = false
|
|
2481
|
+
// And it re-arms the claim it names. Without this, a link that had *already* arrived
|
|
2482
|
+
// some other way (a chat message the reader clicked) keeps the claim it made, and the
|
|
2483
|
+
// same fragment typed afterwards found `linked.id === parsed.id`, made no new claim,
|
|
2484
|
+
// and stayed refused — a command the reader typed was answered with nothing at all
|
|
2485
|
+
// (measured 2026-10-29).
|
|
2486
|
+
if (linked !== undefined) linkedTyped = true
|
|
1302
2487
|
attemptValue(() => {
|
|
1303
2488
|
window.location.hash = fragment
|
|
1304
2489
|
})
|
|
1305
2490
|
safeReadLink()
|
|
2491
|
+
typedClaim = false
|
|
1306
2492
|
}
|
|
1307
2493
|
const offPanel = host.onPanelChange(() => safeReconcile('panel'))
|
|
1308
2494
|
const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
|
|
1309
2495
|
const offDom = host.onDomChange(() => safeReconcile('dom'))
|
|
1310
|
-
const offHashChange = host.onHashChange(() =>
|
|
2496
|
+
const offHashChange = host.onHashChange(() => {
|
|
2497
|
+
// A hash read is never a typed link: whoever wrote the fragment (a chat message, a
|
|
2498
|
+
// page, a plugin) does not get to run a command — with one exception, the echo of the
|
|
2499
|
+
// reader's own write, which is what makes a command work on the first Enter.
|
|
2500
|
+
typedClaim = readableFragment(String(attemptValue(() => window.location.hash) ?? '')) === readableFragment(typedEcho)
|
|
2501
|
+
safeReadLink()
|
|
2502
|
+
})
|
|
1311
2503
|
safeReadLink()
|
|
1312
2504
|
// Pointing at a turn records a position, never a navigation: the listener is passive
|
|
1313
2505
|
// and it refuses to act on any interactive element, so clicks keep working exactly as
|
|
@@ -1393,6 +2585,14 @@ window.__ModuleLoader__.load({
|
|
|
1393
2585
|
paneActive: attemptValue(() => host.activePaneKind()) ?? null,
|
|
1394
2586
|
paneKinds: attemptValue(() => host.paneKinds?.()) ?? null,
|
|
1395
2587
|
},
|
|
2588
|
+
// The address bar's own preferences, so a bug report can say what the bar
|
|
2589
|
+
// was told to do without opening the settings page.
|
|
2590
|
+
preferences: {
|
|
2591
|
+
selectAllOnFocus: readSettings().selectAllOnFocus,
|
|
2592
|
+
recentLimit: readSettings().recentLimit,
|
|
2593
|
+
remembered: recentEntries().length,
|
|
2594
|
+
},
|
|
2595
|
+
work: { reconciles: workCounts.reconciles, placements: workCounts.placements },
|
|
1396
2596
|
compat: observed.compat,
|
|
1397
2597
|
foreignFragment: observed.foreignFragment,
|
|
1398
2598
|
ignoredKinds: observed.ignored,
|
|
@@ -1865,7 +3065,15 @@ window.__ModuleLoader__.load({
|
|
|
1865
3065
|
faces: {
|
|
1866
3066
|
catalog: list,
|
|
1867
3067
|
archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
|
|
1868
|
-
openSession: id =>
|
|
3068
|
+
openSession: (id, parent) => {
|
|
3069
|
+
if (parent === undefined || parent === null || parent === '') return openSession.call(navigation, id)
|
|
3070
|
+
// A subagent view is opened by its address (parent + child + mode), not by the
|
|
3071
|
+
// plain session id: that is the shape `uiWorkspace.openSession` declares
|
|
3072
|
+
// (`SessionTarget = SessionId | SubagentAddress`). The mode is durable in the
|
|
3073
|
+
// catalog, so it is read back rather than guessed.
|
|
3074
|
+
const mode = attemptValue(() => sessions?.subagentAddress?.(id)?.mode) ?? 'unknown'
|
|
3075
|
+
return openSession.call(navigation, { parentSessionId: parent, childSessionId: id, mode })
|
|
3076
|
+
},
|
|
1869
3077
|
selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
|
|
1870
3078
|
beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
|
|
1871
3079
|
openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
|
|
@@ -1896,12 +3104,27 @@ window.__ModuleLoader__.load({
|
|
|
1896
3104
|
return fallback
|
|
1897
3105
|
}
|
|
1898
3106
|
}
|
|
3107
|
+
/**
|
|
3108
|
+
* How many times this link has pressed the settings launcher.
|
|
3109
|
+
*
|
|
3110
|
+
* The launcher is a *toggle*, so it may only be pressed while no menu is on screen: a
|
|
3111
|
+
* press that opened nothing may be retried (bounded), a press that opened something must
|
|
3112
|
+
* never be repeated — that would close it again. Reset with the claim, never per retry.
|
|
3113
|
+
*/
|
|
3114
|
+
let settingsLauncherPresses = 0
|
|
1899
3115
|
const archived = () => {
|
|
1900
3116
|
const ids = attempt(() => faces.archivedSessionIds(), [])
|
|
1901
3117
|
return Array.isArray(ids) ? ids : []
|
|
1902
3118
|
}
|
|
1903
3119
|
const host = {
|
|
1904
3120
|
mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
|
|
3121
|
+
// How many conversations the catalog lists: the compatibility check uses it to tell
|
|
3122
|
+
// "an empty sidebar" from "a sidebar whose rows are no longer where they were".
|
|
3123
|
+
listedSessions: () => attemptValue(() => {
|
|
3124
|
+
const snapshot = faces.catalog.getSnapshot()
|
|
3125
|
+
if (Array.isArray(snapshot?.ids)) return snapshot.ids.length
|
|
3126
|
+
return Object.keys(snapshot?.byId ?? {}).length
|
|
3127
|
+
}, 0),
|
|
1905
3128
|
// The sidebar lists workspaces and their sessions in one tree, in order, so the
|
|
1906
3129
|
// workspace a conversation belongs to is the nearest workspace row before it.
|
|
1907
3130
|
workspaceNameOf: sessionId => {
|
|
@@ -1926,6 +3149,19 @@ window.__ModuleLoader__.load({
|
|
|
1926
3149
|
// The inner page a panel is showing, in that panel's own vocabulary. Each panel
|
|
1927
3150
|
// publishes it with a data attribute; reading it is the whole reflect direction.
|
|
1928
3151
|
panelPage: (panelId, scope) => {
|
|
3152
|
+
if (panelId === SCHEDULES_PANEL_ID) {
|
|
3153
|
+
// Two dimensions, in one path: the status filter the list shows (the default
|
|
3154
|
+
// `all` is not written) and the task whose detail is open (with its tab).
|
|
3155
|
+
const segments = []
|
|
3156
|
+
const filter = scheduleFilterOf(window.document)
|
|
3157
|
+
if (filter !== undefined && filter !== SCHEDULE_FILTERS[0]) segments.push(FILTER_SEGMENT, filter)
|
|
3158
|
+
const task = scheduleTaskOf(window.document)
|
|
3159
|
+
if (task !== undefined) {
|
|
3160
|
+
segments.push(TASK_SEGMENT, task.id)
|
|
3161
|
+
if (task.tab !== 'rule') segments.push(task.tab)
|
|
3162
|
+
}
|
|
3163
|
+
return segments.length === 0 ? undefined : segments
|
|
3164
|
+
}
|
|
1929
3165
|
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
1930
3166
|
// The form and a task are mutually exclusive, and the form wins when both
|
|
1931
3167
|
// would be reported (it is the page the user is looking at).
|
|
@@ -1949,6 +3185,15 @@ window.__ModuleLoader__.load({
|
|
|
1949
3185
|
},
|
|
1950
3186
|
openPanelPage: (panelId, inner) => {
|
|
1951
3187
|
const asked = innerPath(inner)
|
|
3188
|
+
if (panelId === SCHEDULES_PANEL_ID) {
|
|
3189
|
+
// Oldest first: the filter decides which rows the list holds, so it is pressed
|
|
3190
|
+
// before a task in that list is asked for.
|
|
3191
|
+
const filterAt = asked.indexOf(FILTER_SEGMENT)
|
|
3192
|
+
if (filterAt >= 0) clickScheduleFilter(asked[filterAt + 1])
|
|
3193
|
+
const taskAt = asked.indexOf(TASK_SEGMENT)
|
|
3194
|
+
if (taskAt >= 0 && asked[taskAt + 1] !== undefined) clickScheduleTask(asked[taskAt + 1], asked[taskAt + 2])
|
|
3195
|
+
return
|
|
3196
|
+
}
|
|
1952
3197
|
if (panelId === TASK_BOARD_PANEL_ID) {
|
|
1953
3198
|
// The task board owns how its overlays open: we hand over the request on its
|
|
1954
3199
|
// own contract (window events) and nothing else crosses the seam.
|
|
@@ -1974,8 +3219,9 @@ window.__ModuleLoader__.load({
|
|
|
1974
3219
|
const pkg =
|
|
1975
3220
|
window.document?.querySelector?.(`li[data-plugin-package="${segments[0]}"]`) ??
|
|
1976
3221
|
window.document?.querySelector?.(`li[data-plugin-item="${segments[0]}"]`)
|
|
1977
|
-
|
|
1978
|
-
|
|
3222
|
+
// Never the enable switch: a link opens a page, it never changes a plugin.
|
|
3223
|
+
const button = openControlOf(pkg)
|
|
3224
|
+
if (button !== undefined) button.click()
|
|
1979
3225
|
if (segments.length === 1) return
|
|
1980
3226
|
}
|
|
1981
3227
|
const rowId = segments[segments.length - 1]
|
|
@@ -1983,8 +3229,8 @@ window.__ModuleLoader__.load({
|
|
|
1983
3229
|
for (const row of rows) {
|
|
1984
3230
|
const parsed = splitRowHook(typeof row.getAttribute === 'function' ? row.getAttribute('data-plugin-row') : null)
|
|
1985
3231
|
if (parsed === undefined || parsed.rowId !== rowId) continue
|
|
1986
|
-
const button = row
|
|
1987
|
-
if (
|
|
3232
|
+
const button = openControlOf(row)
|
|
3233
|
+
if (button !== undefined) button.click()
|
|
1988
3234
|
return
|
|
1989
3235
|
}
|
|
1990
3236
|
},
|
|
@@ -1999,9 +3245,20 @@ window.__ModuleLoader__.load({
|
|
|
1999
3245
|
// A panel id nobody registered is refused by the layout by THROWING
|
|
2000
3246
|
// (`selectPanel: main panel "x" is not registered`), so the claim is
|
|
2001
3247
|
// swallowed here and reported as "the view did not move" at expiry.
|
|
2002
|
-
openSession: id => {
|
|
2003
|
-
|
|
3248
|
+
openSession: (id, parent) => {
|
|
3249
|
+
// A parent segment that is a title (not an id) is a label: the child still opens by
|
|
3250
|
+
// its own id, which is the identity the route carries anyway.
|
|
3251
|
+
attempt(() => faces.openSession(id, isSessionIdLike(parent) ? parent : undefined))
|
|
2004
3252
|
},
|
|
3253
|
+
// The parent a subagent session belongs to, from the catalog (no session opened), with the
|
|
3254
|
+
// title the catalog keeps for it — the decoration reads better than a uuid.
|
|
3255
|
+
subagentParentOf: id => attemptValue(() => {
|
|
3256
|
+
const snapshot = faces.catalog.getSnapshot()
|
|
3257
|
+
const parentId = snapshot?.byId?.[id]?.parentSessionId ?? faces.sessions?.subagentAddress?.(id)?.parentSessionId
|
|
3258
|
+
if (parentId === undefined || parentId === null || parentId === '') return undefined
|
|
3259
|
+
const title = snapshot?.byId?.[parentId]?.title
|
|
3260
|
+
return { id: String(parentId), title: typeof title === 'string' ? title : '' }
|
|
3261
|
+
}, undefined),
|
|
2005
3262
|
// A row's own identity, in the shape the trajectory views publish it. The value is a
|
|
2006
3263
|
// percent-encoded NUL-separated triple, so the route can read as a plain path.
|
|
2007
3264
|
rowIdentityOf: element => {
|
|
@@ -2056,6 +3313,7 @@ window.__ModuleLoader__.load({
|
|
|
2056
3313
|
revealPosition: (parts, wantedSession) => {
|
|
2057
3314
|
const wanted = innerPath(parts)
|
|
2058
3315
|
const document = window.document
|
|
3316
|
+
|
|
2059
3317
|
if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
|
|
2060
3318
|
const key = wanted.slice(1).join('%00')
|
|
2061
3319
|
for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_ROW_ATTRIBUTE}]`) ?? []) {
|
|
@@ -2088,16 +3346,46 @@ window.__ModuleLoader__.load({
|
|
|
2088
3346
|
// from the catalog here is how this step used to look up "no session".
|
|
2089
3347
|
const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
|
|
2090
3348
|
const session = attemptValue(() => reference?.binding?.session)
|
|
2091
|
-
const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
|
|
2092
3349
|
const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
|
|
2093
3350
|
const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
|
|
2094
|
-
|
|
3351
|
+
// The loader is called **on the session**, never through a detached reference:
|
|
3352
|
+
// `loadThrough` is a prototype method in this build and reads `this.openState`, so
|
|
3353
|
+
// destructuring it (`const load = session.loadThrough`) throws on every call.
|
|
3354
|
+
const usable = typeof session?.loadThrough === 'function' && typeof reference?.release === 'function'
|
|
2095
3355
|
if (typeof seq !== 'number' || !usable) {
|
|
2096
3356
|
attemptValue(() => reference?.release?.())
|
|
2097
3357
|
return false
|
|
2098
3358
|
}
|
|
2099
|
-
|
|
3359
|
+
// The loader resolves when the pages are in, but the chat renders them on its own
|
|
3360
|
+
// commit — so the seat shows up a frame or two later, and until something scrolls
|
|
3361
|
+
// to it the virtual list simply trims the freshly loaded turns again. The official
|
|
3362
|
+
// chat does the same two steps (`loadThrough(seq)` then `scrollToTurn(turn)`); this
|
|
3363
|
+
// is the scroll half, without a handle on its navigation object.
|
|
3364
|
+
const scrollToSeat = () => {
|
|
3365
|
+
const seat = attemptValue(() => document?.querySelector?.(`[${TURN_ATTRIBUTE}="${turn}"]`))
|
|
3366
|
+
if (seat === null || seat === undefined) return false
|
|
3367
|
+
attempt(() => seat.scrollIntoView?.({ block: 'center' }))
|
|
3368
|
+
return true
|
|
3369
|
+
}
|
|
3370
|
+
const pending = attemptValue(() => session.loadThrough(seq))
|
|
2100
3371
|
attemptValue(() => reference.release())
|
|
3372
|
+
if (pending !== undefined && pending !== null && typeof pending.then === 'function') {
|
|
3373
|
+
pending.then(
|
|
3374
|
+
() => {
|
|
3375
|
+
// A few frames of patience: the seat is rendered by the next commit.
|
|
3376
|
+
let tries = 0
|
|
3377
|
+
const tick = () => {
|
|
3378
|
+
tries += 1
|
|
3379
|
+
if (tries > 25 || scrollToSeat()) return
|
|
3380
|
+
attemptValue(() => window.setTimeout?.(tick, 50))
|
|
3381
|
+
}
|
|
3382
|
+
tick()
|
|
3383
|
+
},
|
|
3384
|
+
() => {},
|
|
3385
|
+
)
|
|
3386
|
+
} else {
|
|
3387
|
+
scrollToSeat()
|
|
3388
|
+
}
|
|
2101
3389
|
return true
|
|
2102
3390
|
},
|
|
2103
3391
|
// Which conversation view is showing. The registry is the source of truth for ids
|
|
@@ -2117,18 +3405,39 @@ window.__ModuleLoader__.load({
|
|
|
2117
3405
|
break
|
|
2118
3406
|
}
|
|
2119
3407
|
if (index < 0) return undefined
|
|
3408
|
+
const selected = tabs[index]
|
|
3409
|
+
// The header prints one label per visible tab, and the slot says which id carries
|
|
3410
|
+
// it — that pair survives the "…" overflow, which hides tabs from the DOM while
|
|
3411
|
+
// their views still exist (positional mapping cannot see those at all).
|
|
3412
|
+
const text = typeof selected?.textContent === 'string' ? selected.textContent.trim() : ''
|
|
3413
|
+
const labelled = conversationViewLabelsOf(faces.slots)
|
|
3414
|
+
if (text !== '') {
|
|
3415
|
+
const match = labelled.filter(entry => entry.label === text)
|
|
3416
|
+
if (match.length === 1) {
|
|
3417
|
+
const at = labelled.indexOf(match[0])
|
|
3418
|
+
return { id: match[0].id, index: at, total: labelled.length }
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
// No label pair (a build with no slot labels): fall back to registration order,
|
|
3422
|
+
// and only while every view is rendered — a wrong id is worse than none.
|
|
2120
3423
|
const ids = conversationViewIdsOf(faces)
|
|
2121
3424
|
if (ids === undefined || ids.length !== tabs.length) return undefined
|
|
2122
3425
|
return { id: ids[index], index, total: ids.length }
|
|
2123
3426
|
},
|
|
2124
3427
|
openConversationView: viewId => {
|
|
2125
3428
|
const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
|
|
2126
|
-
const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
|
|
2127
|
-
if (tabs === undefined || tabs ===
|
|
3429
|
+
const tabs = typeof row?.querySelectorAll === 'function' ? [...row.querySelectorAll('[role="tab"]')] : undefined
|
|
3430
|
+
if (tabs === undefined || tabs.length === 0) return
|
|
3431
|
+
// By label first, for the same reason the reflect side does: the tab's text is
|
|
3432
|
+
// what the reader picks, and the slot says which id sits behind it.
|
|
3433
|
+
const labelled = conversationViewLabelsOf(faces.slots)
|
|
3434
|
+
const entry = labelled.find(item => item.id === viewId)
|
|
3435
|
+
const byLabel = entry === undefined || entry.label === ''
|
|
3436
|
+
? undefined
|
|
3437
|
+
: tabs.find(tab => (typeof tab.textContent === 'string' ? tab.textContent.trim() : '') === entry.label)
|
|
2128
3438
|
const ids = conversationViewIdsOf(faces)
|
|
2129
|
-
|
|
2130
|
-
const
|
|
2131
|
-
const tab = index < 0 ? undefined : tabs[index]
|
|
3439
|
+
const index = ids === undefined ? -1 : ids.indexOf(viewId)
|
|
3440
|
+
const tab = byLabel ?? (index < 0 ? undefined : tabs[index])
|
|
2132
3441
|
if (typeof tab?.click === 'function') attempt(() => tab.click())
|
|
2133
3442
|
},
|
|
2134
3443
|
// The right-hand pane names the tab in front. The service is the source of truth;
|
|
@@ -2158,10 +3467,13 @@ window.__ModuleLoader__.load({
|
|
|
2158
3467
|
const snapshot = attemptValue(() => (typeof owner.getSnapshot === 'function' ? owner.getSnapshot() : undefined))
|
|
2159
3468
|
return snapshot === undefined ? undefined : kindOf(snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active)
|
|
2160
3469
|
}
|
|
2161
|
-
|
|
2162
|
-
|
|
3470
|
+
// The controller first: it holds the live tab descriptor (`{ id, kind, title }`), so
|
|
3471
|
+
// its kind survives a title that is no longer the kind — a Files tab showing a file
|
|
3472
|
+
// is titled with the file name (`client.js`), and the DOM only carries that title.
|
|
2163
3473
|
const fromController = read(faces.sidebarRight)
|
|
2164
3474
|
if (fromController !== undefined) return fromController
|
|
3475
|
+
const fromStrip = activePaneTabOf(window.document, paneRegistryOf(faces))
|
|
3476
|
+
if (fromStrip !== undefined) return fromStrip
|
|
2165
3477
|
const fromRegistry = read(faces.sidebarRightTabs)
|
|
2166
3478
|
if (fromRegistry !== undefined) return fromRegistry
|
|
2167
3479
|
// DOM fallback, deliberately narrow: the active-pane marker is shared with the
|
|
@@ -2189,18 +3501,12 @@ window.__ModuleLoader__.load({
|
|
|
2189
3501
|
}
|
|
2190
3502
|
return undefined
|
|
2191
3503
|
},
|
|
3504
|
+
// The model-provider card whose editor is open, when one is (see the helper).
|
|
3505
|
+
activeSettingsProvider: () => openSettingsProviderId(window.document),
|
|
2192
3506
|
// The tab a settings section is showing: the same ARIA contract one level deeper.
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
|
|
2197
|
-
if (typeof candidate.getAttribute !== 'function') continue
|
|
2198
|
-
if (candidate.getAttribute('aria-selected') !== 'true') continue
|
|
2199
|
-
const label = (candidate.textContent ?? '').trim()
|
|
2200
|
-
if (label !== '') return label
|
|
2201
|
-
}
|
|
2202
|
-
return undefined
|
|
2203
|
-
},
|
|
3507
|
+
// Every settings row's selected control, in row order: the section's tabs, then the
|
|
3508
|
+
// row a tab reveals, and so on — the path a settings link can name.
|
|
3509
|
+
activeSettingsLevels: () => settingsSelectedLabelsOf(settingsOverlayOf(window.document)),
|
|
2204
3510
|
// The overlay is a modal, and the platform's own dismiss path is a document-level
|
|
2205
3511
|
// Escape: the shipped modal layer registers `document.addEventListener('keydown',
|
|
2206
3512
|
// handler, true)` gated on `key === 'Escape'` and closes the topmost modal there.
|
|
@@ -2214,44 +3520,118 @@ window.__ModuleLoader__.load({
|
|
|
2214
3520
|
window.document?.dispatchEvent?.(event)
|
|
2215
3521
|
})
|
|
2216
3522
|
},
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
3523
|
+
// Run a power action through the power plugin's own published endpoint. The endpoint
|
|
3524
|
+
// answers by ending the application (or relaunching it), so nothing is read back: the
|
|
3525
|
+
// call is fire-and-forget, and a composition without that plugin simply 404s.
|
|
3526
|
+
// Click the item the power plugin publishes for an action. Contract attribute first
|
|
3527
|
+
// (`data-dsh-power-action`), then the item's own label; when neither matches, the
|
|
3528
|
+
// caller falls back to the endpoint rather than guessing at a button.
|
|
3529
|
+
clickPowerItem: action => {
|
|
3530
|
+
if (POWER_ACTIONS[action] === undefined) return false
|
|
3531
|
+
const menu = powerMenuOf(window.document)
|
|
3532
|
+
if (menu === undefined) return false
|
|
3533
|
+
// Contract attribute only. Matching the item by its *label* would be guessing at a
|
|
3534
|
+
// button that ends the application, and the two labels are a hair apart (关闭DSH /
|
|
3535
|
+
// 重启DSH) — a wrong guess is not recoverable, so an unlabelled menu is left to the
|
|
3536
|
+
// endpoint, which names the action in the URL itself.
|
|
3537
|
+
const contracted = attemptValue(() => [...(menu.querySelectorAll?.(`[data-dsh-power-action="${action}"]`) ?? [])])
|
|
3538
|
+
if (!Array.isArray(contracted) || contracted.length === 0) return false
|
|
3539
|
+
pressElement(contracted[0])
|
|
3540
|
+
return true
|
|
3541
|
+
},
|
|
3542
|
+
powerAction: action => {
|
|
3543
|
+
// A wrong base answers 404 and does nothing, so the candidates are tried in order
|
|
3544
|
+
// until one accepts the request. The last failure is named once, because a desktop
|
|
3545
|
+
// window has no console and a silent no-op is the worst possible answer here.
|
|
3546
|
+
const send = index => {
|
|
3547
|
+
if (index >= POWER_ACTION_BASES.length) {
|
|
3548
|
+
host.warn(`${TAG} no power plugin answered ${action}: tried ${POWER_ACTION_BASES.join(', ')}`)
|
|
3549
|
+
return
|
|
3550
|
+
}
|
|
3551
|
+
const endpoint = `${POWER_ACTION_BASES[index]}/${action}`
|
|
3552
|
+
const result = attemptValue(() => window.fetch?.(endpoint, {
|
|
3553
|
+
method: 'POST',
|
|
3554
|
+
credentials: 'same-origin',
|
|
3555
|
+
headers: { 'content-type': 'application/json' },
|
|
3556
|
+
body: '{}',
|
|
3557
|
+
}))
|
|
3558
|
+
if (result === undefined || typeof result.then !== 'function') return
|
|
3559
|
+
result.then(response => {
|
|
3560
|
+
if (response?.ok !== true) send(index + 1)
|
|
3561
|
+
}).catch(() => send(index + 1))
|
|
3562
|
+
}
|
|
3563
|
+
send(0)
|
|
3564
|
+
},
|
|
3565
|
+
// Open the power menu. A link may do this; nothing inside the menu is ever clicked.
|
|
3566
|
+
openPowerMenu: () => {
|
|
3567
|
+
if (powerMenuOpenOf(window.document)) return
|
|
3568
|
+
const trigger = powerTriggerOf(window.document)
|
|
3569
|
+
if (typeof trigger?.click === 'function') attempt(() => trigger.click())
|
|
3570
|
+
},
|
|
3571
|
+
openSettings: (section, levels, provider) => {
|
|
3572
|
+
const overlay = settingsOverlayOf(window.document)
|
|
3573
|
+
if (overlay !== undefined) {
|
|
3574
|
+
// The overlay is what the link asked for. A named section (and its tab, and one of
|
|
3575
|
+
// its cards) is picked inside it; a bare link is already satisfied.
|
|
3576
|
+
settingsLauncherPresses = 0
|
|
3577
|
+
if (typeof section !== 'string' || section === '') return
|
|
3578
|
+
if (typeof overlay.querySelectorAll !== 'function') return
|
|
3579
|
+
const candidates = [...overlay.querySelectorAll('button')].filter(candidate => {
|
|
3580
|
+
if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
|
|
3581
|
+
return (candidate.textContent ?? '').trim() !== ''
|
|
3582
|
+
})
|
|
3583
|
+
const wanted = section.trim().toLowerCase()
|
|
3584
|
+
const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
|
|
3585
|
+
if (match === undefined || typeof match.click !== 'function') return
|
|
3586
|
+
// The section that is already showing is not pressed again: a section re-render can
|
|
3587
|
+
// close a card editor the link asked for (its drive is retried every half second).
|
|
3588
|
+
const current = candidates.find(candidate => attemptValue(() => candidate.getAttribute?.('aria-current')) === 'true')
|
|
3589
|
+
if (current !== match) attempt(() => match.click())
|
|
3590
|
+
// Deeper levels, in order, each looked up in a row *after* the one the previous
|
|
3591
|
+
// level matched — two rows can share a label (`皮肤` is both a section tab and a
|
|
3592
|
+
// row inside 创意工坊), and a cursor keeps them apart. One level per call: the
|
|
3593
|
+
// row a click reveals arrives with the next render, and the retry picks it up.
|
|
3594
|
+
let cursor = -1
|
|
3595
|
+
for (const level of innerPath(levels)) {
|
|
3596
|
+
const rows = settingsRowsOf(overlay)
|
|
3597
|
+
let hit
|
|
3598
|
+
for (let index = cursor + 1; index < rows.length; index += 1) {
|
|
3599
|
+
const control = settingsControlOf(rows[index], level)
|
|
3600
|
+
if (control !== undefined) {
|
|
3601
|
+
hit = { index, control, row: rows[index] }
|
|
3602
|
+
break
|
|
2237
3603
|
}
|
|
2238
|
-
return
|
|
2239
3604
|
}
|
|
3605
|
+
if (hit === undefined) break
|
|
3606
|
+
cursor = hit.index
|
|
3607
|
+
if (!controlIsOn(hit.row, hit.control)) attempt(() => hit.control.click())
|
|
2240
3608
|
}
|
|
3609
|
+
// And one of its cards may be the page a link named.
|
|
3610
|
+
if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
|
|
3611
|
+
return
|
|
2241
3612
|
}
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
3613
|
+
// The seat carries the shell's own store, and its actions own the panel's open state and
|
|
3614
|
+
// active section — so the panel is opened through it, without replaying any clicks.
|
|
3615
|
+
if (openSettingsThroughStore(faces, section)) return
|
|
3616
|
+
// Otherwise the reader's own path is replayed. A settings item already on
|
|
3617
|
+
// screen (the reader's menu, or one this link opened) is pressed instead of the launcher;
|
|
3618
|
+
// otherwise the launcher is pressed — never while a menu is up (that would close it
|
|
3619
|
+
// again), and only a bounded number of times (a press that opened nothing is retried).
|
|
3620
|
+
const item = settingsMenuItemOf(window.document)
|
|
3621
|
+
if (item !== undefined) {
|
|
3622
|
+
settingsLauncherPresses = 0
|
|
3623
|
+
pressElement(item)
|
|
2248
3624
|
return
|
|
2249
3625
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
3626
|
+
if (visibleMenuOf(window.document) !== undefined) return
|
|
3627
|
+
if (settingsLauncherPresses >= SETTINGS_PRESS_LIMIT) return
|
|
3628
|
+
const button = settingsLauncherOf(window.document) ?? labelledSettingsEntryOf(window.document)
|
|
3629
|
+
if (button === undefined) return
|
|
3630
|
+
settingsLauncherPresses += 1
|
|
3631
|
+
pressElement(button)
|
|
3632
|
+
},
|
|
3633
|
+
resetSettingsDrive: () => {
|
|
3634
|
+
settingsLauncherPresses = 0
|
|
2255
3635
|
},
|
|
2256
3636
|
openPane: (kind, track, warn) => {
|
|
2257
3637
|
if (typeof kind !== 'string' || kind === '') return
|
|
@@ -2355,7 +3735,22 @@ window.__ModuleLoader__.load({
|
|
|
2355
3735
|
attempt(() => {
|
|
2356
3736
|
const root = window.document?.body ?? window.document?.documentElement
|
|
2357
3737
|
if (root === undefined || root === null || typeof window.MutationObserver !== 'function') return () => {}
|
|
2358
|
-
|
|
3738
|
+
// One reconcile per frame. A streaming answer mutates the page dozens of times a
|
|
3739
|
+
// second, and every batch re-reads the whole view state (the sidebar's rows
|
|
3740
|
+
// included). A host without frames (tests, a minimal DOM) runs the handler
|
|
3741
|
+
// straight away, which is what the fake DOM relies on.
|
|
3742
|
+
let queued = false
|
|
3743
|
+
const flush = () => {
|
|
3744
|
+
queued = false
|
|
3745
|
+
handler()
|
|
3746
|
+
}
|
|
3747
|
+
const schedule = () => {
|
|
3748
|
+
if (queued) return
|
|
3749
|
+
queued = true
|
|
3750
|
+
const frame = attemptValue(() => window.requestAnimationFrame?.(flush))
|
|
3751
|
+
if (frame === undefined) flush()
|
|
3752
|
+
}
|
|
3753
|
+
const observer = new window.MutationObserver(schedule)
|
|
2359
3754
|
// Every published page hook must be watched: a filter that names only some of
|
|
2360
3755
|
// them silently starves the retry loop of notifications (real bug: the task
|
|
2361
3756
|
// board's drive looked flaky because its attributes were not observed).
|
|
@@ -2374,6 +3769,10 @@ window.__ModuleLoader__.load({
|
|
|
2374
3769
|
'aria-current',
|
|
2375
3770
|
// Switching a settings *tab* moves this one, for the same reason.
|
|
2376
3771
|
'aria-selected',
|
|
3772
|
+
// The automation list's status filter is three buttons whose only state is
|
|
3773
|
+
// this attribute, and pressing one touches nothing else: without it here the
|
|
3774
|
+
// address bar kept the filter it saw first.
|
|
3775
|
+
'aria-pressed',
|
|
2377
3776
|
SETTINGS_SURFACE_ATTRIBUTE,
|
|
2378
3777
|
],
|
|
2379
3778
|
})
|
|
@@ -2448,6 +3847,231 @@ window.__ModuleLoader__.load({
|
|
|
2448
3847
|
return undefined
|
|
2449
3848
|
}
|
|
2450
3849
|
|
|
3850
|
+
/**
|
|
3851
|
+
* The "Settings" item of the launcher menu, when this build puts the overlay one menu down.
|
|
3852
|
+
*
|
|
3853
|
+
* The sidebar gear opens a popover on rc.3 (measured: Settings / Feedback / Sign in), so
|
|
3854
|
+
* clicking the gear alone leaves the overlay closed and every settings link expires. The
|
|
3855
|
+
* item's text carries its shortcut (`Settings⌥⌘,`), so only the head of the label matches.
|
|
3856
|
+
* @param {object} document - the document.
|
|
3857
|
+
* @returns {object|undefined} the menu item that opens the overlay.
|
|
3858
|
+
*/
|
|
3859
|
+
/**
|
|
3860
|
+
* Whether an element is really on screen.
|
|
3861
|
+
*
|
|
3862
|
+
* The shell keeps closed popovers mounted, so "the selector matches" proves nothing: a
|
|
3863
|
+
* hidden item is unpressable, and pressing it also stops the launcher from being pressed
|
|
3864
|
+
* again. `offsetParent` is useless here — a fixed-position popover reports null while it
|
|
3865
|
+
* is perfectly visible — so the rect list is the test.
|
|
3866
|
+
* @param {object} node - the element.
|
|
3867
|
+
* @returns {boolean} whether it is visible.
|
|
3868
|
+
*/
|
|
3869
|
+
function onScreen(node) {
|
|
3870
|
+
if (node === null || node === undefined) return false
|
|
3871
|
+
if (typeof node.closest === 'function' && node.closest('[hidden]') !== null) return false
|
|
3872
|
+
const rects = attemptValue(() => node.getClientRects?.())
|
|
3873
|
+
if (Array.isArray(rects) && rects.length === 0) return false
|
|
3874
|
+
return true
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
/**
|
|
3878
|
+
* The open popover, when one is on screen.
|
|
3879
|
+
*
|
|
3880
|
+
* One selector at a time: a comma list is beyond a small DOM stub.
|
|
3881
|
+
* @param {object} document - the document.
|
|
3882
|
+
* @returns {object|undefined} the popover root.
|
|
3883
|
+
*/
|
|
3884
|
+
function visibleMenuOf(document) {
|
|
3885
|
+
for (const selector of ['[role="menu"]']) {
|
|
3886
|
+
const nodes = attemptValue(() => [...(document?.querySelectorAll?.(selector) ?? [])])
|
|
3887
|
+
if (!Array.isArray(nodes)) continue
|
|
3888
|
+
for (const node of nodes) {
|
|
3889
|
+
if (onScreen(node)) return node
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
return undefined
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
/**
|
|
3896
|
+
* The "Settings" item of the launcher menu, when this build puts the overlay one menu down.
|
|
3897
|
+
*
|
|
3898
|
+
* The seat is single-occupant, so a shell that seats an account row there turns the gear
|
|
3899
|
+
* into a menu trigger (measured on the desktop: the trigger is labelled with the account
|
|
3900
|
+
* name and carries `aria-haspopup="menu"`). The item is matched by its label — its head may
|
|
3901
|
+
* carry a shortcut (`Settings⌥⌘,`) and it need not be a `menuitem` at all — and only while
|
|
3902
|
+
* the menu is really on screen.
|
|
3903
|
+
* @param {object} document - the document.
|
|
3904
|
+
* @returns {object|undefined} the menu item that opens the overlay.
|
|
3905
|
+
*/
|
|
3906
|
+
function settingsMenuItemOf(document) {
|
|
3907
|
+
const menu = visibleMenuOf(document)
|
|
3908
|
+
const scopes = menu === undefined ? [document] : [menu]
|
|
3909
|
+
for (const scope of scopes) {
|
|
3910
|
+
for (const selector of ['[role="menuitem"]', 'button', '[role="option"]', 'a']) {
|
|
3911
|
+
const nodes = attemptValue(() => [...(scope?.querySelectorAll?.(selector) ?? [])])
|
|
3912
|
+
if (!Array.isArray(nodes)) continue
|
|
3913
|
+
for (const node of nodes) {
|
|
3914
|
+
if (!onScreen(node)) continue
|
|
3915
|
+
const label = String(node.textContent ?? '').trim()
|
|
3916
|
+
if (!/设置|settings/i.test(label)) continue
|
|
3917
|
+
// Outside a menu, only a real menu item counts: a stray button that happens to
|
|
3918
|
+
// be labelled "设置" is not this drive's to press.
|
|
3919
|
+
if (menu === undefined && node.getAttribute?.('role') !== 'menuitem') continue
|
|
3920
|
+
return node
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
return undefined
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
/**
|
|
3928
|
+
* Whether the power menu is open.
|
|
3929
|
+
*
|
|
3930
|
+
* It is the one surface this router reads from another *plugin*, not from the platform, so
|
|
3931
|
+
* the contract attribute comes first and the plugin's own class names are the fallback for
|
|
3932
|
+
* a build that predates it. Nothing here writes anything: it only answers "is the popover
|
|
3933
|
+
* up", which is what makes the menu a routable (if transient) place.
|
|
3934
|
+
* @param {object} document - the document.
|
|
3935
|
+
* @returns {boolean} whether the menu is on screen.
|
|
3936
|
+
*/
|
|
3937
|
+
function powerMenuOpenOf(document) {
|
|
3938
|
+
const marked = attemptValue(() => document?.querySelector?.(`[${POWER_MENU_ATTRIBUTE}]`))
|
|
3939
|
+
if (marked !== null && marked !== undefined) {
|
|
3940
|
+
const value = typeof marked.getAttribute === 'function' ? marked.getAttribute(POWER_MENU_ATTRIBUTE) : null
|
|
3941
|
+
return value === null || value === '' || value === 'open'
|
|
3942
|
+
}
|
|
3943
|
+
const menus = attemptValue(() => document?.getElementsByClassName?.(POWER_MENU_CLASS))
|
|
3944
|
+
if (menus !== undefined && menus !== null && menus.length > 0) return true
|
|
3945
|
+
const triggers = attemptValue(() => document?.getElementsByClassName?.(POWER_TRIGGER_CLASS))
|
|
3946
|
+
const trigger = triggers?.[0]
|
|
3947
|
+
if (trigger === undefined || trigger === null) return false
|
|
3948
|
+
return typeof trigger.getAttribute === 'function' && trigger.getAttribute('aria-expanded') === 'true'
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
/**
|
|
3952
|
+
* Open the settings panel through the shell's own store.
|
|
3953
|
+
*
|
|
3954
|
+
* The `sidebar.settings` seat is a single-occupant slot, and the shell's registration carries
|
|
3955
|
+
* the store that owns the panel: `create().actions.openSection(id)` opens it on a section with
|
|
3956
|
+
* no clicking at all. Measured 2026-10-28: a seat occupied by an account row opens a popover
|
|
3957
|
+
* whose item the drive could not press, and every settings link died there — this path does
|
|
3958
|
+
* not depend on that popover at all.
|
|
3959
|
+
* @param {object} faces - the probed faces.
|
|
3960
|
+
* @param {string} section - the section label a link names (empty for a bare link).
|
|
3961
|
+
* @returns {boolean} whether the store accepted the call.
|
|
3962
|
+
*/
|
|
3963
|
+
function openSettingsThroughStore(faces, section) {
|
|
3964
|
+
const seat = attemptValue(() => faces.slots?.entries?.(SETTINGS_TRIGGER_SLOT)?.[0]?.store)
|
|
3965
|
+
if (seat === undefined || seat === null || typeof seat.create !== 'function') return false
|
|
3966
|
+
const actions = attemptValue(() => seat.create()?.actions)
|
|
3967
|
+
if (actions === undefined || actions === null) return false
|
|
3968
|
+
const wanted = typeof section === 'string' ? section.trim() : ''
|
|
3969
|
+
if (wanted === '') {
|
|
3970
|
+
if (typeof actions.open !== 'function') return false
|
|
3971
|
+
return attemptValue(() => { actions.open(); return true }) === true
|
|
3972
|
+
}
|
|
3973
|
+
const id = settingsSectionIdOf(faces, wanted)
|
|
3974
|
+
if (id === undefined) return false
|
|
3975
|
+
if (typeof actions.openSection !== 'function') return false
|
|
3976
|
+
return attemptValue(() => { actions.openSection(id); return true }) === true
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
/**
|
|
3980
|
+
* The id of the section a link names, read from the slot the shell renders its nav from.
|
|
3981
|
+
*
|
|
3982
|
+
* A link names a section by the label the reader sees (that is the only identity the DOM
|
|
3983
|
+
* carries), while the store wants the registration id — this is the one place that maps one
|
|
3984
|
+
* to the other, and it is the same list the shell itself maps over.
|
|
3985
|
+
* @param {object} faces - the probed faces.
|
|
3986
|
+
* @param {string} label - the section label.
|
|
3987
|
+
* @returns {string|undefined} the section id.
|
|
3988
|
+
*/
|
|
3989
|
+
function settingsSectionIdOf(faces, label) {
|
|
3990
|
+
const rows = attemptValue(() => faces.slots?.entries?.(SETTINGS_SECTION_SLOT))
|
|
3991
|
+
if (!Array.isArray(rows)) return undefined
|
|
3992
|
+
const wanted = label.trim().toLowerCase()
|
|
3993
|
+
for (const row of rows) {
|
|
3994
|
+
const id = row === null || typeof row !== 'object' ? undefined : row.options?.id
|
|
3995
|
+
if (typeof id !== 'string' || id === '') continue
|
|
3996
|
+
let text = row.options?.label
|
|
3997
|
+
if (typeof text === 'function') text = attemptValue(() => text())
|
|
3998
|
+
if (typeof text !== 'string') continue
|
|
3999
|
+
if (text.trim().toLowerCase() === wanted) return id
|
|
4000
|
+
}
|
|
4001
|
+
return undefined
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
/**
|
|
4005
|
+
* The labelled settings entry, for a composition whose seat carries no launcher at all.
|
|
4006
|
+
* @param {object} document - the document.
|
|
4007
|
+
* @returns {object|undefined} the entry.
|
|
4008
|
+
*/
|
|
4009
|
+
function labelledSettingsEntryOf(document) {
|
|
4010
|
+
const buttons = attemptValue(() => [...(document?.querySelectorAll?.('button') ?? [])])
|
|
4011
|
+
if (!Array.isArray(buttons)) return undefined
|
|
4012
|
+
return buttons.find(candidate => /^(settings|设置)$/i.test(String(candidate?.textContent ?? '').trim()))
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
/**
|
|
4016
|
+
* Press an element the way a pointer does.
|
|
4017
|
+
*
|
|
4018
|
+
* A popover trigger commonly opens on `pointerdown`/`mousedown`, so a bare `element.click()`
|
|
4019
|
+
* is a no-op on it (measured: the settings seat's account row opens its menu only for a real
|
|
4020
|
+
* pointer press — the menu was never opened by the drive, so the link never landed). The
|
|
4021
|
+
* sequence is dispatched at the element's centre, and a missing constructor is skipped so a
|
|
4022
|
+
* minimal DOM still works.
|
|
4023
|
+
* @param {object} element - the element to press.
|
|
4024
|
+
* @returns {boolean} whether anything was dispatched.
|
|
4025
|
+
*/
|
|
4026
|
+
function pressElement(element) {
|
|
4027
|
+
if (element === null || element === undefined) return false
|
|
4028
|
+
const box = attemptValue(() => element.getBoundingClientRect?.()) ?? { left: 0, top: 0, width: 0, height: 0 }
|
|
4029
|
+
const options = {
|
|
4030
|
+
bubbles: true,
|
|
4031
|
+
cancelable: true,
|
|
4032
|
+
view: window,
|
|
4033
|
+
clientX: (box.left ?? 0) + (box.width ?? 0) / 2,
|
|
4034
|
+
clientY: (box.top ?? 0) + (box.height ?? 0) / 2,
|
|
4035
|
+
}
|
|
4036
|
+
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup']) {
|
|
4037
|
+
const Constructor = type.startsWith('pointer') ? window.PointerEvent : window.MouseEvent
|
|
4038
|
+
if (typeof Constructor !== 'function') continue
|
|
4039
|
+
attemptValue(() => element.dispatchEvent?.(new Constructor(type, options)))
|
|
4040
|
+
}
|
|
4041
|
+
if (typeof element.click === 'function') attemptValue(() => element.click())
|
|
4042
|
+
return true
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
/**
|
|
4046
|
+
* The settings launcher: the dialog trigger when the shell seats one, the seat's own button
|
|
4047
|
+
* otherwise.
|
|
4048
|
+
*
|
|
4049
|
+
* A dialog trigger opens the overlay in one press; a menu trigger (the account row some
|
|
4050
|
+
* shells seat there) needs its menu followed, which the caller does on the next step.
|
|
4051
|
+
* @param {object} document - the document.
|
|
4052
|
+
* @returns {object|undefined} the element a reader clicks to reach the settings surface.
|
|
4053
|
+
*/
|
|
4054
|
+
function settingsLauncherOf(document) {
|
|
4055
|
+
const slot = attemptValue(() => document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`))
|
|
4056
|
+
const dialog = attemptValue(() => slot?.querySelector?.('button[aria-haspopup="dialog"]'))
|
|
4057
|
+
if (dialog !== null && dialog !== undefined) return dialog
|
|
4058
|
+
const own = attemptValue(() => slot?.querySelector?.('button'))
|
|
4059
|
+
if (own !== null && own !== undefined) return own
|
|
4060
|
+
return undefined
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
/**
|
|
4064
|
+
* The power button, by contract attribute first and by the plugin's class name after it.
|
|
4065
|
+
* @param {object} document - the document.
|
|
4066
|
+
* @returns {object|undefined} the element a reader clicks to open the menu.
|
|
4067
|
+
*/
|
|
4068
|
+
function powerTriggerOf(document) {
|
|
4069
|
+
const marked = attemptValue(() => document?.querySelector?.(`[${POWER_TRIGGER_ATTRIBUTE}]`))
|
|
4070
|
+
if (marked !== null && marked !== undefined) return marked
|
|
4071
|
+
const found = attemptValue(() => document?.getElementsByClassName?.(POWER_TRIGGER_CLASS)?.[0])
|
|
4072
|
+
return found === undefined ? undefined : found
|
|
4073
|
+
}
|
|
4074
|
+
|
|
2451
4075
|
/**
|
|
2452
4076
|
* The fragment a reader can read: percent escapes decoded, or the raw text when the
|
|
2453
4077
|
* escapes are malformed. Display only — the router still writes the encoded form.
|
|
@@ -2487,6 +4111,34 @@ window.__ModuleLoader__.load({
|
|
|
2487
4111
|
*/
|
|
2488
4112
|
function landedFragment(wanted, now) {
|
|
2489
4113
|
if (String(wanted) === String(now)) return true
|
|
4114
|
+
// A power action is a command, not a place: nothing on screen can ever equal it, so
|
|
4115
|
+
// waiting for a match would make the bar cry "打不开" over the one or two seconds the
|
|
4116
|
+
// application takes to go away. Sending it *is* landing it.
|
|
4117
|
+
const wantedRoute = parseRoute(String(wanted))
|
|
4118
|
+
if (wantedRoute?.kind === 'power' && powerActionOf(wantedRoute.id) !== undefined) return true
|
|
4119
|
+
// The reader hands over what they read — a settings section is raw Chinese — while the
|
|
4120
|
+
// canonical form is percent-encoded, so one route has two spellings. Comparing the raw
|
|
4121
|
+
// strings called a link that had already landed "打不开": the overlay was open on that
|
|
4122
|
+
// very section while the bar said it could not open it (measured 2026-10-28).
|
|
4123
|
+
if (readableFragment(wanted) === readableFragment(now)) return true
|
|
4124
|
+
const nowRoute = parseRoute(String(now))
|
|
4125
|
+
if (wantedRoute === undefined || nowRoute === undefined) return false
|
|
4126
|
+
if (wantedRoute.unknown !== undefined || nowRoute.unknown !== undefined) return false
|
|
4127
|
+
// A section is a refinement of the overlay: a bare settings link is satisfied by any
|
|
4128
|
+
// section, and a named one by that same section.
|
|
4129
|
+
if (wantedRoute.kind === 'settings' && nowRoute.kind === 'settings') {
|
|
4130
|
+
const section = wantedRoute.id ?? ''
|
|
4131
|
+
if (section !== '' && section !== (nowRoute.id ?? '')) return false
|
|
4132
|
+
const wantedLevels = settingsPartsOf(wantedRoute.inner).levels
|
|
4133
|
+
const shownLevels = settingsPartsOf(nowRoute.inner).levels
|
|
4134
|
+
return wantedLevels.length <= shownLevels.length && wantedLevels.every((level, index) => level === shownLevels[index])
|
|
4135
|
+
}
|
|
4136
|
+
// A panel link may be wider than the page on screen (`plugins/<pkg>` for an open row).
|
|
4137
|
+
if (wantedRoute.kind === 'panel' && nowRoute.kind === 'panel' && wantedRoute.id === nowRoute.id) {
|
|
4138
|
+
const wantedPage = innerPath(wantedRoute.inner)
|
|
4139
|
+
const shownPage = innerPath(nowRoute.inner)
|
|
4140
|
+
if (wantedPage.length <= shownPage.length && wantedPage.every((part, index) => shownPage[index] === part)) return true
|
|
4141
|
+
}
|
|
2490
4142
|
const target = targetSessionOf(wanted)
|
|
2491
4143
|
return target !== undefined && target === targetSessionOf(now)
|
|
2492
4144
|
}
|
|
@@ -2541,10 +4193,11 @@ window.__ModuleLoader__.load({
|
|
|
2541
4193
|
* @returns {object|undefined} the trigger element, or undefined when absent.
|
|
2542
4194
|
*/
|
|
2543
4195
|
function powerTrigger() {
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
??
|
|
4196
|
+
const page = attemptValue(() => window.document)
|
|
4197
|
+
if (typeof page?.querySelector !== 'function') return undefined
|
|
4198
|
+
return page.querySelector('[data-dshpm-mount] .dshpm-trigger')
|
|
4199
|
+
?? page.querySelector('.dshpm-trigger')
|
|
4200
|
+
?? page.querySelector('[aria-label="电源菜单"]')
|
|
2548
4201
|
?? undefined
|
|
2549
4202
|
}
|
|
2550
4203
|
|
|
@@ -2594,7 +4247,7 @@ window.__ModuleLoader__.load({
|
|
|
2594
4247
|
try {
|
|
2595
4248
|
style = document.createElement('style')
|
|
2596
4249
|
style.id = TITLEBAR_STYLE_ID
|
|
2597
|
-
style.textContent = macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT
|
|
4250
|
+
style.textContent = `${macInHeader ? `${TITLEBAR_STYLE_TEXT}\n${MAC_STYLE_TEXT}` : TITLEBAR_STYLE_TEXT}\n${RECENT_STYLE_TEXT}`
|
|
2598
4251
|
const parent = document.head ?? document.body
|
|
2599
4252
|
if (parent !== undefined && parent !== null && typeof parent.appendChild === 'function') parent.appendChild(style)
|
|
2600
4253
|
} catch {
|
|
@@ -2612,6 +4265,16 @@ window.__ModuleLoader__.load({
|
|
|
2612
4265
|
input.placeholder = TITLEBAR_PLACEHOLDER
|
|
2613
4266
|
if (typeof input.setAttribute === 'function') input.setAttribute('aria-label', '地址栏 / address bar')
|
|
2614
4267
|
bar.appendChild(input)
|
|
4268
|
+
// The recent list: the drop-down a browser shows when its address bar is focused.
|
|
4269
|
+
// It is a child of the bar so it moves with it and can never end up somewhere
|
|
4270
|
+
// else on a screen where the bar is repositioned.
|
|
4271
|
+
const recentList = document.createElement('div')
|
|
4272
|
+
if (typeof recentList.setAttribute === 'function') {
|
|
4273
|
+
recentList.setAttribute(RECENT_LIST_ATTRIBUTE, '')
|
|
4274
|
+
recentList.setAttribute('role', 'listbox')
|
|
4275
|
+
recentList.setAttribute('aria-label', '最近使用的链接 / recent links')
|
|
4276
|
+
}
|
|
4277
|
+
bar.appendChild(recentList)
|
|
2615
4278
|
/**
|
|
2616
4279
|
* The free stretch the bar was last fitted into.
|
|
2617
4280
|
*
|
|
@@ -2672,6 +4335,8 @@ window.__ModuleLoader__.load({
|
|
|
2672
4335
|
* macOS: sit in the free stretch of the header row, on the power button's
|
|
2673
4336
|
* own line.
|
|
2674
4337
|
*/
|
|
4338
|
+
/** The shape the last placement was measured from, so an unchanged header is not re-walked. */
|
|
4339
|
+
let lastShape
|
|
2675
4340
|
const place = () => {
|
|
2676
4341
|
if (macInHeader) {
|
|
2677
4342
|
// The bar lives on the header row, on the same line as the power
|
|
@@ -2684,6 +4349,20 @@ window.__ModuleLoader__.load({
|
|
|
2684
4349
|
const row = macHeaderRow(trigger)
|
|
2685
4350
|
const rowRect = row?.getBoundingClientRect?.()
|
|
2686
4351
|
const anchored = rect !== undefined && rect !== null && rect.width > 0
|
|
4352
|
+
// The observer fires for every mutation on the page, and a streaming answer produces
|
|
4353
|
+
// them constantly: walking the row again when nothing that shaped the last placement
|
|
4354
|
+
// moved is pure waste (the walk reads a box per leaf, i.e. forces layout). A screen
|
|
4355
|
+
// with no row to measure is not cached this way — its band is the page's own content,
|
|
4356
|
+
// which changes without moving the row.
|
|
4357
|
+
const shape = [
|
|
4358
|
+
rowRect?.top, rowRect?.left, rowRect?.width, rowRect?.height,
|
|
4359
|
+
rect?.top, rect?.left, rect?.width,
|
|
4360
|
+
Math.round(globalThis.innerWidth ?? 0),
|
|
4361
|
+
(row?.textContent ?? '').length,
|
|
4362
|
+
].join('|')
|
|
4363
|
+
if (anchored && shape === lastShape && bar.style.display !== 'none') return
|
|
4364
|
+
if (anchored) lastShape = shape
|
|
4365
|
+
workCounts.placements += 1
|
|
2687
4366
|
// The row is only walked when there is a button to measure from.
|
|
2688
4367
|
const gap = anchored ? macFreeGap(macLeaves(row, 0), rect, rowRect) : null
|
|
2689
4368
|
if (gap === null) {
|
|
@@ -2703,11 +4382,30 @@ window.__ModuleLoader__.load({
|
|
|
2703
4382
|
bar.style.display = 'none'
|
|
2704
4383
|
return
|
|
2705
4384
|
}
|
|
2706
|
-
const
|
|
2707
|
-
|
|
4385
|
+
const remembered = fitBar(lastPlacement.gap, lastPlacement.top)
|
|
4386
|
+
// Yield to whatever this screen draws on that line: the bar moves into a free run
|
|
4387
|
+
// of its own instead of being drawn across a control (measured: it sat straight
|
|
4388
|
+
// across the automation task's detail tabs).
|
|
4389
|
+
const runs = []
|
|
4390
|
+
let cursor = 0
|
|
4391
|
+
for (const span of bandSpans(document, bar, remembered.top, MAC_BAR_HEIGHT)) {
|
|
4392
|
+
if (span.left > cursor) runs.push({ left: cursor, right: span.left })
|
|
4393
|
+
cursor = Math.max(cursor, span.right)
|
|
4394
|
+
}
|
|
4395
|
+
if (viewport > cursor) runs.push({ left: cursor, right: viewport })
|
|
4396
|
+
let best = null
|
|
4397
|
+
for (const run of runs) {
|
|
4398
|
+
if (run.right - run.left < MAC_BAR_MIN_WIDTH) continue
|
|
4399
|
+
if (best === null || run.right - run.left > best.right - best.left) best = run
|
|
4400
|
+
}
|
|
4401
|
+
if (best === null) {
|
|
4402
|
+
bar.style.display = 'none'
|
|
4403
|
+
return
|
|
4404
|
+
}
|
|
4405
|
+
const box = fitBar(best, remembered.top)
|
|
2708
4406
|
bar.style.display = ''
|
|
2709
4407
|
bar.style.top = `${box.top}px`
|
|
2710
|
-
bar.style.left = `${left}px`
|
|
4408
|
+
bar.style.left = `${box.left}px`
|
|
2711
4409
|
bar.style.right = 'auto'
|
|
2712
4410
|
bar.style.width = `${box.width}px`
|
|
2713
4411
|
return
|
|
@@ -2744,7 +4442,7 @@ window.__ModuleLoader__.load({
|
|
|
2744
4442
|
let placeFrame
|
|
2745
4443
|
const schedulePlace = () => {
|
|
2746
4444
|
if (placeFrame !== undefined) return
|
|
2747
|
-
placeFrame = attemptValue(() =>
|
|
4445
|
+
placeFrame = attemptValue(() => window.requestAnimationFrame?.(() => {
|
|
2748
4446
|
placeFrame = undefined
|
|
2749
4447
|
place()
|
|
2750
4448
|
}))
|
|
@@ -2769,6 +4467,11 @@ window.__ModuleLoader__.load({
|
|
|
2769
4467
|
show(window.location?.hash)
|
|
2770
4468
|
/** Whether the view has arrived where the link asked, judged by what is on screen. */
|
|
2771
4469
|
const arrived = wanted => {
|
|
4470
|
+
// A command is not a place: nothing on screen can ever match it, so it counts as
|
|
4471
|
+
// arrived the moment it is sent — otherwise the bar would cry "打不开" over the
|
|
4472
|
+
// second or two the application takes to go away (measured 2026-10-29).
|
|
4473
|
+
const typed = parseRoute(String(wanted))
|
|
4474
|
+
if (typed?.kind === 'power' && powerActionOf(typed.id) !== undefined) return true
|
|
2772
4475
|
const report = attemptValue(() => window.__dshUrlRouter?.report?.())
|
|
2773
4476
|
const canonical = report?.canonical
|
|
2774
4477
|
if (canonical === undefined || canonical === null) return false
|
|
@@ -2810,14 +4513,294 @@ window.__ModuleLoader__.load({
|
|
|
2810
4513
|
input.value = why
|
|
2811
4514
|
}, TITLEBAR_SETTLE_MS))
|
|
2812
4515
|
}
|
|
4516
|
+
/**
|
|
4517
|
+
* The chip a remembered link is shown with: what kind of screen it names.
|
|
4518
|
+
* @param {object|undefined} route - the decoded route.
|
|
4519
|
+
* @returns {string} the chip's text.
|
|
4520
|
+
*/
|
|
4521
|
+
const recentKind = route => {
|
|
4522
|
+
if (route === undefined || route.unknown !== undefined) return '链接'
|
|
4523
|
+
if (route.kind === 'session') return '会话'
|
|
4524
|
+
if (route.kind === 'workspace') return '工作区'
|
|
4525
|
+
if (route.kind === 'panel') return '面板'
|
|
4526
|
+
if (route.kind === 'settings') return '设置'
|
|
4527
|
+
return '链接'
|
|
4528
|
+
}
|
|
4529
|
+
/** Hide the remembered links. */
|
|
4530
|
+
const hideRecent = () => {
|
|
4531
|
+
if (typeof recentList.removeAttribute === 'function') recentList.removeAttribute('data-open')
|
|
4532
|
+
}
|
|
4533
|
+
/**
|
|
4534
|
+
* Whether the reader has edited the text since the bar was focused.
|
|
4535
|
+
*
|
|
4536
|
+
* The list is narrowed only by text the reader typed. The field always holds the
|
|
4537
|
+
* current link when it is merely focused, and filtering by that would hide every
|
|
4538
|
+
* remembered screen but the one on display.
|
|
4539
|
+
*/
|
|
4540
|
+
let narrowed = false
|
|
4541
|
+
/** Empty the list before it is drawn again. */
|
|
4542
|
+
const emptyRecent = () => {
|
|
4543
|
+
while (recentList.firstChild !== null && recentList.firstChild !== undefined) {
|
|
4544
|
+
if (typeof recentList.removeChild !== 'function') break
|
|
4545
|
+
recentList.removeChild(recentList.firstChild)
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* The absolute URL a remembered link stands for.
|
|
4550
|
+
*
|
|
4551
|
+
* A copied link has to survive the trip out of this window, so it carries the path
|
|
4552
|
+
* and the launch query (`?token=…`) exactly the way a row anchor's own "copy link
|
|
4553
|
+
* address" does — the bare fragment only works inside an already-open page.
|
|
4554
|
+
* @param {string} hash - the fragment.
|
|
4555
|
+
* @returns {string} the URL to copy.
|
|
4556
|
+
*/
|
|
4557
|
+
const absoluteLink = hash => {
|
|
4558
|
+
const location = window.location ?? {}
|
|
4559
|
+
return `${location.origin ?? ''}${location.pathname ?? ''}${location.search ?? ''}${hash}`
|
|
4560
|
+
}
|
|
4561
|
+
/** Set while the copy's throw-away field borrows focus, so the bar ignores the swap. */
|
|
4562
|
+
let quietFocus = false
|
|
4563
|
+
/**
|
|
4564
|
+
* Run something without the bar reading it as the reader leaving or entering the
|
|
4565
|
+
* field: the copy must neither close the list nor re-select the link.
|
|
4566
|
+
* @param {Function} run - what to run.
|
|
4567
|
+
* @returns {*} whatever it returned.
|
|
4568
|
+
*/
|
|
4569
|
+
const quietly = run => {
|
|
4570
|
+
quietFocus = true
|
|
4571
|
+
try {
|
|
4572
|
+
return run()
|
|
4573
|
+
} finally {
|
|
4574
|
+
quietFocus = false
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
/**
|
|
4578
|
+
* Put text on the clipboard the old way: select it in a throw-away field and let the
|
|
4579
|
+
* command copy it. Used when the window has no clipboard service, or refuses the
|
|
4580
|
+
* async call — and the reader is put back in the field afterwards, because the field
|
|
4581
|
+
* was only there to be copied from.
|
|
4582
|
+
* @param {string} text - what to copy.
|
|
4583
|
+
* @returns {boolean} whether the copy was accepted.
|
|
4584
|
+
*/
|
|
4585
|
+
const copyBySelection = text => quietly(() => attemptValue(() => {
|
|
4586
|
+
const wasFocused = attemptValue(() => document.activeElement)
|
|
4587
|
+
const area = document.createElement('textarea')
|
|
4588
|
+
area.value = text
|
|
4589
|
+
area.setAttribute?.('readonly', '')
|
|
4590
|
+
if (area.style !== undefined) {
|
|
4591
|
+
area.style.position = 'fixed'
|
|
4592
|
+
area.style.top = '-1000px'
|
|
4593
|
+
}
|
|
4594
|
+
document.body?.appendChild?.(area)
|
|
4595
|
+
area.focus?.()
|
|
4596
|
+
area.select?.()
|
|
4597
|
+
const done = typeof document.execCommand === 'function' && document.execCommand('copy') === true
|
|
4598
|
+
area.remove?.()
|
|
4599
|
+
if (wasFocused === input) attemptValue(() => input.focus?.())
|
|
4600
|
+
return done
|
|
4601
|
+
}, false) ?? false)
|
|
4602
|
+
/**
|
|
4603
|
+
* Copy a remembered link.
|
|
4604
|
+
* @param {string} hash - the fragment to copy.
|
|
4605
|
+
* @returns {Promise<boolean>|boolean} whether the copy was accepted.
|
|
4606
|
+
*/
|
|
4607
|
+
const copyLink = hash => {
|
|
4608
|
+
const text = absoluteLink(hash)
|
|
4609
|
+
const clipboard = attemptValue(() => globalThis.navigator?.clipboard)
|
|
4610
|
+
if (clipboard !== undefined && clipboard !== null && typeof clipboard.writeText === 'function') {
|
|
4611
|
+
let pending
|
|
4612
|
+
try {
|
|
4613
|
+
pending = Promise.resolve(clipboard.writeText(text))
|
|
4614
|
+
} catch {
|
|
4615
|
+
return copyBySelection(text)
|
|
4616
|
+
}
|
|
4617
|
+
// A window that refuses the async call still has the old path. (The fallback
|
|
4618
|
+
// is a function call inside the handler on purpose: evaluating it up front
|
|
4619
|
+
// would run — and focus a throw-away field — on every single copy.)
|
|
4620
|
+
return pending.then(() => true, () => copyBySelection(text))
|
|
4621
|
+
}
|
|
4622
|
+
return copyBySelection(text)
|
|
4623
|
+
}
|
|
4624
|
+
/** Timers that put a "copied" row back, so disposal can clear them. */
|
|
4625
|
+
const copiedTimers = new Set()
|
|
4626
|
+
/** The bar's own confirmation chip: it stays visible even if the list had to close. */
|
|
4627
|
+
const toast = document.createElement('span')
|
|
4628
|
+
toast.className = 'dshur-toast'
|
|
4629
|
+
if (typeof toast.setAttribute === 'function') {
|
|
4630
|
+
toast.setAttribute(TOAST_ATTRIBUTE, '')
|
|
4631
|
+
toast.setAttribute('aria-live', 'polite')
|
|
4632
|
+
}
|
|
4633
|
+
bar.appendChild(toast)
|
|
4634
|
+
let toastTimer
|
|
4635
|
+
/**
|
|
4636
|
+
* Show one short confirmation in the bar.
|
|
4637
|
+
* @param {string} text - what to say.
|
|
4638
|
+
*/
|
|
4639
|
+
const flashToast = text => {
|
|
4640
|
+
toast.textContent = text
|
|
4641
|
+
toast.setAttribute?.('data-show', '')
|
|
4642
|
+
if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
|
|
4643
|
+
toastTimer = attemptValue(() => window.setTimeout(() => {
|
|
4644
|
+
toastTimer = undefined
|
|
4645
|
+
toast.removeAttribute?.('data-show')
|
|
4646
|
+
toast.textContent = ''
|
|
4647
|
+
}, 1200))
|
|
4648
|
+
}
|
|
4649
|
+
/**
|
|
4650
|
+
* Say so on the row that was copied, then put the link back.
|
|
4651
|
+
* @param {object} item - the remembered-link row.
|
|
4652
|
+
* @param {object} link - that row's link text.
|
|
4653
|
+
*/
|
|
4654
|
+
const flashCopied = (item, link) => {
|
|
4655
|
+
const text = readableFragment(attemptValue(() => item.getAttribute(RECENT_ITEM_ATTRIBUTE)) ?? '')
|
|
4656
|
+
link.textContent = '已复制 ✓'
|
|
4657
|
+
item.setAttribute?.('data-copied', '')
|
|
4658
|
+
const timer = attemptValue(() => window.setTimeout(() => {
|
|
4659
|
+
copiedTimers.delete(timer)
|
|
4660
|
+
// A redraw may have replaced the row already; only a live one is restored.
|
|
4661
|
+
link.textContent = text
|
|
4662
|
+
item.removeAttribute?.('data-copied')
|
|
4663
|
+
}, 1200))
|
|
4664
|
+
if (timer !== undefined) copiedTimers.add(timer)
|
|
4665
|
+
}
|
|
4666
|
+
/**
|
|
4667
|
+
* Draw the remembered links: the screens this bar named and the links pasted into
|
|
4668
|
+
* it, newest first, narrowed by whatever is typed. Nothing to show means no list.
|
|
4669
|
+
*/
|
|
4670
|
+
const drawRecent = () => {
|
|
4671
|
+
const typed = narrowed ? String(input.value ?? '').trim().toLowerCase() : ''
|
|
4672
|
+
// One scan of the sidebar for the whole list: asking per entry re-walked every row
|
|
4673
|
+
// once per item (and twice per item, since the filter asks too).
|
|
4674
|
+
const titles = sessionTitles()
|
|
4675
|
+
// `recentEntries()` already leaves out the screen on display, so a remembered
|
|
4676
|
+
// link is only narrowed here by what the reader typed.
|
|
4677
|
+
const shown = recentEntries().filter(entry => {
|
|
4678
|
+
if (typed === '') return true
|
|
4679
|
+
return `${entry.hash}\n${recentLabel(entry.hash, titles)}`.toLowerCase().includes(typed)
|
|
4680
|
+
})
|
|
4681
|
+
emptyRecent()
|
|
4682
|
+
for (const entry of shown) {
|
|
4683
|
+
const label = recentLabel(entry.hash, titles)
|
|
4684
|
+
const item = document.createElement('button')
|
|
4685
|
+
item.type = 'button'
|
|
4686
|
+
if (typeof item.setAttribute === 'function') {
|
|
4687
|
+
item.setAttribute(RECENT_ITEM_ATTRIBUTE, entry.hash)
|
|
4688
|
+
item.setAttribute('role', 'option')
|
|
4689
|
+
item.setAttribute('title', `${entry.source === 'paste' ? '粘贴的链接' : '用过的界面'}:${readableFragment(entry.hash)}(右键复制链接)`)
|
|
4690
|
+
}
|
|
4691
|
+
const kind = document.createElement('span')
|
|
4692
|
+
kind.className = 'dshur-kind'
|
|
4693
|
+
kind.textContent = recentKind(parseRoute(entry.hash))
|
|
4694
|
+
item.appendChild(kind)
|
|
4695
|
+
if (label !== '') {
|
|
4696
|
+
const title = document.createElement('span')
|
|
4697
|
+
title.className = 'dshur-title'
|
|
4698
|
+
title.textContent = label
|
|
4699
|
+
item.appendChild(title)
|
|
4700
|
+
}
|
|
4701
|
+
const link = document.createElement('span')
|
|
4702
|
+
link.className = 'dshur-link'
|
|
4703
|
+
link.textContent = readableFragment(entry.hash)
|
|
4704
|
+
item.appendChild(link)
|
|
4705
|
+
item.addEventListener('mousedown', event => {
|
|
4706
|
+
// The click belongs to this list: the input has to keep focus, or the
|
|
4707
|
+
// press would blur it first and the list would be gone before the click.
|
|
4708
|
+
attemptValue(() => event?.preventDefault?.())
|
|
4709
|
+
})
|
|
4710
|
+
item.addEventListener('click', () => {
|
|
4711
|
+
narrowed = false
|
|
4712
|
+
hideRecent()
|
|
4713
|
+
mark('pending', undefined)
|
|
4714
|
+
// The box says where it is going at once; the settle below corrects it to
|
|
4715
|
+
// what actually landed (or says why nothing did).
|
|
4716
|
+
input.value = readableFragment(entry.hash)
|
|
4717
|
+
driveTypedFragment(entry.hash)
|
|
4718
|
+
settle(entry.hash, false)
|
|
4719
|
+
})
|
|
4720
|
+
item.addEventListener('contextmenu', event => {
|
|
4721
|
+
// Right-click copies instead of opening a menu: this list is the one place
|
|
4722
|
+
// where "give me this link" is the action a reader wants, and the browser's
|
|
4723
|
+
// own menu would only offer the same thing one level deeper.
|
|
4724
|
+
attemptValue(() => event?.preventDefault?.())
|
|
4725
|
+
const confirm = accepted => {
|
|
4726
|
+
if (accepted !== true) return
|
|
4727
|
+
flashCopied(item, link)
|
|
4728
|
+
flashToast('已复制链接')
|
|
4729
|
+
}
|
|
4730
|
+
const copied = copyLink(entry.hash)
|
|
4731
|
+
if (typeof copied?.then === 'function') copied.then(confirm)
|
|
4732
|
+
else confirm(copied)
|
|
4733
|
+
})
|
|
4734
|
+
recentList.appendChild(item)
|
|
4735
|
+
}
|
|
4736
|
+
if (shown.length === 0) {
|
|
4737
|
+
hideRecent()
|
|
4738
|
+
return
|
|
4739
|
+
}
|
|
4740
|
+
if (typeof recentList.setAttribute === 'function') recentList.setAttribute('data-open', '')
|
|
4741
|
+
}
|
|
4742
|
+
/**
|
|
4743
|
+
* Select everything in the bar, the way a browser's address bar does on its first
|
|
4744
|
+
* click. Off means the reader keeps the caret an ordinary text field gives them.
|
|
4745
|
+
* @returns {boolean} whether the text was selected.
|
|
4746
|
+
*/
|
|
4747
|
+
const selectAll = () => {
|
|
4748
|
+
if (!readSettings().selectAllOnFocus) return false
|
|
4749
|
+
return attemptValue(() => {
|
|
4750
|
+
if (typeof input.select !== 'function') return false
|
|
4751
|
+
input.select()
|
|
4752
|
+
return true
|
|
4753
|
+
}, false)
|
|
4754
|
+
}
|
|
4755
|
+
const onFocus = () => {
|
|
4756
|
+
// The copy's own field came and went: this is not the reader focusing the bar.
|
|
4757
|
+
if (quietFocus) return
|
|
4758
|
+
narrowed = false
|
|
4759
|
+
revealBar()
|
|
4760
|
+
selectAll()
|
|
4761
|
+
drawRecent()
|
|
4762
|
+
}
|
|
4763
|
+
const onMouseDown = event => {
|
|
4764
|
+
// Already focused: this press is placing a caret inside the link.
|
|
4765
|
+
if (document.activeElement === input) return
|
|
4766
|
+
if (!readSettings().selectAllOnFocus) return
|
|
4767
|
+
narrowed = false
|
|
4768
|
+
// The default of this very press would drop the selection made below.
|
|
4769
|
+
attemptValue(() => event?.preventDefault?.())
|
|
4770
|
+
attemptValue(() => input.focus?.())
|
|
4771
|
+
selectAll()
|
|
4772
|
+
drawRecent()
|
|
4773
|
+
}
|
|
4774
|
+
const onPaste = event => {
|
|
4775
|
+
const pasted = attemptValue(() => event?.clipboardData?.getData?.('text'))
|
|
4776
|
+
const fragment = attemptValue(() => titlebarFragmentOf(pasted))
|
|
4777
|
+
// Remembered as soon as it is a link this bar can open: a link that is already
|
|
4778
|
+
// on screen writes no route later, and would otherwise be forgotten entirely.
|
|
4779
|
+
if (typeof fragment === 'string' && fragment !== '') rememberRecent(fragment, 'paste')
|
|
4780
|
+
}
|
|
4781
|
+
const onInput = () => {
|
|
4782
|
+
narrowed = true
|
|
4783
|
+
drawRecent()
|
|
4784
|
+
}
|
|
4785
|
+
const onBlur = () => {
|
|
4786
|
+
if (quietFocus) return
|
|
4787
|
+
narrowed = false
|
|
4788
|
+
hideRecent()
|
|
4789
|
+
// The pointer may still be on the bar; concealBar() checks that and the list.
|
|
4790
|
+
attemptValue(() => window.setTimeout?.(concealBar, 0))
|
|
4791
|
+
}
|
|
2813
4792
|
const onKeyDown = event => {
|
|
2814
4793
|
if (event?.key === 'Escape') {
|
|
2815
4794
|
stopSettle()
|
|
4795
|
+
narrowed = false
|
|
4796
|
+
hideRecent()
|
|
2816
4797
|
mark('idle', undefined)
|
|
2817
4798
|
input.value = readableFragment(window.location?.hash)
|
|
2818
4799
|
return
|
|
2819
4800
|
}
|
|
2820
4801
|
if (event?.key !== 'Enter') return
|
|
4802
|
+
narrowed = false
|
|
4803
|
+
hideRecent()
|
|
2821
4804
|
const fragment = titlebarFragmentOf(input.value)
|
|
2822
4805
|
if (fragment === undefined) {
|
|
2823
4806
|
// Nothing here names a route: leave the address bar saying what is on screen.
|
|
@@ -2833,7 +4816,47 @@ window.__ModuleLoader__.load({
|
|
|
2833
4816
|
driveTypedFragment(fragment)
|
|
2834
4817
|
settle(fragment, false)
|
|
2835
4818
|
}
|
|
4819
|
+
/** Whether the bar is asked to stay out of sight until the pointer is on it. */
|
|
4820
|
+
const hoverMode = () => readSettings().hoverReveal === true
|
|
4821
|
+
/** Whether the drop-down is up (a reveal must outlive the pointer leaving). */
|
|
4822
|
+
const recentIsOpen = () => attemptValue(() => recentList.getAttribute?.('data-open') !== null, false)
|
|
4823
|
+
/** Whether the reader is typing in the bar. */
|
|
4824
|
+
const inputIsFocused = () => attemptValue(() => window.document?.activeElement === input, false)
|
|
4825
|
+
/** Show the bar; only meaningful in hover mode (the stylesheet does the rest). */
|
|
4826
|
+
const revealBar = () => {
|
|
4827
|
+
if (!hoverMode()) return
|
|
4828
|
+
attemptValue(() => bar.setAttribute?.(SHOWN_ATTRIBUTE, ''))
|
|
4829
|
+
}
|
|
4830
|
+
/** Hide it again — unless a focused field or an open list still needs it visible. */
|
|
4831
|
+
const concealBar = () => {
|
|
4832
|
+
if (!hoverMode()) return
|
|
4833
|
+
if (inputIsFocused() || recentIsOpen()) return
|
|
4834
|
+
attemptValue(() => bar.removeAttribute?.(SHOWN_ATTRIBUTE))
|
|
4835
|
+
}
|
|
4836
|
+
/**
|
|
4837
|
+
* Put the bar into the state the preference asks for. Called once at mount, again
|
|
4838
|
+
* whenever the preference changes, and never while a route is being written (the
|
|
4839
|
+
* bar's contents are independent of whether it is visible).
|
|
4840
|
+
*/
|
|
4841
|
+
const applyHoverMode = () => {
|
|
4842
|
+
attemptValue(() => {
|
|
4843
|
+
if (hoverMode()) bar.setAttribute?.(HOVER_ATTRIBUTE, '')
|
|
4844
|
+
else {
|
|
4845
|
+
bar.removeAttribute?.(HOVER_ATTRIBUTE)
|
|
4846
|
+
bar.removeAttribute?.(SHOWN_ATTRIBUTE)
|
|
4847
|
+
}
|
|
4848
|
+
})
|
|
4849
|
+
}
|
|
4850
|
+
bar.addEventListener?.('mouseenter', revealBar)
|
|
4851
|
+
bar.addEventListener?.('mouseleave', concealBar)
|
|
2836
4852
|
input.addEventListener('keydown', onKeyDown)
|
|
4853
|
+
input.addEventListener('focus', onFocus)
|
|
4854
|
+
input.addEventListener('mousedown', onMouseDown)
|
|
4855
|
+
input.addEventListener('input', onInput)
|
|
4856
|
+
input.addEventListener('paste', onPaste)
|
|
4857
|
+
input.addEventListener('blur', onBlur)
|
|
4858
|
+
applyHoverMode()
|
|
4859
|
+
const offHoverSettings = subscribeSettings(() => applyHoverMode())
|
|
2837
4860
|
// Both platforms mount the bar in the body: Windows pins it into the
|
|
2838
4861
|
// title-bar strip, macOS pins it over the resident header row.
|
|
2839
4862
|
document.body.appendChild(bar)
|
|
@@ -2847,9 +4870,12 @@ window.__ModuleLoader__.load({
|
|
|
2847
4870
|
* node, so what was typed into it survives.
|
|
2848
4871
|
*/
|
|
2849
4872
|
let headerWatcher
|
|
2850
|
-
|
|
4873
|
+
// The window owns both, so a page that replaces `document` (or a host with its own
|
|
4874
|
+
// observers) still gets the watcher; a global-only read silently found nothing.
|
|
4875
|
+
const Observer = attemptValue(() => window.MutationObserver)
|
|
4876
|
+
if (macInHeader && typeof Observer === 'function') {
|
|
2851
4877
|
try {
|
|
2852
|
-
headerWatcher = new
|
|
4878
|
+
headerWatcher = new Observer(() => schedulePlace())
|
|
2853
4879
|
headerWatcher.observe(document.body, { childList: true, subtree: true })
|
|
2854
4880
|
} catch {
|
|
2855
4881
|
headerWatcher = undefined
|
|
@@ -2866,7 +4892,19 @@ window.__ModuleLoader__.load({
|
|
|
2866
4892
|
return () => {
|
|
2867
4893
|
offRoute?.()
|
|
2868
4894
|
stopSettle()
|
|
4895
|
+
if (placeFrame !== undefined) attemptValue(() => window.cancelAnimationFrame?.(placeFrame))
|
|
2869
4896
|
input.removeEventListener?.('keydown', onKeyDown)
|
|
4897
|
+
input.removeEventListener?.('focus', onFocus)
|
|
4898
|
+
input.removeEventListener?.('mousedown', onMouseDown)
|
|
4899
|
+
input.removeEventListener?.('input', onInput)
|
|
4900
|
+
input.removeEventListener?.('paste', onPaste)
|
|
4901
|
+
input.removeEventListener?.('blur', onBlur)
|
|
4902
|
+
bar.removeEventListener?.('mouseenter', revealBar)
|
|
4903
|
+
bar.removeEventListener?.('mouseleave', concealBar)
|
|
4904
|
+
offHoverSettings?.()
|
|
4905
|
+
for (const timer of copiedTimers) attemptValue(() => window.clearTimeout(timer))
|
|
4906
|
+
copiedTimers.clear()
|
|
4907
|
+
if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
|
|
2870
4908
|
if (onResize !== undefined) window.removeEventListener?.('resize', onResize)
|
|
2871
4909
|
headerWatcher?.disconnect?.()
|
|
2872
4910
|
bar.remove?.()
|
|
@@ -2874,6 +4912,173 @@ window.__ModuleLoader__.load({
|
|
|
2874
4912
|
}
|
|
2875
4913
|
}
|
|
2876
4914
|
|
|
4915
|
+
// ---------------------------------------------------------------------
|
|
4916
|
+
// settings page: the plugin manager's row page is this plugin's settings surface
|
|
4917
|
+
//
|
|
4918
|
+
// The manager dispatches a row's configuration under `<package name>#<row id>` and
|
|
4919
|
+
// renders the entry twice: once as the row's one-line summary and once as the page
|
|
4920
|
+
// the row's configure control opens. Registering here is what turns
|
|
4921
|
+
// `#/panel/plugins/dsh-url-router/dsh-url-router` from a read-only overview into the
|
|
4922
|
+
// page these two preferences are set on — the same shape every other plugin in this
|
|
4923
|
+
// profile has, instead of a settings tab of its own.
|
|
4924
|
+
// ---------------------------------------------------------------------
|
|
4925
|
+
/** The card's stylesheet id, so a re-apply replaces its own rather than stacking copies. */
|
|
4926
|
+
const SETTINGS_STYLE_ID = 'dsh-url-router-settings-style'
|
|
4927
|
+
/** The card's own styles: the manager's class names are hashed, so it cannot borrow them. */
|
|
4928
|
+
const SETTINGS_STYLE_TEXT = [
|
|
4929
|
+
'.dshur{display:flex;flex-direction:column;gap:8px;color:var(--dsw-alias-label-primary,inherit);font-size:13px;line-height:20px}',
|
|
4930
|
+
'.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}',
|
|
4931
|
+
'.dshur-row{display:flex;align-items:center;gap:10px;justify-content:space-between}',
|
|
4932
|
+
'.dshur-label{display:flex;align-items:center;gap:8px;font-weight:500;cursor:pointer}',
|
|
4933
|
+
'.dshur-check{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
4934
|
+
'.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}',
|
|
4935
|
+
'.dshur-number:focus{outline:none;border-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
4936
|
+
'.dshur-state{color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
|
|
4937
|
+
'.dshur-hint{margin:0;color:var(--dsw-alias-label-tertiary,#81858c);font-size:12px;line-height:18px}',
|
|
4938
|
+
'.dshur-meta{display:flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary,#4b5872);font-size:12px}',
|
|
4939
|
+
'.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}',
|
|
4940
|
+
'.dshur-clear:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}',
|
|
4941
|
+
].join('')
|
|
4942
|
+
|
|
4943
|
+
/**
|
|
4944
|
+
* The settings card on this plugin's row page.
|
|
4945
|
+
*
|
|
4946
|
+
* The manager calls it twice: with `view: 'summary'` for the row's one-liner (it must
|
|
4947
|
+
* stay cheap — it renders on every row paint and must not touch the disk) and with the
|
|
4948
|
+
* page's own view for the settings themselves.
|
|
4949
|
+
* @param {object} props - slot props, `view` names which of the two is wanted.
|
|
4950
|
+
* @returns {object|string} the element to draw, or the one-liner.
|
|
4951
|
+
*/
|
|
4952
|
+
function RouterSettingsCard({ view }) {
|
|
4953
|
+
const h = react.createElement
|
|
4954
|
+
const [settings, setSettings] = react.useState(readSettings())
|
|
4955
|
+
const [draft, setDraft] = react.useState(String(readSettings().recentLimit))
|
|
4956
|
+
const [remembered, setRemembered] = react.useState(() => recentEntries().length)
|
|
4957
|
+
react.useEffect(() => subscribeSettings(next => {
|
|
4958
|
+
setSettings(next)
|
|
4959
|
+
setDraft(String(next.recentLimit))
|
|
4960
|
+
setRemembered(recentEntries().length)
|
|
4961
|
+
}), [])
|
|
4962
|
+
if (view === 'summary') {
|
|
4963
|
+
return `全选 ${settings.selectAllOnFocus ? '开' : '关'} · 悬浮显示 ${settings.hoverReveal ? '开' : '关'} · 记忆 ${settings.recentLimit} 条`
|
|
4964
|
+
}
|
|
4965
|
+
/**
|
|
4966
|
+
* Take a typed count: an empty field, a minus sign or anything else unusable is a
|
|
4967
|
+
* half-typed value and must not be committed as 0.
|
|
4968
|
+
* @param {string} text - what the field holds.
|
|
4969
|
+
*/
|
|
4970
|
+
const commitCount = text => {
|
|
4971
|
+
setDraft(text)
|
|
4972
|
+
const number = Number(text)
|
|
4973
|
+
if (String(text).trim() === '' || !Number.isFinite(number) || number < 0) return
|
|
4974
|
+
setSettings(updateSettings({ recentLimit: Math.floor(number) }))
|
|
4975
|
+
}
|
|
4976
|
+
return h('div', { className: 'dshur' }, [
|
|
4977
|
+
h('div', { className: 'dshur-block', key: 'select' }, [
|
|
4978
|
+
h('div', { className: 'dshur-row' }, [
|
|
4979
|
+
h('label', { className: 'dshur-label' }, [
|
|
4980
|
+
h('input', {
|
|
4981
|
+
type: 'checkbox',
|
|
4982
|
+
className: 'dshur-check',
|
|
4983
|
+
checked: settings.selectAllOnFocus,
|
|
4984
|
+
onChange: event => setSettings(updateSettings({ selectAllOnFocus: event?.target?.checked === true })),
|
|
4985
|
+
}),
|
|
4986
|
+
h('span', null, '首次点击全选链接'),
|
|
4987
|
+
]),
|
|
4988
|
+
h('span', { className: 'dshur-state' }, settings.selectAllOnFocus ? '开' : '关'),
|
|
4989
|
+
]),
|
|
4990
|
+
h('p', { className: 'dshur-hint' }, '第一次点进去整条选中,直接输入就替换;已在框内再点,才放光标。'),
|
|
4991
|
+
]),
|
|
4992
|
+
h('div', { className: 'dshur-block', key: 'hover' }, [
|
|
4993
|
+
h('div', { className: 'dshur-row' }, [
|
|
4994
|
+
h('label', { className: 'dshur-label' }, [
|
|
4995
|
+
h('input', {
|
|
4996
|
+
type: 'checkbox',
|
|
4997
|
+
className: 'dshur-check',
|
|
4998
|
+
checked: settings.hoverReveal,
|
|
4999
|
+
onChange: event => setSettings(updateSettings({ hoverReveal: event?.target?.checked === true })),
|
|
5000
|
+
}),
|
|
5001
|
+
h('span', null, '悬浮时才显示地址栏'),
|
|
5002
|
+
]),
|
|
5003
|
+
h('span', { className: 'dshur-state' }, settings.hoverReveal ? '开' : '关'),
|
|
5004
|
+
]),
|
|
5005
|
+
h('p', { className: 'dshur-hint' }, '平时隐去,鼠标移到地址栏的位置才出现。'),
|
|
5006
|
+
]),
|
|
5007
|
+
h('div', { className: 'dshur-block', key: 'recent' }, [
|
|
5008
|
+
h('div', { className: 'dshur-row' }, [
|
|
5009
|
+
h('label', { className: 'dshur-label', htmlFor: 'dshur-recent-limit' }, '记住最近链接的条数'),
|
|
5010
|
+
h('input', {
|
|
5011
|
+
id: 'dshur-recent-limit',
|
|
5012
|
+
type: 'number',
|
|
5013
|
+
min: 0,
|
|
5014
|
+
step: 1,
|
|
5015
|
+
inputMode: 'numeric',
|
|
5016
|
+
className: 'dshur-number',
|
|
5017
|
+
value: draft,
|
|
5018
|
+
onChange: event => commitCount(event?.target?.value ?? ''),
|
|
5019
|
+
onBlur: () => setDraft(String(readSettings().recentLimit)),
|
|
5020
|
+
}),
|
|
5021
|
+
]),
|
|
5022
|
+
h('p', { className: 'dshur-hint' }, '下拉框里能看到的条数,默认 8;0 = 不记忆、不弹列表;没有上限。当前界面不占名额。'),
|
|
5023
|
+
h('p', { className: 'dshur-meta' }, [
|
|
5024
|
+
h('span', { key: 'count' }, `已记住 ${remembered} 条`),
|
|
5025
|
+
h('button', {
|
|
5026
|
+
type: 'button',
|
|
5027
|
+
className: 'dshur-clear',
|
|
5028
|
+
key: 'clear',
|
|
5029
|
+
onClick: () => {
|
|
5030
|
+
clearRecent()
|
|
5031
|
+
setRemembered(0)
|
|
5032
|
+
},
|
|
5033
|
+
}, '清空记录'),
|
|
5034
|
+
]),
|
|
5035
|
+
]),
|
|
5036
|
+
])
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
/**
|
|
5040
|
+
* Register the settings card with the plugin manager.
|
|
5041
|
+
*
|
|
5042
|
+
* A composition without the manager, without the slot service, or without React has
|
|
5043
|
+
* no card and needs none: nothing else in the plugin depends on this.
|
|
5044
|
+
* @param {object} ctx - client context.
|
|
5045
|
+
* @returns {() => void} the disposer.
|
|
5046
|
+
*/
|
|
5047
|
+
function startSettingsCard(ctx) {
|
|
5048
|
+
if (react === undefined || typeof window === 'undefined' || window.document === undefined) return () => {}
|
|
5049
|
+
const slots = ctx?.slots
|
|
5050
|
+
if (typeof slots?.inject !== 'function' || typeof slots?.register !== 'function') return () => {}
|
|
5051
|
+
const document = window.document
|
|
5052
|
+
let style
|
|
5053
|
+
try {
|
|
5054
|
+
style = document.createElement('style')
|
|
5055
|
+
style.id = SETTINGS_STYLE_ID
|
|
5056
|
+
style.textContent = SETTINGS_STYLE_TEXT
|
|
5057
|
+
const parent = document.head ?? document.body
|
|
5058
|
+
if (typeof parent?.appendChild === 'function') parent.appendChild(style)
|
|
5059
|
+
} catch {
|
|
5060
|
+
style = undefined
|
|
5061
|
+
}
|
|
5062
|
+
let off
|
|
5063
|
+
try {
|
|
5064
|
+
off = slots.inject('plugins.row.config', () => slots.register({
|
|
5065
|
+
name: 'plugins.row.config',
|
|
5066
|
+
key: ROW_CONFIG_KEY,
|
|
5067
|
+
}, RouterSettingsCard))
|
|
5068
|
+
} catch {
|
|
5069
|
+
// A page that never declares the slot is a page without a card, not a failure.
|
|
5070
|
+
off = undefined
|
|
5071
|
+
}
|
|
5072
|
+
return () => {
|
|
5073
|
+
try {
|
|
5074
|
+
if (typeof off === 'function') off()
|
|
5075
|
+
} catch {
|
|
5076
|
+
// A slot service that is already gone needs no undo.
|
|
5077
|
+
}
|
|
5078
|
+
style?.remove?.()
|
|
5079
|
+
}
|
|
5080
|
+
}
|
|
5081
|
+
|
|
2877
5082
|
function apply(ctx) {
|
|
2878
5083
|
// Fragment routing: the address bar names the view, and a link opens it.
|
|
2879
5084
|
ctx.effect(() => {
|
|
@@ -2928,6 +5133,10 @@ window.__ModuleLoader__.load({
|
|
|
2928
5133
|
if (typeof window === 'undefined' || window.document === undefined) return () => {}
|
|
2929
5134
|
return startTitlebarAddress(window.document, subscribeRoute)
|
|
2930
5135
|
}, 'url-router: desktop address bar')
|
|
5136
|
+
// Settings page: the plugin manager's row page carries the two preferences, so
|
|
5137
|
+
// there is no separate settings tab to find them in.
|
|
5138
|
+
ctx.effect(() => startSettingsCard(ctx), 'url-router: settings card')
|
|
5139
|
+
|
|
2931
5140
|
}
|
|
2932
5141
|
|
|
2933
5142
|
exports.apply = apply
|