dsh-url-router 0.4.2 → 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 +65 -4
- package/README.md +13 -5
- package/lib/client.js +939 -82
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -78,7 +78,69 @@ window.__ModuleLoader__.load({
|
|
|
78
78
|
// another router's route may legitimately be pasted in.
|
|
79
79
|
// ---------------------------------------------------------------------
|
|
80
80
|
/** Route kind aliases accepted on input. */
|
|
81
|
-
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'
|
|
82
144
|
/** Attribute the settings overlay puts on its root element. */
|
|
83
145
|
const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
|
|
84
146
|
/**
|
|
@@ -93,6 +155,8 @@ window.__ModuleLoader__.load({
|
|
|
93
155
|
const SETTINGS_SURFACE_VALUE = 'settings'
|
|
94
156
|
/** Slot the settings entry is rendered into (its button opens the overlay). */
|
|
95
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'
|
|
96
160
|
/** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
|
|
97
161
|
const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
|
|
98
162
|
/** How often an unsatisfied link is retried while it is authoritative. */
|
|
@@ -137,6 +201,48 @@ window.__ModuleLoader__.load({
|
|
|
137
201
|
const TURN_SEGMENT = 'turn'
|
|
138
202
|
/** Inner segment that names the right-hand pane in front. */
|
|
139
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
|
+
}
|
|
140
246
|
/** Service the right-hand pane registers, whose snapshot names the active tab. */
|
|
141
247
|
const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
|
|
142
248
|
/** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
|
|
@@ -166,14 +272,14 @@ window.__ModuleLoader__.load({
|
|
|
166
272
|
* The router's own version, for the report a human files. A test keeps it equal to the
|
|
167
273
|
* package version, so a release cannot forget to bump it.
|
|
168
274
|
*/
|
|
169
|
-
const ROUTER_VERSION = '0.4.
|
|
275
|
+
const ROUTER_VERSION = '0.4.3'
|
|
170
276
|
/**
|
|
171
277
|
* Hand-maintained build marker, reported alongside the version. A working copy that is
|
|
172
278
|
* linked into a profile serves this file straight from disk, so the package version
|
|
173
279
|
* cannot say *which* copy a live window loaded — this can, and `report().build` is how a
|
|
174
280
|
* real-machine check answers that. Bump it whenever a live window must be told apart.
|
|
175
281
|
*/
|
|
176
|
-
const ROUTER_BUILD = 'desktop-bar+settings-marker+modal-dismiss+mac-header-bar+bar-select-all+recent-links+copy-link+automation-links+page-over-pane+band-yield+provider-card+perf+compat+turn-jump.
|
|
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'
|
|
177
283
|
|
|
178
284
|
const TAG = '[url-router]'
|
|
179
285
|
|
|
@@ -228,11 +334,22 @@ window.__ModuleLoader__.load({
|
|
|
228
334
|
* has no power button. A couple of pixels is rounding, more is a resize.
|
|
229
335
|
*/
|
|
230
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'
|
|
231
346
|
const MAC_STYLE_TEXT = [
|
|
232
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}`,
|
|
233
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}`,
|
|
234
349
|
`#${TITLEBAR_BAR_ID}[data-dsh-url-router-platform="mac"] input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
235
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}`,
|
|
236
353
|
].join('')
|
|
237
354
|
/** Where the bar's last spot is kept, so a screen without a power button still gets it. */
|
|
238
355
|
const MAC_PLACEMENT_KEY = 'dsh-url-router-placement'
|
|
@@ -424,6 +541,8 @@ window.__ModuleLoader__.load({
|
|
|
424
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}`,
|
|
425
542
|
`#${TITLEBAR_BAR_ID} input:focus{border-color:var(--dsw-alias-brand-primary,rgba(79,124,255,.9))}`,
|
|
426
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}`,
|
|
427
546
|
].join('')
|
|
428
547
|
/**
|
|
429
548
|
* The recent list's own stylesheet: a browser-style drop-down under the bar.
|
|
@@ -492,7 +611,7 @@ window.__ModuleLoader__.load({
|
|
|
492
611
|
/** Remembered links, newest first. */
|
|
493
612
|
const RECENT_KEY = 'dsh-url-router-recent'
|
|
494
613
|
/** What the bar does before the settings page has ever been opened. */
|
|
495
|
-
const DEFAULT_SETTINGS = { selectAllOnFocus: true, recentLimit: 8 }
|
|
614
|
+
const DEFAULT_SETTINGS = { selectAllOnFocus: true, hoverReveal: false, recentLimit: 8 }
|
|
496
615
|
/**
|
|
497
616
|
* The key the plugin manager dispatches a row's configuration under:
|
|
498
617
|
* `<package name>#<row id>`. This package declares one row whose package name and
|
|
@@ -516,13 +635,14 @@ window.__ModuleLoader__.load({
|
|
|
516
635
|
/**
|
|
517
636
|
* The settings record, with anything unusable replaced by its default.
|
|
518
637
|
* @param {unknown} raw - the parsed record, or anything that was on disk.
|
|
519
|
-
* @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record.
|
|
638
|
+
* @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record.
|
|
520
639
|
*/
|
|
521
640
|
function normaliseSettings(raw) {
|
|
522
641
|
const source = raw !== null && typeof raw === 'object' ? raw : {}
|
|
523
642
|
const limit = Number(source.recentLimit)
|
|
524
643
|
return {
|
|
525
644
|
selectAllOnFocus: typeof source.selectAllOnFocus === 'boolean' ? source.selectAllOnFocus : DEFAULT_SETTINGS.selectAllOnFocus,
|
|
645
|
+
hoverReveal: typeof source.hoverReveal === 'boolean' ? source.hoverReveal : DEFAULT_SETTINGS.hoverReveal,
|
|
526
646
|
// A count of remembered links: whole, never negative, and with no upper bound
|
|
527
647
|
// the reader did not ask for.
|
|
528
648
|
recentLimit: Number.isFinite(limit) && limit >= 0 ? Math.floor(limit) : DEFAULT_SETTINGS.recentLimit,
|
|
@@ -531,7 +651,7 @@ window.__ModuleLoader__.load({
|
|
|
531
651
|
|
|
532
652
|
/**
|
|
533
653
|
* Read the settings, once.
|
|
534
|
-
* @returns {{selectAllOnFocus: boolean, recentLimit: number}} the record in force.
|
|
654
|
+
* @returns {{selectAllOnFocus: boolean, hoverReveal: boolean, recentLimit: number}} the record in force.
|
|
535
655
|
*/
|
|
536
656
|
function readSettings() {
|
|
537
657
|
if (settingsCache !== undefined) return settingsCache
|
|
@@ -620,6 +740,8 @@ window.__ModuleLoader__.load({
|
|
|
620
740
|
function recentIdentity(hash) {
|
|
621
741
|
const route = parseRoute(String(hash ?? ''))
|
|
622
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 ''
|
|
623
745
|
if (route.kind === 'workspace') {
|
|
624
746
|
const inner = innerPath(route.inner)
|
|
625
747
|
return inner.length >= 2 ? `${ROW_KEY_PREFIX}${inner[1]}` : `${route.kind}:${route.id}`
|
|
@@ -778,6 +900,30 @@ window.__ModuleLoader__.load({
|
|
|
778
900
|
return ids.length === 0 ? undefined : ids
|
|
779
901
|
}
|
|
780
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
|
+
|
|
781
927
|
/**
|
|
782
928
|
* Read a value, or undefined when reading it throws.
|
|
783
929
|
* @param {Function} read - the reader.
|
|
@@ -1106,11 +1252,134 @@ window.__ModuleLoader__.load({
|
|
|
1106
1252
|
* @param {string|Array<string>} inner - the inner path.
|
|
1107
1253
|
* @returns {{tab: string|undefined, provider: string|undefined}} the parts.
|
|
1108
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
|
+
|
|
1109
1378
|
function settingsPartsOf(inner) {
|
|
1110
1379
|
const parts = innerPath(inner)
|
|
1111
1380
|
const at = parts.indexOf(PROVIDER_SEGMENT)
|
|
1112
|
-
|
|
1113
|
-
return {
|
|
1381
|
+
const levels = at < 0 ? parts : parts.slice(0, at)
|
|
1382
|
+
return { levels, provider: at < 0 ? undefined : parts[at + 1] }
|
|
1114
1383
|
}
|
|
1115
1384
|
|
|
1116
1385
|
/**
|
|
@@ -1269,7 +1538,7 @@ window.__ModuleLoader__.load({
|
|
|
1269
1538
|
const kind = ROUTE_ALIASES[match[1]]
|
|
1270
1539
|
if (kind === undefined) return { unknown: match[1] }
|
|
1271
1540
|
// A modal surface needs no identity: `#/settings` is the whole route.
|
|
1272
|
-
if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
|
|
1541
|
+
if (match[2] === undefined) return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
|
|
1273
1542
|
// A conversation can be named inside its workspace — the shape the sidebar itself shows,
|
|
1274
1543
|
// and the only way a link says *where* a conversation lives.
|
|
1275
1544
|
if (kind === 'workspace') {
|
|
@@ -1296,9 +1565,9 @@ window.__ModuleLoader__.load({
|
|
|
1296
1565
|
}
|
|
1297
1566
|
const segments = match[2].replace(/\/+$/, '').split('/')
|
|
1298
1567
|
const head = segments.shift() ?? ''
|
|
1299
|
-
if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
|
|
1568
|
+
if (head === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
|
|
1300
1569
|
const id = decodePart(head).trim()
|
|
1301
|
-
if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
|
|
1570
|
+
if (id === '') return kind === 'settings' || kind === 'power' ? { kind, id: '' } : undefined
|
|
1302
1571
|
// A settings section has no stable id in the DOM (measured: the nav item carries only
|
|
1303
1572
|
// aria-current), so its identity is its *label* — the string the user actually sees and
|
|
1304
1573
|
// clicks. Round-tripping the label keeps the link self-consistent; a locale change
|
|
@@ -1324,6 +1593,10 @@ window.__ModuleLoader__.load({
|
|
|
1324
1593
|
function routeHash(kind, id, inner) {
|
|
1325
1594
|
// A modal surface has no identity: `#/settings`, not `#/settings/`.
|
|
1326
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)}`
|
|
1327
1600
|
if (kind === 'workspace') {
|
|
1328
1601
|
const rest = innerPath(inner)
|
|
1329
1602
|
const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
|
|
@@ -1493,6 +1766,10 @@ window.__ModuleLoader__.load({
|
|
|
1493
1766
|
panelAttempts = 0
|
|
1494
1767
|
innerAttempts = 0
|
|
1495
1768
|
paneOpenedFor = undefined
|
|
1769
|
+
powerActionSent = false
|
|
1770
|
+
powerActionAttempts = 0
|
|
1771
|
+
linkedTyped = false
|
|
1772
|
+
attemptValue(() => host.resetSettingsDrive?.())
|
|
1496
1773
|
dropTimer()
|
|
1497
1774
|
}
|
|
1498
1775
|
/**
|
|
@@ -1500,15 +1777,19 @@ window.__ModuleLoader__.load({
|
|
|
1500
1777
|
* conversation behind it, because the panel is what fills the screen.
|
|
1501
1778
|
*/
|
|
1502
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: '' }
|
|
1503
1784
|
// The settings overlay covers the main column, so while it is up it is what is on
|
|
1504
1785
|
// screen; when it closes the ordinary surface takes the address bar back.
|
|
1505
1786
|
if (settingsOverlayOf(window.document) !== undefined) {
|
|
1506
1787
|
const section = host.activeSettingsSection() ?? ''
|
|
1507
|
-
const
|
|
1788
|
+
const levels = section === '' ? [] : (attemptValue(() => host.activeSettingsLevels?.()) ?? [])
|
|
1508
1789
|
// A model-provider card whose editor is open is a page of its own: its id rides
|
|
1509
1790
|
// after the section (and after the tab, when the section has one).
|
|
1510
1791
|
const provider = section === '' ? undefined : host.activeSettingsProvider()
|
|
1511
|
-
const inner = [...
|
|
1792
|
+
const inner = [...levels, ...(provider === undefined ? [] : [PROVIDER_SEGMENT, provider])]
|
|
1512
1793
|
return inner.length === 0 ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner }
|
|
1513
1794
|
}
|
|
1514
1795
|
const panelId = host.activePanelId()
|
|
@@ -1551,7 +1832,14 @@ window.__ModuleLoader__.load({
|
|
|
1551
1832
|
// but never while a turn link is in flight: that link owns the address bar until it lands,
|
|
1552
1833
|
// and naming the turn here too is what made it look "landed" before it ever drove.
|
|
1553
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)
|
|
1554
1841
|
const position = [
|
|
1842
|
+
...(subagentLabel === undefined || subagentLabel === '' ? [] : [SUBAGENT_SEGMENT, subagentLabel]),
|
|
1555
1843
|
...(rowAnchor !== undefined && rowAnchor.session === id ? (rowAnchor.turn === undefined || rowAnchor.turn === '' ? [ROW_SEGMENT, ...rowAnchor.parts] : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]) : []),
|
|
1556
1844
|
...(anchoredTurn.length > 0 && rowAnchor !== undefined && rowAnchor.session === id ? [] : anchoredTurn),
|
|
1557
1845
|
...(view !== undefined && view.index !== 0 ? [VIEW_SEGMENT, view.id] : []),
|
|
@@ -1590,6 +1878,11 @@ window.__ModuleLoader__.load({
|
|
|
1590
1878
|
if (route?.kind === 'session') return route.id
|
|
1591
1879
|
return undefined
|
|
1592
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
|
+
}
|
|
1593
1886
|
const viewIdOfRoute = route => {
|
|
1594
1887
|
const parts = innerPath(route?.inner)
|
|
1595
1888
|
const at = parts.indexOf(VIEW_SEGMENT)
|
|
@@ -1672,7 +1965,10 @@ window.__ModuleLoader__.load({
|
|
|
1672
1965
|
if (current.id !== linked.id) return false
|
|
1673
1966
|
const wanted = settingsPartsOf(linked.inner)
|
|
1674
1967
|
const shown = settingsPartsOf(current.inner)
|
|
1675
|
-
|
|
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
|
|
1676
1972
|
// A provider card is a page one level deeper than its section: a link that names one
|
|
1677
1973
|
// has to see that one open (the section alone is satisfied by any of its cards).
|
|
1678
1974
|
return wanted.provider === undefined || wanted.provider === shown.provider
|
|
@@ -1747,6 +2043,8 @@ window.__ModuleLoader__.load({
|
|
|
1747
2043
|
// (and re-entry can drop the link), so every read below must use the snapshot: reading the
|
|
1748
2044
|
// live binding again is how a drive turned into "Cannot read properties of undefined".
|
|
1749
2045
|
const link = linked
|
|
2046
|
+
// The parent a subagent link names (decoration: the child alone is what opens).
|
|
2047
|
+
const linkParent = subagentParentOfRoute(link)
|
|
1750
2048
|
|
|
1751
2049
|
if (sameRoute(current, link)) {
|
|
1752
2050
|
dropRetry()
|
|
@@ -1807,7 +2105,7 @@ window.__ModuleLoader__.load({
|
|
|
1807
2105
|
const shown = host.mainViewSessionId()
|
|
1808
2106
|
if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1809
2107
|
panelAttempts += 1
|
|
1810
|
-
host.openSession(rowSession)
|
|
2108
|
+
host.openSession(rowSession, linkParent)
|
|
1811
2109
|
} else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1812
2110
|
innerAttempts += 1
|
|
1813
2111
|
if (!host.revealPosition(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealPosition(rowParts.slice(wantedTurn, wantedTurn + 2), rowSession)
|
|
@@ -1823,7 +2121,7 @@ window.__ModuleLoader__.load({
|
|
|
1823
2121
|
const shown = host.mainViewSessionId()
|
|
1824
2122
|
if (shown !== wantedSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1825
2123
|
panelAttempts += 1
|
|
1826
|
-
host.openSession(wantedSession)
|
|
2124
|
+
host.openSession(wantedSession, linkParent)
|
|
1827
2125
|
} else if (shown === wantedSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1828
2126
|
innerAttempts += 1
|
|
1829
2127
|
host.openConversationView(wantedView)
|
|
@@ -1838,7 +2136,88 @@ window.__ModuleLoader__.load({
|
|
|
1838
2136
|
const shown = host.mainViewSessionId()
|
|
1839
2137
|
if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1840
2138
|
panelAttempts += 1
|
|
1841
|
-
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()
|
|
1842
2221
|
}
|
|
1843
2222
|
armRetry()
|
|
1844
2223
|
return
|
|
@@ -1847,7 +2226,7 @@ window.__ModuleLoader__.load({
|
|
|
1847
2226
|
if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
1848
2227
|
innerAttempts += 1
|
|
1849
2228
|
const parts = settingsPartsOf(link.inner)
|
|
1850
|
-
host.openSettings(link.id, parts.
|
|
2229
|
+
host.openSettings(link.id, parts.levels, parts.provider)
|
|
1851
2230
|
}
|
|
1852
2231
|
armRetry()
|
|
1853
2232
|
return
|
|
@@ -1891,7 +2270,7 @@ window.__ModuleLoader__.load({
|
|
|
1891
2270
|
// idempotent on a conversation that is not shown yet (no seats, nothing to scroll to, and
|
|
1892
2271
|
// the paging path is a no-op too), and waiting for the main-view counter to catch up is
|
|
1893
2272
|
// what left a cold-opened turn link doing nothing at all.
|
|
1894
|
-
host.openSession(wantedSession)
|
|
2273
|
+
host.openSession(wantedSession, linkParent)
|
|
1895
2274
|
host.revealPosition(innerPath([TURN_SEGMENT, wantedTurn]), wantedSession)
|
|
1896
2275
|
// A row may follow the turn (`turn/<n>/row/…`): reveal it after the turn.
|
|
1897
2276
|
const rowAt = innerPath(link.inner).indexOf(ROW_SEGMENT)
|
|
@@ -1962,7 +2341,7 @@ window.__ModuleLoader__.load({
|
|
|
1962
2341
|
}
|
|
1963
2342
|
if ((!claimed || moved) && host.isOpenable(link.id)) {
|
|
1964
2343
|
claimed = true
|
|
1965
|
-
host.openSession(link.id)
|
|
2344
|
+
host.openSession(link.id, linkParent)
|
|
1966
2345
|
}
|
|
1967
2346
|
}
|
|
1968
2347
|
/** Adopt the surface the address bar names, if it names one. */
|
|
@@ -1996,6 +2375,7 @@ window.__ModuleLoader__.load({
|
|
|
1996
2375
|
return
|
|
1997
2376
|
}
|
|
1998
2377
|
if (linked === undefined || linked.kind !== parsed.kind || linked.id !== parsed.id) {
|
|
2378
|
+
linkedTyped = typedClaim
|
|
1999
2379
|
linked = { kind: parsed.kind, id: parsed.id, inner: parsed.inner, deadline: host.now() + LINK_ENFORCE_WINDOW_MS }
|
|
2000
2380
|
if (parsed.kind === 'panel' && parsed.inner !== undefined) panelScope = innerPath(parsed.inner)[0]
|
|
2001
2381
|
claimed = false
|
|
@@ -2048,7 +2428,43 @@ window.__ModuleLoader__.load({
|
|
|
2048
2428
|
retryTimer?.()
|
|
2049
2429
|
retryTimer = undefined
|
|
2050
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
|
|
2051
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
|
+
}
|
|
2052
2468
|
// The desktop address bar hands its fragment over here instead of relying on the
|
|
2053
2469
|
// browser: a programmatic hash write fires `hashchange` in a browser, but a hash
|
|
2054
2470
|
// equal to the current one fires nothing at all, and the desktop shell serves the
|
|
@@ -2056,15 +2472,34 @@ window.__ModuleLoader__.load({
|
|
|
2056
2472
|
// listener uses makes the bar work in both cases. The hash is still written, so the
|
|
2057
2473
|
// address bar, Back/Forward and copied links all agree with what is on screen.
|
|
2058
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
|
|
2059
2487
|
attemptValue(() => {
|
|
2060
2488
|
window.location.hash = fragment
|
|
2061
2489
|
})
|
|
2062
2490
|
safeReadLink()
|
|
2491
|
+
typedClaim = false
|
|
2063
2492
|
}
|
|
2064
2493
|
const offPanel = host.onPanelChange(() => safeReconcile('panel'))
|
|
2065
2494
|
const offCatalog = host.onCatalogChange(() => safeReconcile('catalog'))
|
|
2066
2495
|
const offDom = host.onDomChange(() => safeReconcile('dom'))
|
|
2067
|
-
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
|
+
})
|
|
2068
2503
|
safeReadLink()
|
|
2069
2504
|
// Pointing at a turn records a position, never a navigation: the listener is passive
|
|
2070
2505
|
// and it refuses to act on any interactive element, so clicks keep working exactly as
|
|
@@ -2630,7 +3065,15 @@ window.__ModuleLoader__.load({
|
|
|
2630
3065
|
faces: {
|
|
2631
3066
|
catalog: list,
|
|
2632
3067
|
archivedSessionIds: () => registry.getSnapshot().archivedSessionIds,
|
|
2633
|
-
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
|
+
},
|
|
2634
3077
|
selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
|
|
2635
3078
|
beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
|
|
2636
3079
|
openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
|
|
@@ -2661,6 +3104,14 @@ window.__ModuleLoader__.load({
|
|
|
2661
3104
|
return fallback
|
|
2662
3105
|
}
|
|
2663
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
|
|
2664
3115
|
const archived = () => {
|
|
2665
3116
|
const ids = attempt(() => faces.archivedSessionIds(), [])
|
|
2666
3117
|
return Array.isArray(ids) ? ids : []
|
|
@@ -2794,9 +3245,20 @@ window.__ModuleLoader__.load({
|
|
|
2794
3245
|
// A panel id nobody registered is refused by the layout by THROWING
|
|
2795
3246
|
// (`selectPanel: main panel "x" is not registered`), so the claim is
|
|
2796
3247
|
// swallowed here and reported as "the view did not move" at expiry.
|
|
2797
|
-
openSession: id => {
|
|
2798
|
-
|
|
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))
|
|
2799
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),
|
|
2800
3262
|
// A row's own identity, in the shape the trajectory views publish it. The value is a
|
|
2801
3263
|
// percent-encoded NUL-separated triple, so the route can read as a plain path.
|
|
2802
3264
|
rowIdentityOf: element => {
|
|
@@ -2943,18 +3405,39 @@ window.__ModuleLoader__.load({
|
|
|
2943
3405
|
break
|
|
2944
3406
|
}
|
|
2945
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.
|
|
2946
3423
|
const ids = conversationViewIdsOf(faces)
|
|
2947
3424
|
if (ids === undefined || ids.length !== tabs.length) return undefined
|
|
2948
3425
|
return { id: ids[index], index, total: ids.length }
|
|
2949
3426
|
},
|
|
2950
3427
|
openConversationView: viewId => {
|
|
2951
3428
|
const row = window.document?.querySelector?.(`[${CONVERSATION_TABS_ATTRIBUTE}]`)
|
|
2952
|
-
const tabs = typeof row?.querySelectorAll === 'function' ? row.querySelectorAll('[role="tab"]') : undefined
|
|
2953
|
-
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)
|
|
2954
3438
|
const ids = conversationViewIdsOf(faces)
|
|
2955
|
-
|
|
2956
|
-
const
|
|
2957
|
-
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])
|
|
2958
3441
|
if (typeof tab?.click === 'function') attempt(() => tab.click())
|
|
2959
3442
|
},
|
|
2960
3443
|
// The right-hand pane names the tab in front. The service is the source of truth;
|
|
@@ -3021,17 +3504,9 @@ window.__ModuleLoader__.load({
|
|
|
3021
3504
|
// The model-provider card whose editor is open, when one is (see the helper).
|
|
3022
3505
|
activeSettingsProvider: () => openSettingsProviderId(window.document),
|
|
3023
3506
|
// The tab a settings section is showing: the same ARIA contract one level deeper.
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
|
|
3028
|
-
if (typeof candidate.getAttribute !== 'function') continue
|
|
3029
|
-
if (candidate.getAttribute('aria-selected') !== 'true') continue
|
|
3030
|
-
const label = (candidate.textContent ?? '').trim()
|
|
3031
|
-
if (label !== '') return label
|
|
3032
|
-
}
|
|
3033
|
-
return undefined
|
|
3034
|
-
},
|
|
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)),
|
|
3035
3510
|
// The overlay is a modal, and the platform's own dismiss path is a document-level
|
|
3036
3511
|
// Escape: the shipped modal layer registers `document.addEventListener('keydown',
|
|
3037
3512
|
// handler, true)` gated on `key === 'Escape'` and closes the topmost modal there.
|
|
@@ -3045,50 +3520,118 @@ window.__ModuleLoader__.load({
|
|
|
3045
3520
|
window.document?.dispatchEvent?.(event)
|
|
3046
3521
|
})
|
|
3047
3522
|
},
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
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
|
|
3072
3603
|
}
|
|
3073
|
-
// And one of its cards may be the page a link named.
|
|
3074
|
-
if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
|
|
3075
|
-
return
|
|
3076
3604
|
}
|
|
3605
|
+
if (hit === undefined) break
|
|
3606
|
+
cursor = hit.index
|
|
3607
|
+
if (!controlIsOn(hit.row, hit.control)) attempt(() => hit.control.click())
|
|
3077
3608
|
}
|
|
3609
|
+
// And one of its cards may be the page a link named.
|
|
3610
|
+
if (typeof provider === 'string' && provider !== '') clickSettingsProvider(provider)
|
|
3611
|
+
return
|
|
3078
3612
|
}
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
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)
|
|
3085
3624
|
return
|
|
3086
3625
|
}
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
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
|
|
3092
3635
|
},
|
|
3093
3636
|
openPane: (kind, track, warn) => {
|
|
3094
3637
|
if (typeof kind !== 'string' || kind === '') return
|
|
@@ -3304,6 +3847,231 @@ window.__ModuleLoader__.load({
|
|
|
3304
3847
|
return undefined
|
|
3305
3848
|
}
|
|
3306
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
|
+
|
|
3307
4075
|
/**
|
|
3308
4076
|
* The fragment a reader can read: percent escapes decoded, or the raw text when the
|
|
3309
4077
|
* escapes are malformed. Display only — the router still writes the encoded form.
|
|
@@ -3343,6 +4111,34 @@ window.__ModuleLoader__.load({
|
|
|
3343
4111
|
*/
|
|
3344
4112
|
function landedFragment(wanted, now) {
|
|
3345
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
|
+
}
|
|
3346
4142
|
const target = targetSessionOf(wanted)
|
|
3347
4143
|
return target !== undefined && target === targetSessionOf(now)
|
|
3348
4144
|
}
|
|
@@ -3671,6 +4467,11 @@ window.__ModuleLoader__.load({
|
|
|
3671
4467
|
show(window.location?.hash)
|
|
3672
4468
|
/** Whether the view has arrived where the link asked, judged by what is on screen. */
|
|
3673
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
|
|
3674
4475
|
const report = attemptValue(() => window.__dshUrlRouter?.report?.())
|
|
3675
4476
|
const canonical = report?.canonical
|
|
3676
4477
|
if (canonical === undefined || canonical === null) return false
|
|
@@ -3955,6 +4756,7 @@ window.__ModuleLoader__.load({
|
|
|
3955
4756
|
// The copy's own field came and went: this is not the reader focusing the bar.
|
|
3956
4757
|
if (quietFocus) return
|
|
3957
4758
|
narrowed = false
|
|
4759
|
+
revealBar()
|
|
3958
4760
|
selectAll()
|
|
3959
4761
|
drawRecent()
|
|
3960
4762
|
}
|
|
@@ -3984,6 +4786,8 @@ window.__ModuleLoader__.load({
|
|
|
3984
4786
|
if (quietFocus) return
|
|
3985
4787
|
narrowed = false
|
|
3986
4788
|
hideRecent()
|
|
4789
|
+
// The pointer may still be on the bar; concealBar() checks that and the list.
|
|
4790
|
+
attemptValue(() => window.setTimeout?.(concealBar, 0))
|
|
3987
4791
|
}
|
|
3988
4792
|
const onKeyDown = event => {
|
|
3989
4793
|
if (event?.key === 'Escape') {
|
|
@@ -4012,12 +4816,47 @@ window.__ModuleLoader__.load({
|
|
|
4012
4816
|
driveTypedFragment(fragment)
|
|
4013
4817
|
settle(fragment, false)
|
|
4014
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)
|
|
4015
4852
|
input.addEventListener('keydown', onKeyDown)
|
|
4016
4853
|
input.addEventListener('focus', onFocus)
|
|
4017
4854
|
input.addEventListener('mousedown', onMouseDown)
|
|
4018
4855
|
input.addEventListener('input', onInput)
|
|
4019
4856
|
input.addEventListener('paste', onPaste)
|
|
4020
4857
|
input.addEventListener('blur', onBlur)
|
|
4858
|
+
applyHoverMode()
|
|
4859
|
+
const offHoverSettings = subscribeSettings(() => applyHoverMode())
|
|
4021
4860
|
// Both platforms mount the bar in the body: Windows pins it into the
|
|
4022
4861
|
// title-bar strip, macOS pins it over the resident header row.
|
|
4023
4862
|
document.body.appendChild(bar)
|
|
@@ -4060,6 +4899,9 @@ window.__ModuleLoader__.load({
|
|
|
4060
4899
|
input.removeEventListener?.('input', onInput)
|
|
4061
4900
|
input.removeEventListener?.('paste', onPaste)
|
|
4062
4901
|
input.removeEventListener?.('blur', onBlur)
|
|
4902
|
+
bar.removeEventListener?.('mouseenter', revealBar)
|
|
4903
|
+
bar.removeEventListener?.('mouseleave', concealBar)
|
|
4904
|
+
offHoverSettings?.()
|
|
4063
4905
|
for (const timer of copiedTimers) attemptValue(() => window.clearTimeout(timer))
|
|
4064
4906
|
copiedTimers.clear()
|
|
4065
4907
|
if (toastTimer !== undefined) attemptValue(() => window.clearTimeout(toastTimer))
|
|
@@ -4118,7 +4960,7 @@ window.__ModuleLoader__.load({
|
|
|
4118
4960
|
setRemembered(recentEntries().length)
|
|
4119
4961
|
}), [])
|
|
4120
4962
|
if (view === 'summary') {
|
|
4121
|
-
return
|
|
4963
|
+
return `全选 ${settings.selectAllOnFocus ? '开' : '关'} · 悬浮显示 ${settings.hoverReveal ? '开' : '关'} · 记忆 ${settings.recentLimit} 条`
|
|
4122
4964
|
}
|
|
4123
4965
|
/**
|
|
4124
4966
|
* Take a typed count: an empty field, a minus sign or anything else unusable is a
|
|
@@ -4141,11 +4983,26 @@ window.__ModuleLoader__.load({
|
|
|
4141
4983
|
checked: settings.selectAllOnFocus,
|
|
4142
4984
|
onChange: event => setSettings(updateSettings({ selectAllOnFocus: event?.target?.checked === true })),
|
|
4143
4985
|
}),
|
|
4144
|
-
h('span', null, '
|
|
4986
|
+
h('span', null, '首次点击全选链接'),
|
|
4145
4987
|
]),
|
|
4146
4988
|
h('span', { className: 'dshur-state' }, settings.selectAllOnFocus ? '开' : '关'),
|
|
4147
4989
|
]),
|
|
4148
|
-
h('p', { className: 'dshur-hint' }, '
|
|
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' }, '平时隐去,鼠标移到地址栏的位置才出现。'),
|
|
4149
5006
|
]),
|
|
4150
5007
|
h('div', { className: 'dshur-block', key: 'recent' }, [
|
|
4151
5008
|
h('div', { className: 'dshur-row' }, [
|
|
@@ -4162,7 +5019,7 @@ window.__ModuleLoader__.load({
|
|
|
4162
5019
|
onBlur: () => setDraft(String(readSettings().recentLimit)),
|
|
4163
5020
|
}),
|
|
4164
5021
|
]),
|
|
4165
|
-
h('p', { className: 'dshur-hint' }, '
|
|
5022
|
+
h('p', { className: 'dshur-hint' }, '下拉框里能看到的条数,默认 8;0 = 不记忆、不弹列表;没有上限。当前界面不占名额。'),
|
|
4166
5023
|
h('p', { className: 'dshur-meta' }, [
|
|
4167
5024
|
h('span', { key: 'count' }, `已记住 ${remembered} 条`),
|
|
4168
5025
|
h('button', {
|