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:
bowenliang123 committed 2026-08-29 13:32:09 +08:00
1 parent 4f89f9ebc0
commit 59cd1be501
4 files changed
+139 -5

No files matched your search

+15 -2
View File
@@ -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"
+22
View File
@@ -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()
+64 -1
View File
@@ -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()
})
})
+38 -2
View File
@@ -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')
})
})