diff --git a/src/client/components/pluginInfo.tsx b/src/client/components/pluginInfo.tsx
index 54d06fb..d8a213b 100644
--- a/src/client/components/pluginInfo.tsx
+++ b/src/client/components/pluginInfo.tsx
@@ -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 {
{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. */}
+
)
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index 8424e9e..16ea12e 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -46,6 +46,8 @@ export const DICT_ZH: Record = {
'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 = {
'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',
diff --git a/src/client/settingsJump.ts b/src/client/settingsJump.ts
new file mode 100644
index 0000000..254bfad
--- /dev/null
+++ b/src/client/settingsJump.ts
@@ -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('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 */ }
+}
diff --git a/src/client/styles.css b/src/client/styles.css
index 0214d55..6d32b2e 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -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; }
diff --git a/tests/client/components/pluginInfo.spec.ts b/tests/client/components/pluginInfo.spec.ts
index ddbcee9..e96206b 100644
--- a/tests/client/components/pluginInfo.spec.ts
+++ b/tests/client/components/pluginInfo.spec.ts
@@ -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()
})
diff --git a/tests/client/settingsJump.spec.ts b/tests/client/settingsJump.spec.ts
new file mode 100644
index 0000000..ab4e150
--- /dev/null
+++ b/tests/client/settingsJump.spec.ts
@@ -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)
+ })
+})