fix: 回应 Codex review——复制反馈仅在写入成功时显示;插件名改为 window.open 按钮

- copy 现在 await writeClipboard 的布尔结果,剪贴板被拒时不再显示「已复制」(避免误导用户粘贴从未写入的命令)
- 插件名由 <a target=_blank> 改为 <button>,与「跳转」按钮同走 window.open——锚点会被 document capture 的链接接管拦截(忽略 target=_blank)开进侧边栏浏览器,按钮形态绕过
- 测试:新增剪贴板拒绝用例 + 名字/跳转双按钮 window.open 断言
This commit is contained in:
Menghuan1918
2026-08-14 22:53:14 +08:00
parent 2bcfdfce87
commit 58999c2f3b
3 changed files with 65 additions and 22 deletions
+10
View File
@@ -555,16 +555,26 @@
outline-offset: 1px;
}
/* The plugin name: a BUTTON (not an anchor — an anchor would be caught by
the sidebar link takeover at document capture, ignoring target=_blank)
styled like a link; opens the repo in a real new browser tab. */
.pluginName {
appearance: none;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
text-align: left;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.pluginName:hover {
+22 -9
View File
@@ -47,19 +47,23 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
// Which entry's copy button currently shows the "已复制" feedback.
const [copiedId, setCopiedId] = useState<string | null>(null)
/** Copy the entry's install script to the clipboard (best-effort) and
* flash the button's "已复制" label for a moment. Never closes anything,
* never throws outward. */
const copy = (entry: PluginEntry): void => {
void writeClipboard(entry.install)
/** Copy the entry's install script to the clipboard and flash the button's
* "已复制" label for a moment. The feedback ONLY appears after a
* successful write — when the clipboard is unavailable or denied
* (writeClipboard resolves false) nothing is shown, so the user is never
* told to paste a command that was not placed on the clipboard. Never
* closes anything, never throws outward. */
const copy = async (entry: PluginEntry): Promise<void> => {
const written = await writeClipboard(entry.install)
if (!written) return
setCopiedId(entry.id)
window.setTimeout(() => {
setCopiedId(current => (current === entry.id ? null : current))
}, COPIED_FEEDBACK_MS)
}
/** Open the plugin's repo in a REAL new browser tab (a button, so the
* sidebar link takeover cannot reroute it). */
/** Open the plugin's repo in a REAL new browser tab (window.open — a
* button, so the sidebar link takeover cannot reroute it). */
const jump = (entry: PluginEntry): void => {
window.open(entry.url, '_blank', 'noopener')
}
@@ -84,9 +88,18 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
{catalogOf(kind).map(entry => (
<div key={entry.id} className={css.pluginEntry}>
<div className={css.pluginEntryHead}>
<a className={css.pluginName} href={entry.url} target="_blank" rel="noreferrer">
{/* The name is a BUTTON on the same window.open path as the
jump button: as an anchor it would be caught by the
document-capture link takeover (which ignores
target=_blank) and land in the sidebar browser. */}
<button
type="button"
className={css.pluginName}
aria-label={`${t('openPlugin')}: ${entry.name}`}
onClick={() => { jump(entry) }}
>
{entry.name}
</a>
</button>
<span className={css.pluginEntryActions}>
<button
type="button"
+33 -13
View File
@@ -37,11 +37,12 @@ describe('PluginListBody (render)', () => {
const html = renderToString(createElement(PluginListBody, { service, kind: 'viewer' }))
// The topic is a BUTTON (window.open in a new tab), not an anchor.
expect(html).toContain('Browse more plugins on GitHub')
expect(html).not.toContain(`href="${PLUGIN_TOPIC_URL}"`)
// The seeded catalog entry: name as a link, description, install script,
// The name is a BUTTON on the same window.open path (an anchor would be
// caught by the sidebar link takeover) — no href anywhere in the body.
expect(html).not.toContain('href=')
// The seeded catalog entry: name, description, install script,
// a jump button (opens the repo in a NEW browser tab) and a copy button.
expect(html).toContain(officeEntry.name)
expect(html).toContain(`href="${officeEntry.url}"`)
expect(html).toContain(officeEntry.install.replaceAll('&', '&amp;'))
expect(html).toContain('Open')
expect(html).toContain('Copy')
@@ -93,9 +94,9 @@ function mountBody(service: ReturnType<typeof createBetterSidebarService>, kind:
root.render(createElement(PluginListBody, { service, kind }))
})
return {
clickCopy: () => {
clickCopy: async () => {
const button = container.querySelector('button[aria-label^="Copy install command:"]')!
act(() => { (button as HTMLButtonElement).click() })
await act(async () => { (button as HTMLButtonElement).click() })
},
buttonLabel: () => {
const button = container.querySelector('button[aria-label^="Copy install command:"]')
@@ -109,14 +110,14 @@ function mountBody(service: ReturnType<typeof createBetterSidebarService>, kind:
}
describe('PluginListBody copy click (interactive)', () => {
it('clicking Copy writes the install script to the clipboard and flashes "Copied"', () => {
it('clicking Copy writes the install script to the clipboard and flashes "Copied"', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
expect(body.buttonLabel()).toBe('Copy')
body.clickCopy()
await body.clickCopy()
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
expect(body.buttonLabel()).toBe('Copied')
@@ -124,13 +125,13 @@ describe('PluginListBody copy click (interactive)', () => {
vi.restoreAllMocks()
})
it('copying does NOT close anything and the modal body stays mounted', () => {
it('copying does NOT close anything and the modal body stays mounted', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
body.clickCopy()
await body.clickCopy()
// The entry is still rendered (nothing closed, no navigation, no open).
expect(body.buttonLabel()).toBe('Copied')
@@ -139,7 +140,21 @@ describe('PluginListBody copy click (interactive)', () => {
vi.restoreAllMocks()
})
it('the jump button opens the plugin repo in a NEW browser tab (window.open, not a link)', () => {
it('a DENIED clipboard write shows no "Copied" feedback (never claims a copy that did not happen)', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(false)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
await body.clickCopy()
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
expect(body.buttonLabel()).toBe('Copy')
body.unmount()
vi.restoreAllMocks()
})
it('both the name button and the jump button open the repo in a NEW browser tab (window.open, not a link)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const opened: Array<[string, string, string]> = []
@@ -155,9 +170,14 @@ describe('PluginListBody copy click (interactive)', () => {
act(() => {
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
})
const button = container.querySelector('button[aria-label^="Open:"]')!
act(() => { (button as HTMLButtonElement).click() })
expect(opened).toEqual([[officeEntry.url, '_blank', 'noopener']])
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
expect(jumpButtons).toHaveLength(2) // the name + the jump button
act(() => { (jumpButtons[0] as HTMLButtonElement).click() })
act(() => { (jumpButtons[1] as HTMLButtonElement).click() })
expect(opened).toEqual([
[officeEntry.url, '_blank', 'noopener'],
[officeEntry.url, '_blank', 'noopener'],
])
act(() => { root.unmount() })
container.remove()
} finally {