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:
1 parent
bc6f2f160a
commit
0090fa3a57
6 files changed
+206
-1
No files matched your search
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user