fix: ignore canceled external URL opens
This commit is contained in:
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { PulseView } from './PulseView'
|
||||
import type { PulseCommit } from '../types'
|
||||
|
||||
const openExternalUrl = vi.fn()
|
||||
const mockCommits: PulseCommit[] = [
|
||||
{
|
||||
hash: 'abc123def456',
|
||||
@@ -45,6 +46,9 @@ vi.mock('../mock-tauri', () => ({
|
||||
vi.mock('../hooks/useDragRegion', () => ({
|
||||
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
|
||||
}))
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
|
||||
}))
|
||||
|
||||
describe('PulseView', () => {
|
||||
beforeEach(() => {
|
||||
@@ -109,6 +113,17 @@ describe('PulseView', () => {
|
||||
expect(nonLink.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('opens commit links through the shared external URL opener', async () => {
|
||||
mockInvokeFn.mockResolvedValue(mockCommits)
|
||||
|
||||
render(<PulseView vaultPath="/test/vault" />)
|
||||
|
||||
const link = await screen.findByText('abc123d')
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://github.com/owner/repo/commit/abc123def456')
|
||||
})
|
||||
|
||||
it('renders file list with correct titles when expanded', async () => {
|
||||
mockInvokeFn.mockResolvedValue(mockCommits)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import type { PulseCommit, PulseFile } from '../types'
|
||||
import { relativeDate } from '../utils/noteListHelpers'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { getLocaleDateLocale, translate, type AppLocale } from '../lib/i18n'
|
||||
import { GitRepositorySelect } from './GitRepositorySelect'
|
||||
import type { GitRepositoryOption } from '../utils/gitRepositories'
|
||||
@@ -147,6 +148,7 @@ function CommitCard({
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const Chevron = expanded ? CaretDown : CaretRight
|
||||
const toggleExpanded = useCallback(() => setExpanded((value) => !value), [])
|
||||
const commitUrl = commit.githubUrl
|
||||
|
||||
return (
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
@@ -174,19 +176,15 @@ function CommitCard({
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 8 }}>
|
||||
<span className="text-[11px] text-muted-foreground">{relativeDate(commit.date)}</span>
|
||||
{commit.githubUrl ? (
|
||||
{commitUrl ? (
|
||||
<a
|
||||
className="flex items-center text-[11px] font-mono text-primary no-underline hover:underline"
|
||||
style={{ gap: 3 }}
|
||||
href={commit.githubUrl}
|
||||
href={commitUrl}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isTauri()) {
|
||||
import('@tauri-apps/plugin-opener').then((mod) => mod.openUrl(commit.githubUrl!))
|
||||
} else {
|
||||
window.open(commit.githubUrl!, '_blank')
|
||||
}
|
||||
void openExternalUrl(commitUrl)
|
||||
}}
|
||||
title={translate(locale, 'pulse.openOnGitHub')}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
import { openUrl, revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
import {
|
||||
copyLocalPath,
|
||||
isUrlValue,
|
||||
@@ -56,6 +56,23 @@ describe('openExternalUrl', () => {
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats Windows user-canceled opener dialogs as a benign no-op', async () => {
|
||||
vi.stubGlobal('isTauri', true)
|
||||
vi.mocked(openUrl).mockRejectedValueOnce(new Error('The operation was canceled by the user. (os error 1223)'))
|
||||
|
||||
await expect(openExternalUrl('https://example.com')).resolves.toBeUndefined()
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith('https://example.com')
|
||||
})
|
||||
|
||||
it('keeps real native opener failures visible to callers', async () => {
|
||||
vi.stubGlobal('isTauri', true)
|
||||
const failure = new Error('failed to open default browser')
|
||||
vi.mocked(openUrl).mockRejectedValueOnce(failure)
|
||||
|
||||
await expect(openExternalUrl('https://example.com')).rejects.toThrow(failure)
|
||||
})
|
||||
})
|
||||
|
||||
describe('local file actions', () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
function parseHttpUrl(candidate: string): URL | null {
|
||||
type ExternalUrlCandidate = string
|
||||
type AbsoluteFilePath = string
|
||||
|
||||
function parseHttpUrl(candidate: ExternalUrlCandidate): URL | null {
|
||||
try {
|
||||
const parsedUrl = new URL(candidate)
|
||||
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' ? parsedUrl : null
|
||||
@@ -14,12 +17,28 @@ function hasBareDomainHost(parsedUrl: URL): boolean {
|
||||
return dotIndex > 0 && dotIndex <= parsedUrl.hostname.length - 3
|
||||
}
|
||||
|
||||
function startsWithHttpProtocol(url: string): boolean {
|
||||
function startsWithHttpProtocol(url: ExternalUrlCandidate): boolean {
|
||||
const lowerUrl = url.toLowerCase()
|
||||
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
|
||||
}
|
||||
|
||||
export function normalizeExternalUrl(value: string): string | null {
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (!error || typeof error !== 'object') return ''
|
||||
|
||||
const message = Reflect.get(error, 'message')
|
||||
return typeof message === 'string' ? message : ''
|
||||
}
|
||||
|
||||
function isExternalOpenCanceledByUser(error: unknown): boolean {
|
||||
const message = getErrorMessage(error).toLowerCase()
|
||||
return message.includes('os error 1223') ||
|
||||
message.includes('operation was canceled by the user') ||
|
||||
message.includes('operation was cancelled by the user')
|
||||
}
|
||||
|
||||
export function normalizeExternalUrl(value: ExternalUrlCandidate): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
@@ -36,11 +55,11 @@ export function normalizeExternalUrl(value: string): string | null {
|
||||
return bareDomainCandidate
|
||||
}
|
||||
|
||||
export function isUrlValue(value: string): boolean {
|
||||
export function isUrlValue(value: ExternalUrlCandidate): boolean {
|
||||
return normalizeExternalUrl(value) !== null
|
||||
}
|
||||
|
||||
export function normalizeUrl(url: string): string {
|
||||
export function normalizeUrl(url: ExternalUrlCandidate): string {
|
||||
const normalized = normalizeExternalUrl(url)
|
||||
if (normalized) return normalized
|
||||
if (startsWithHttpProtocol(url)) return url
|
||||
@@ -48,20 +67,25 @@ export function normalizeUrl(url: string): string {
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
export async function openExternalUrl(url: ExternalUrlCandidate): Promise<void> {
|
||||
const normalized = normalizeExternalUrl(url)
|
||||
if (!normalized) return
|
||||
|
||||
if (isTauri()) {
|
||||
const { openUrl } = await import('@tauri-apps/plugin-opener')
|
||||
await openUrl(normalized)
|
||||
try {
|
||||
await openUrl(normalized)
|
||||
} catch (error) {
|
||||
if (isExternalOpenCanceledByUser(error)) return
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
window.open(normalized, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a local file path with the system default app (e.g. TextEdit for .json). */
|
||||
export async function openLocalFile(absolutePath: string, vaultPath?: string): Promise<void> {
|
||||
export async function openLocalFile(absolutePath: AbsoluteFilePath, vaultPath?: AbsoluteFilePath): Promise<void> {
|
||||
if (isTauri()) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const args: { path: string; vaultPath?: string } = { path: absolutePath }
|
||||
@@ -71,7 +95,7 @@ export async function openLocalFile(absolutePath: string, vaultPath?: string): P
|
||||
}
|
||||
|
||||
/** Reveal a local file or folder in the system file manager. */
|
||||
export async function revealLocalPath(absolutePath: string): Promise<void> {
|
||||
export async function revealLocalPath(absolutePath: AbsoluteFilePath): Promise<void> {
|
||||
if (isTauri()) {
|
||||
const { revealItemInDir } = await import('@tauri-apps/plugin-opener')
|
||||
await revealItemInDir(absolutePath)
|
||||
@@ -79,7 +103,7 @@ export async function revealLocalPath(absolutePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/** Copy a local file or folder path to the system clipboard. */
|
||||
export async function copyLocalPath(absolutePath: string): Promise<void> {
|
||||
export async function copyLocalPath(absolutePath: AbsoluteFilePath): Promise<void> {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API is unavailable')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user