fix: use Tauri opener plugin for URL property clicks

Replace window.open() with Tauri's opener plugin (openUrl) so that
clicking a URL property in the Properties panel opens the system
browser instead of failing silently or triggering edit mode.

- Install @tauri-apps/plugin-opener (npm + Cargo + capabilities)
- Add openExternalUrl() utility with Tauri/browser fallback
- Add stopPropagation to prevent accidental edit mode activation
- Add double-click guard (500ms debounce via ref)
- Update tests to mock openExternalUrl instead of window.open
- Add explicit test that URL click does not trigger onStartEdit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-24 22:20:33 +01:00
parent 9a5b37f602
commit 75f7e4fa4f
9 changed files with 517 additions and 16 deletions

View File

@@ -3,6 +3,13 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue, normalizeUrl } from '../utils/url'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url')
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
})
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & { openExternalUrl: ReturnType<typeof vi.fn> }
describe('EditableValue', () => {
const onSave = vi.fn()
const onCancel = vi.fn()
@@ -219,28 +226,28 @@ describe('UrlValue', () => {
expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
})
it('opens URL in new tab on click', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
it('opens URL via openExternalUrl on click', () => {
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
fireEvent.click(screen.getByTestId('url-link'))
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
openSpy.mockRestore()
expect(openExternalUrl).toHaveBeenCalledWith('https://example.com')
})
it('normalizes bare domain before opening', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
render(<UrlValue value="example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
fireEvent.click(screen.getByTestId('url-link'))
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
openSpy.mockRestore()
expect(openExternalUrl).toHaveBeenCalledWith('https://example.com')
})
it('does not open malformed URL', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
render(<UrlValue value="://broken" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
fireEvent.click(screen.getByTestId('url-link'))
expect(openSpy).not.toHaveBeenCalled()
openSpy.mockRestore()
expect(openExternalUrl).not.toHaveBeenCalled()
})
it('does not trigger edit mode when URL link is clicked', () => {
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
fireEvent.click(screen.getByTestId('url-link'))
expect(onStartEdit).not.toHaveBeenCalled()
})
it('shows edit button that calls onStartEdit', () => {

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react'
import { normalizeUrl } from '../utils/url'
import { useState, useCallback, useRef } from 'react'
import { normalizeUrl, openExternalUrl } from '../utils/url'
export function UrlValue({
value,
@@ -15,6 +15,7 @@ export function UrlValue({
onStartEdit: () => void
}) {
const [editValue, setEditValue] = useState(value)
const openingRef = useRef(false)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
@@ -25,16 +26,28 @@ export function UrlValue({
}
}
const handleOpen = useCallback(() => {
const handleOpen = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (openingRef.current) return
openingRef.current = true
const normalized = normalizeUrl(value)
try {
new URL(normalized)
window.open(normalized, '_blank')
openExternalUrl(normalized).catch(() => {
// opener failed — ignore silently
})
} catch {
// malformed URL — do nothing
} finally {
setTimeout(() => { openingRef.current = false }, 500)
}
}, [value])
const handleEditClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onStartEdit()
}, [onStartEdit])
if (isEditing) {
return (
<input
@@ -61,7 +74,7 @@ export function UrlValue({
</span>
<button
className="shrink-0 border-none bg-transparent p-0 text-[12px] leading-none text-muted-foreground opacity-0 transition-all hover:text-foreground group-hover/url:opacity-100"
onClick={onStartEdit}
onClick={handleEditClick}
title="Edit URL"
data-testid="url-edit-btn"
>

View File

@@ -1,3 +1,5 @@
import { isTauri } from '../mock-tauri'
const URL_PATTERN = /^https?:\/\//i
const BARE_DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+([/?#]|$)/i
@@ -10,3 +12,13 @@ export function normalizeUrl(url: string): string {
if (URL_PATTERN.test(url)) return url
return `https://${url}`
}
/** Open a URL in the system browser. Uses Tauri opener plugin in native mode, window.open in browser. */
export async function openExternalUrl(url: string): Promise<void> {
if (isTauri()) {
const { openUrl } = await import('@tauri-apps/plugin-opener')
await openUrl(url)
} else {
window.open(url, '_blank')
}
}