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) + }) +})