feat(client): expand the plugin settings card on the Open-in-Settings jump
The settings shell keeps its open/section state in private component state and offers no plugin-facing open face, but the card registered on settings.plugin.item is ours. After the jump's settings-trigger click survives, it raises a short-lived in-bundle expand request; the card consumes it on mount and lands open, scrolled into view. Degraded jumps raise no request, and the request expires in 5s, so no stale signal surfaces on a later manual visit.
This commit is contained in:
1 parent
4f89f9ebc0
commit
59cd1be501
4 files changed
+139
-5
No files matched your search
@@ -4,11 +4,14 @@
|
||||
* Host-served `dsh-context` settings namespace — the section itself supplies nothing; it renders nothing while the namespace is
|
||||
* unavailable
|
||||
* (a deployment without the Host half, or a remote browser, shows no trace).
|
||||
*/
|
||||
* Mounts expanded when the Plugin Info card's "Open in Settings" jump left a
|
||||
* fresh expand request (settingsJump.ts), scrolling itself into view.
|
||||
*/
|
||||
|
||||
import type * as ReactNS from 'react'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { React } from '../react'
|
||||
import { consumeCardExpand } from '../settingsJump'
|
||||
import type { SettingsField, SettingsState } from '../settings'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
|
||||
@@ -61,11 +64,21 @@ export function makeSettingsCard(kit: ViewKit): (props: SettingsCardProps) => Re
|
||||
const { t } = kit
|
||||
return function SettingsCard(props: SettingsCardProps): ReactNS.ReactElement | null {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const itemRef = React.useRef<HTMLLIElement | null>(null)
|
||||
// "Open in Settings" jump: consume its fresh expand request once on mount
|
||||
// and land open; every guard stays local so no host quirk can surface.
|
||||
React.useEffect(() => {
|
||||
if (!consumeCardExpand()) return
|
||||
setOpen(true)
|
||||
try {
|
||||
itemRef.current?.scrollIntoView({ block: 'nearest' })
|
||||
} catch { /* hosts without scrollIntoView: expanded but unscrolled */ }
|
||||
}, [])
|
||||
const state = typeof props.useContextSettings === 'function' ? props.useContextSettings(s => s) : undefined
|
||||
if (state === undefined || state.status === 'unavailable') return null
|
||||
const disabled = state.status !== 'ready' || !state.writable
|
||||
return (
|
||||
<li className={'lc-settings-card' + (open ? ' lc-settings-open' : '')}>
|
||||
<li ref={itemRef} className={'lc-settings-card' + (open ? ' lc-settings-open' : '')}>
|
||||
<button
|
||||
type="button"
|
||||
className="lc-settings-head"
|
||||
|
||||
@@ -7,8 +7,26 @@
|
||||
* labels. EVERY step is individually guarded: a shell that doesn't match
|
||||
* (older host, different layout, missing sidebar) degrades the whole jump
|
||||
* to a silent no-op — this path must never throw into the caller's render.
|
||||
*
|
||||
* The last leg — the card itself — is ours: the module also carries a
|
||||
* short-lived in-bundle expand request that the plugin's settings card
|
||||
* consumes on mount, so the jump lands on the configuration already open.
|
||||
*/
|
||||
|
||||
let requestedAt = 0
|
||||
|
||||
/** Raise a short-lived request for the plugin's settings card to mount expanded. */
|
||||
export function requestCardExpand(now: number = Date.now()): void {
|
||||
requestedAt = now
|
||||
}
|
||||
|
||||
/** Consume the request once; true only while it is still fresh (younger than maxAgeMs). */
|
||||
export function consumeCardExpand(now: number = Date.now(), maxAgeMs = 5000): boolean {
|
||||
const fresh = requestedAt > 0 && now - requestedAt < maxAgeMs
|
||||
requestedAt = 0
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** The Plugins settings section's shipped nav labels (en/zh locales). */
|
||||
const SECTION_LABELS = new Set(['Plugins', '插件'])
|
||||
|
||||
@@ -39,6 +57,10 @@ export function openPluginSettings(
|
||||
if (open === undefined && triggers.length === 0) return
|
||||
// A collapsed trigger opens the panel; an already-open one is left alone.
|
||||
triggers.find(b => b.getAttribute('aria-expanded') !== 'true')?.click()
|
||||
// Only a chrome with a settings dialog raises the expand request, and
|
||||
// only once the trigger click survived — a degraded jump leaves no
|
||||
// stale signal behind.
|
||||
requestCardExpand()
|
||||
schedule(() => {
|
||||
try {
|
||||
findSectionRow(doc)?.click()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SettingsCard (src/client/components/settingsCard.tsx) rendered with real
|
||||
// React against the real DICT_EN strings; the select rows open the REAL
|
||||
// Menu primitive (portaled into document.body) and pick through it.
|
||||
// Menu primitive (portaled into document.body) and pick through it. The
|
||||
// "Open in Settings" jump path mounts the card pre-expanded (settingsJump.ts
|
||||
// expand request), with the scroll best-effort against stubbed prototypes.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
@@ -8,6 +10,7 @@ import { h } from '../../../src/client/react'
|
||||
import { makeSettingsCard } from '../../../src/client/components/settingsCard'
|
||||
import type { SettingsState } from '../../../src/client/settings'
|
||||
import { DICT_EN } from '../../../src/client/i18n'
|
||||
import { requestCardExpand } from '../../../src/client/settingsJump'
|
||||
import { click, keydown, makeKit, mount, query, queryAll, text } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
@@ -26,6 +29,16 @@ function menuItems(): HTMLElement[] {
|
||||
return queryAll(document.body, '[role="menu"] [role="menuitem"]')
|
||||
}
|
||||
|
||||
type ScrollIntoViewLike = (this: Element, arg?: unknown) => void
|
||||
|
||||
/** Swap Element.prototype.scrollIntoView (absent in jsdom); returns the original. */
|
||||
function stubScrollIntoView(impl: ScrollIntoViewLike | undefined): ScrollIntoViewLike | undefined {
|
||||
const proto = Element.prototype as unknown as { scrollIntoView?: ScrollIntoViewLike }
|
||||
const original = proto.scrollIntoView
|
||||
proto.scrollIntoView = impl
|
||||
return original
|
||||
}
|
||||
|
||||
describe('SettingsCard', () => {
|
||||
test('renders nothing without a settings hook or when the namespace is unavailable', async () => {
|
||||
const m1 = await mount(h(SettingsCard, {}))
|
||||
@@ -164,4 +177,54 @@ describe('SettingsCard', () => {
|
||||
assert.equal(document.body.querySelector('[role="menu"]'), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a fresh expand request mounts the card open, scrolled into view — once', async () => {
|
||||
const scrolled: Array<{ el: Element; arg: unknown }> = []
|
||||
const restore = stubScrollIntoView(function (this: Element, arg?: unknown) {
|
||||
scrolled.push({ el: this, arg })
|
||||
})
|
||||
try {
|
||||
requestCardExpand()
|
||||
const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) }))
|
||||
const card = query(m.container, '.lc-settings-card')
|
||||
assert.ok(card.className.includes('lc-settings-open'))
|
||||
assert.equal(query(m.container, '.lc-settings-head').getAttribute('aria-expanded'), 'true')
|
||||
assert.equal(queryAll(m.container, '.lc-settings-select').length, 3)
|
||||
assert.equal(scrolled.length, 1, 'the card scrolls itself into view')
|
||||
assert.deepEqual(scrolled[0].arg, { block: 'nearest' })
|
||||
assert.equal(scrolled[0].el, card)
|
||||
await m.unmount()
|
||||
|
||||
// Consumed once: a later mount starts collapsed again.
|
||||
const again = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) }))
|
||||
assert.equal(query(again.container, '.lc-settings-head').getAttribute('aria-expanded'), 'false')
|
||||
assert.equal(scrolled.length, 1)
|
||||
await again.unmount()
|
||||
} finally {
|
||||
stubScrollIntoView(restore)
|
||||
}
|
||||
})
|
||||
|
||||
test('a host whose scrollIntoView throws still mounts expanded', async () => {
|
||||
const restore = stubScrollIntoView(() => { throw new Error('no scrolling here') })
|
||||
try {
|
||||
requestCardExpand()
|
||||
const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) }))
|
||||
assert.equal(query(m.container, '.lc-settings-head').getAttribute('aria-expanded'), 'true')
|
||||
await m.unmount()
|
||||
} finally {
|
||||
stubScrollIntoView(restore)
|
||||
}
|
||||
})
|
||||
|
||||
test('a pending request is consumed even when the card renders nothing', async () => {
|
||||
requestCardExpand()
|
||||
const m = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf({ status: 'unavailable' })) }))
|
||||
assert.equal(m.container.childElementCount, 0)
|
||||
await m.unmount()
|
||||
|
||||
const again = await mount(h(SettingsCard, { useContextSettings: hookFor(stateOf()) }))
|
||||
assert.equal(query(again.container, '.lc-settings-head').getAttribute('aria-expanded'), 'false')
|
||||
await again.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
// openPluginSettings (src/client/settingsJump.ts): the guarded DOM jump to
|
||||
// the plugin's settings page — settings trigger first, Plugins section nav
|
||||
// row second — plus the hostile degradations (missing chrome, throwing
|
||||
// elements, already-open panel). jsdom supplies the real document.
|
||||
// elements, already-open panel) and the expand request the surviving jump
|
||||
// leaves for the plugin's settings card. jsdom supplies the real document.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { afterEach, describe, test } from 'vitest'
|
||||
import { openPluginSettings } from '../../src/client/settingsJump'
|
||||
import { consumeCardExpand, openPluginSettings, requestCardExpand } from '../../src/client/settingsJump'
|
||||
|
||||
/** Immediate scheduler: captures the deferred runs in issue order. */
|
||||
function syncSchedule(): { runs: Array<() => void>; schedule: (run: () => void, ms: number) => void } {
|
||||
@@ -32,6 +33,7 @@ function clicks(el: HTMLElement): { count: () => number } {
|
||||
describe('openPluginSettings', () => {
|
||||
afterEach(() => {
|
||||
document.body.textContent = ''
|
||||
consumeCardExpand()
|
||||
})
|
||||
|
||||
test('clicks the collapsed trigger, then the Plugins section row', () => {
|
||||
@@ -135,4 +137,38 @@ describe('openPluginSettings', () => {
|
||||
assert.equal(menuClicks.count(), 0, 'only dialog triggers are candidates')
|
||||
assert.equal(runs.length, 0)
|
||||
})
|
||||
|
||||
test('a surviving jump leaves a fresh expand request for the settings card', () => {
|
||||
trigger(document, 'false')
|
||||
const section = document.createElement('button')
|
||||
section.textContent = '插件'
|
||||
document.body.appendChild(section)
|
||||
const { schedule } = syncSchedule()
|
||||
|
||||
openPluginSettings(document, schedule)
|
||||
|
||||
assert.equal(consumeCardExpand(), true, 'the card mounts expanded on this jump')
|
||||
assert.equal(consumeCardExpand(), false, 'the request is consumed once')
|
||||
})
|
||||
|
||||
test('degraded jumps leave no expand request behind', () => {
|
||||
const { runs, schedule } = syncSchedule()
|
||||
openPluginSettings(document, schedule)
|
||||
assert.equal(consumeCardExpand(), false, 'no settings dialog in this chrome: no request')
|
||||
|
||||
const t = trigger(document, 'false')
|
||||
t.click = () => { throw new Error('detached') }
|
||||
assert.doesNotThrow(() => openPluginSettings(document, schedule))
|
||||
assert.equal(consumeCardExpand(), false, 'a failed trigger click leaves no stale request')
|
||||
assert.equal(runs.length, 0)
|
||||
})
|
||||
|
||||
test('the expand request is freshness-windowed (5s) and single-shot', () => {
|
||||
requestCardExpand(1000)
|
||||
assert.equal(consumeCardExpand(1000 + 4999), true, 'fresh within the window')
|
||||
assert.equal(consumeCardExpand(1000 + 4999), false, 'already consumed')
|
||||
|
||||
requestCardExpand(1000)
|
||||
assert.equal(consumeCardExpand(1000 + 5000), false, 'stale past the window')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user