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:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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('&', '&'))
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user