fix: wait for file associations before enabling the default action

This commit is contained in:
creatixchu committed 2026-09-20 11:55:48 +08:00
1 parent 6111f879fd
commit 71b7bbeb88
3 files changed
+27 -7

No files matched your search

@@ -58,7 +58,8 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode {
return (
<OpenTargetButton
key={props.absolutePath} kind="file" applications={apps ?? []} defaultId={apps?.find(app => app.default)?.id}
failed={association?.path === props.absolutePath && apps === null} empty={props.empty === true} t={props.t}
loading={association?.path !== props.absolutePath} failed={association?.path === props.absolutePath && apps === null}
empty={props.empty === true} t={props.t}
refresh={() => { setRevision(value => value + 1) }}
execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open',
operation.kind === 'application' ? operation.id : undefined)}
@@ -26,6 +26,7 @@ export interface OpenTargetButtonProps {
readonly applications: readonly OpenTargetApplication[]
readonly defaultId: string | undefined
readonly failed: boolean
readonly loading?: boolean
readonly empty?: boolean
readonly t: TranslateNS<typeof NS>
readonly execute: (operation: OpenTargetOperation) => Promise<OpenInAppPathFailure | null>
@@ -78,6 +79,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
const [menuOpen, setMenuOpen] = useState(false)
const { pending, toast, act } = useOpenTargetGesture(props.execute, t)
const preferred = applications.find(app => app.id === defaultId)
const disabled = pending || props.loading === true
const revealDefault = kind === 'file' && preferred === undefined
const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name })
const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) }
@@ -87,7 +89,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
: <ApplicationIcon key={preferred?.icon} source={preferred?.icon ?? null} size={13} />
if (props.empty === true) return (
<>
<button type="button" className={css.empty} disabled={pending} data-open-path-unpreviewable onClick={primary}>
<button type="button" className={css.empty} disabled={disabled} data-open-path-unpreviewable onClick={primary}>
{t('path.open')}{icon}
</button>
{toast}
@@ -97,7 +99,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
<>
<Menu
className={css.menuAnchor}
open={menuOpen && !pending}
open={menuOpen && !disabled}
autoFocus
portal
dense
@@ -117,15 +119,15 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
]}
onSelect={(id) => { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }}
anchor={(
<div className={css.split} data-open-target={kind} data-open-path={kind === 'file' ? '' : undefined} data-state={pending ? 'busy' : 'idle'}>
<div className={css.split} data-open-target={kind} data-open-path={kind === 'file' ? '' : undefined} data-state={disabled ? 'busy' : 'idle'}>
<Tooltip label={revealDefault ? t('path.reveal') : t('open.tooltip')} side="bottom" delayMs={500}>
<button type="button" className={css.main} disabled={pending} aria-label={primaryLabel} data-open-path-open={kind === 'file' ? '' : undefined} onClick={primary}>
<button type="button" className={css.main} disabled={disabled} aria-label={primaryLabel} data-open-path-open={kind === 'file' ? '' : undefined} onClick={primary}>
{icon}
</button>
</Tooltip>
<button
type="button" className={css.chevron} disabled={pending}
aria-haspopup="menu" aria-expanded={menuOpen && !pending} aria-label={t('path.more')}
type="button" className={css.chevron} disabled={disabled}
aria-haspopup="menu" aria-expanded={menuOpen && !disabled} aria-label={t('path.more')}
data-open-path-more={kind === 'file' ? '' : undefined}
onClick={() => {
if (!menuOpen) props.refresh?.()
@@ -187,6 +187,7 @@ it('ignores an obsolete file query and refreshes the default application when th
it('keeps reveal last and makes it the default when the file query fails', async () => {
const b = bench()
render(<OpenPathAction {...b.props} applications={async () => null} />)
await act(async () => {})
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) })
const items = screen.getAllByRole('menuitem')
expect(items.map(item => item.textContent)).toEqual([zh['path.appsError'], '显示文件位置(默认)'])
@@ -198,6 +199,7 @@ it('keeps reveal last and makes it the default when the file query fails', async
it.each([{ apps: [] }, { apps: [{ id: '/Player.app', name: 'Player', default: false, icon: null }] }])('uses reveal as the default after the available applications when none is default', async ({ apps }) => {
const b = bench()
render(<OpenPathAction {...b.props} applications={async () => apps} />)
await act(async () => {})
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) })
expect(screen.getAllByRole('menuitem').at(-1)?.textContent).toBe('显示文件位置(默认)')
expect(screen.queryByText('用默认应用打开')).toBeNull()
@@ -230,3 +232,18 @@ it('closes the application menu from its chevron without querying again', async
expect(screen.queryByRole('menu')).toBeNull()
expect(b.props.applications).toHaveBeenCalledTimes(calls)
})
it('waits for the file association query before allowing a default action', async () => {
const b = bench()
const query = Promise.withResolvers<Awaited<ReturnType<OpenPathActionProps['applications']>>>()
const view = render(<OpenPathAction {...b.props} applications={() => query.promise} />)
const main = view.container.querySelector('[data-open-path-open]')!
expect(main).toHaveProperty('disabled', true)
fireEvent.click(main)
expect(b.openPath).not.toHaveBeenCalled()
await act(async () => { query.resolve([]) })
expect(main).toHaveProperty('disabled', false)
await act(async () => { fireEvent.click(main) })
expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined)
})