feat(plugin-info): add a settings entry below the GitHub row

The harness settings panel keeps its open state inside the shell
component and exposes no plugin-facing open face, so the entry drives
the real chrome best-effort: click the sidebar's dialog trigger, then
the Plugins section nav row (matched by its shipped en/zh labels).
Every step is individually guarded - a shell without a matching
trigger or section degrades the whole jump to a silent no-op, and no
path can throw into the render.
This commit is contained in:
bowenliang123 committed 2026-08-29 09:54:24 +08:00
1 parent bc6f2f160a
commit 0090fa3a57
6 files changed
+206 -1

No files matched your search

+6
View File
@@ -6,6 +6,7 @@
import type * as ReactNS from 'react'
import { fetchLatestVersion, isNewerVersion } from '../latestVersion'
import { PLUGIN_NAME, PLUGIN_REPO, PLUGIN_REPO_SHORT, PLUGIN_VERSION } from '../meta'
import { openPluginSettings } from '../settingsJump'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
@@ -43,6 +44,11 @@ export function makePluginInfo(kit: ViewKit): () => ReactNS.ReactElement {
<div className="lc-pi-grid">
{row(t('plugin.name'), nameValue, PLUGIN_REPO + '/releases')}
{row(t('plugin.github'), PLUGIN_REPO_SHORT, PLUGIN_REPO)}
{/* Best-effort jump to this plugin's settings page — openPluginSettings silently no-ops when the host's chrome doesn't match. */}
<button type="button" className="lc-pi-row lc-pi-row-btn" onClick={() => { openPluginSettings() }}>
<div className="lc-pi-label">{t('plugin.settings')}</div>
<div className="lc-pi-value">{t('plugin.settingsOpen')}</div>
</button>
</div>
</div>
)
+4
View File
@@ -46,6 +46,8 @@ export const DICT_ZH: Record<string, string> = {
'plugin.hint': 'The best DSH context plugin ⭐',
'plugin.name': '插件',
'plugin.github': 'GitHub',
'plugin.settings': '设置',
'plugin.settingsOpen': '前往设置页',
'trend.title': '上下文趋势',
'gran.step': '步骤', 'gran.turn': '轮次',
'settings.title': '上下文', 'settings.desc': '上下文面板的展示偏好',
@@ -253,6 +255,8 @@ export const DICT_EN: Record<string, string> = {
'plugin.hint': 'The best DSH context plugin ⭐',
'plugin.name': 'Plugin',
'plugin.github': 'GitHub',
'plugin.settings': 'Settings',
'plugin.settingsOpen': 'Open in Settings',
'trend.title': 'Context Trend',
'gran.step': 'Step', 'gran.turn': 'Turn',
'settings.title': 'Context', 'settings.desc': 'Display preferences for the Context panel',
+48
View File
@@ -0,0 +1,48 @@
/**
* Best-effort jump to this plugin's settings page (Settings → Plugins →
* Plugin configuration). The harness settings panel keeps its open state
* inside the shell component — no plugin-facing open face exists — so the
* jump drives the real chrome: click the sidebar's settings trigger, then
* the Plugins section nav row, both found by their shipped attributes and
* 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 Plugins settings section's shipped nav labels (en/zh locales). */
const SECTION_LABELS = new Set(['Plugins', '插件'])
/** Buttons the jump may operate on, in document order. */
function buttonsOf(doc: Document): HTMLButtonElement[] {
return [...doc.querySelectorAll<HTMLButtonElement>('button')]
}
/** The sidebar's settings triggers: dialog semantics plus an expanded flag. */
function findTriggers(doc: Document): HTMLButtonElement[] {
return buttonsOf(doc)
.filter(b => b.getAttribute('aria-haspopup') === 'dialog' && b.hasAttribute('aria-expanded'))
}
/** The Plugins section's nav row, matched by its shipped label. */
function findSectionRow(doc: Document): HTMLButtonElement | undefined {
return buttonsOf(doc).find(b => SECTION_LABELS.has(b.textContent.trim()))
}
export function openPluginSettings(
doc: Document = document,
schedule: (run: () => void, ms: number) => void = (run, ms) => { window.setTimeout(run, ms) },
): void {
try {
const triggers = findTriggers(doc)
const open = triggers.find(b => b.getAttribute('aria-expanded') === 'true')
// No dialog trigger at all: this shell has no settings panel — no-op.
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()
schedule(() => {
try {
findSectionRow(doc)?.click()
} catch { /* no Plugins row on this host: stay on the opened section */ }
}, 80)
} catch { /* the settings surface doesn't match: silent no-op */ }
}
+2
View File
@@ -276,6 +276,8 @@ button.lc-brief-row-link { cursor: pointer; }
.lc-sl-count { flex: 1 1 auto; text-align: right; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lc-sl-pct { flex: none; min-width: 30px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.lc-pi-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
/* The settings-entry row is a button wearing the link-row chrome — reset the button chrome the shared row style doesn't cover. */
.lc-pi-row-btn { appearance: none; border: 0; background: none; font: inherit; color: inherit; text-align: left; padding: 0; cursor: pointer; width: 100%; }
.lc-pi-row { display: flex; flex-direction: row; justify-content: space-between; align-items: baseline; gap: 12px; min-width: 0; text-decoration: none; color: inherit; }
.lc-pi-row:hover .lc-pi-value { text-decoration: underline; }
.lc-pi-hint { text-decoration: none; }
+8 -1
View File
@@ -43,7 +43,7 @@ describe('PluginInfo', () => {
assert.equal(fetchMock.mock.calls.length, 0)
assert.ok(text(m.container).includes('Plugin Info'))
const rows = queryAll(m.container, '.lc-pi-row')
assert.equal(rows.length, 2)
assert.equal(rows.length, 3)
assert.equal(query(rows[0], '.lc-pi-label').textContent, 'Plugin')
assert.equal(query(rows[0], '.lc-pi-value').textContent, 'dsh-context (v0.0.0-dev)')
assert.equal(rows[0].getAttribute('href'), 'https://github.com/bowenliang123/dsh-context/releases')
@@ -51,6 +51,10 @@ describe('PluginInfo', () => {
assert.equal(query(rows[1], '.lc-pi-label').textContent, 'GitHub')
assert.equal(query(rows[1], '.lc-pi-value').textContent, 'bowenliang123/dsh-context')
assert.equal(rows[1].getAttribute('href'), 'https://github.com/bowenliang123/dsh-context')
// The settings entry: a button (no href) below GitHub — the guarded jump runs on click.
assert.equal(rows[2].getAttribute('href'), null)
assert.equal(query(rows[2], '.lc-pi-label').textContent, 'Settings')
assert.equal(query(rows[2], '.lc-pi-value').textContent, 'Open in Settings')
// The tagline is the repo link too: hover underlines it, a click opens GitHub.
const hint = query(m.container, '.lc-pi-hint')
assert.ok(text(hint).includes('The best DSH context plugin'))
@@ -58,6 +62,9 @@ describe('PluginInfo', () => {
assert.equal(hint.getAttribute('target'), '_blank')
assert.equal(hint.getAttribute('rel'), 'noreferrer')
assert.equal(queryAll(m.container, '.lc-pi-update').length, 0)
// The settings entry runs the guarded jump; without the harness chrome in
// the jsdom document it silently no-ops — safe to click in tests.
assert.doesNotThrow(() => rows[2].click())
await m.unmount()
})
+138
View File
@@ -0,0 +1,138 @@
// 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.
import assert from 'node:assert/strict'
import { afterEach, describe, test } from 'vitest'
import { openPluginSettings } from '../../src/client/settingsJump'
/** Immediate scheduler: captures the deferred runs in issue order. */
function syncSchedule(): { runs: Array<() => void>; schedule: (run: () => void, ms: number) => void } {
const runs: Array<() => void> = []
return { runs, schedule: (run) => { runs.push(run) } }
}
/** A sidebar-style settings trigger (dialog semantics + expanded flag). */
function trigger(doc: Document, expanded: 'true' | 'false'): HTMLButtonElement {
const b = doc.createElement('button')
b.setAttribute('aria-haspopup', 'dialog')
b.setAttribute('aria-expanded', expanded)
doc.body.appendChild(b)
return b
}
/** Count native click dispatches on an element. */
function clicks(el: HTMLElement): { count: () => number } {
let n = 0
el.addEventListener('click', () => { n++ })
return { count: () => n }
}
describe('openPluginSettings', () => {
afterEach(() => {
document.body.textContent = ''
})
test('clicks the collapsed trigger, then the Plugins section row', () => {
const t = trigger(document, 'false')
const tClicks = clicks(t)
const section = document.createElement('button')
section.textContent = '插件'
document.body.appendChild(section)
const sClicks = clicks(section)
const { runs, schedule } = syncSchedule()
openPluginSettings(document, schedule)
assert.equal(tClicks.count(), 1, 'the collapsed trigger is clicked immediately')
assert.equal(sClicks.count(), 0, 'the section row waits for the panel to open')
assert.equal(runs.length, 1)
runs[0]()
assert.equal(sClicks.count(), 1, 'the Plugins nav row is clicked once the panel opens')
})
test('the en-labeled section row matches too', () => {
trigger(document, 'false')
const section = document.createElement('button')
section.textContent = 'Plugins'
document.body.appendChild(section)
const sClicks = clicks(section)
const { runs, schedule } = syncSchedule()
openPluginSettings(document, schedule)
runs[0]()
assert.equal(sClicks.count(), 1)
})
test('the default schedule defers through window.setTimeout', async () => {
trigger(document, 'false')
const section = document.createElement('button')
section.textContent = '插件'
document.body.appendChild(section)
const sClicks = clicks(section)
openPluginSettings(document)
await new Promise(resolve => { setTimeout(resolve, 120) })
assert.equal(sClicks.count(), 1)
})
test('an already-open panel skips the trigger but still selects the section', () => {
const t = trigger(document, 'true')
const tClicks = clicks(t)
const section = document.createElement('button')
section.textContent = '插件'
document.body.appendChild(section)
const sClicks = clicks(section)
const { runs, schedule } = syncSchedule()
openPluginSettings(document, schedule)
runs[0]()
assert.equal(tClicks.count(), 0, 'no re-click on an open panel')
assert.equal(sClicks.count(), 1)
})
test('a chrome with no settings trigger degrades to a silent no-op', () => {
const { runs, schedule } = syncSchedule()
assert.doesNotThrow(() => openPluginSettings(document, schedule))
assert.equal(runs.length, 0, 'no deferred section selection without a trigger')
})
test('elements that throw on click never surface the error', () => {
const t = trigger(document, 'false')
t.click = () => { throw new Error('detached') }
const { runs, schedule } = syncSchedule()
assert.doesNotThrow(() => openPluginSettings(document, schedule))
// The trigger's failure aborts this jump entirely — the guarded path.
assert.equal(runs.length, 0)
})
test('a throwing section row is contained by its own guard', () => {
trigger(document, 'false')
const section = document.createElement('button')
section.textContent = '插件'
section.click = () => { throw new Error('detached') }
document.body.appendChild(section)
const { runs, schedule } = syncSchedule()
assert.doesNotThrow(() => openPluginSettings(document, schedule))
assert.doesNotThrow(() => runs[0]())
})
test('lookalike chrome is ignored: a menu trigger is not the settings dialog', () => {
const menu = document.createElement('button')
menu.setAttribute('aria-haspopup', 'menu')
menu.setAttribute('aria-expanded', 'false')
document.body.appendChild(menu)
const menuClicks = clicks(menu)
const { runs, schedule } = syncSchedule()
openPluginSettings(document, schedule)
assert.equal(menuClicks.count(), 0, 'only dialog triggers are candidates')
assert.equal(runs.length, 0)
})
})