diff --git a/static/app/components/commandPalette/__stories__/components.tsx b/static/app/components/commandPalette/__stories__/components.tsx index 3b22337bd6caf8..fb28a8e6d25dd8 100644 --- a/static/app/components/commandPalette/__stories__/components.tsx +++ b/static/app/components/commandPalette/__stories__/components.tsx @@ -13,7 +13,10 @@ export function CommandPaletteDemo() { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { + ( + action: CollectionTreeNode, + _options?: {modifierKeys?: {shiftKey: boolean}} + ) => { if ('to' in action) { navigate(normalizeUrl(action.to)); } else if ('onAction' in action) { diff --git a/static/app/components/commandPalette/ui/commandPalette.spec.tsx b/static/app/components/commandPalette/ui/commandPalette.spec.tsx index 16b28ed0701f64..a21c8363ad50cf 100644 --- a/static/app/components/commandPalette/ui/commandPalette.spec.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.spec.tsx @@ -32,19 +32,33 @@ import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette import {CommandPaletteSlot} from 'sentry/components/commandPalette/ui/commandPaletteSlot'; import {useNavigate} from 'sentry/utils/useNavigate'; -function GlobalActionsComponent({children}: {children?: React.ReactNode}) { +function GlobalActionsComponent({ + children, + onAction, +}: { + children?: React.ReactNode; + onAction?: ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => void; +}) { const navigate = useNavigate(); const handleAction = useCallback( - (action: CollectionTreeNode) => { - if ('to' in action) { + ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => { + if (onAction) { + onAction(action, options); + } else if ('to' in action) { navigate(action.to); } else if ('onAction' in action) { action.onAction(); } closeModal(); }, - [navigate] + [navigate, onAction] ); return ( @@ -104,6 +118,46 @@ describe('CommandPalette', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); + it('shift-enter on an internal link forwards modifier keys and closes modal', async () => { + const closeSpy = jest.spyOn(modalActions, 'closeModal'); + const onAction = jest.fn(); + + render( + + + + ); + + await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + + expect(onAction).toHaveBeenCalledWith(expect.objectContaining({to: '/target/'}), { + modifierKeys: {shiftKey: true}, + }); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it('shows internal and external trailing link indicators for link actions', async () => { + render( + + + + + + + ); + + const internalAction = await screen.findByRole('option', {name: 'Internal'}); + const externalAction = await screen.findByRole('option', {name: 'External'}); + + expect( + internalAction.querySelector('[data-test-id="command-palette-link-indicator"]') + ).toHaveAttribute('data-link-type', 'internal'); + expect( + externalAction.querySelector('[data-test-id="command-palette-link-indicator"]') + ).toHaveAttribute('data-link-type', 'external'); + }); + it('clicking action with children shows sub-items, backspace returns', async () => { const closeSpy = jest.spyOn(modalActions, 'closeModal'); render( diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 8a1564a2de82eb..b3dacd08dbeaa7 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -1,4 +1,4 @@ -import {Fragment, useCallback, useLayoutEffect, useMemo, useRef} from 'react'; +import {Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; import {preload} from 'react-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; @@ -26,10 +26,11 @@ import { useCommandPaletteDispatch, useCommandPaletteState, } from 'sentry/components/commandPalette/ui/commandPaletteStateContext'; +import {isExternalLocation} from 'sentry/components/commandPalette/ui/locationUtils'; import {useCommandPaletteAnalytics} from 'sentry/components/commandPalette/useCommandPaletteAnalytics'; import {FeedbackButton} from 'sentry/components/feedbackButton/feedbackButton'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {IconArrow, IconClose, IconSearch} from 'sentry/icons'; +import {IconArrow, IconClose, IconLink, IconOpen, IconSearch} from 'sentry/icons'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; import {t} from 'sentry/locale'; import {fzf} from 'sentry/utils/search/fzf'; @@ -64,7 +65,10 @@ type CMDKFlatItem = CollectionTreeNode & { }; interface CommandPaletteProps { - onAction: (action: CollectionTreeNode) => void; + onAction: ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => void; } export function CommandPalette(props: CommandPaletteProps) { @@ -200,7 +204,12 @@ export function CommandPalette(props: CommandPaletteProps) { }); const onActionSelection = useCallback( - (key: string | number | null) => { + ( + key: string | number | null, + options?: { + modifierKeys?: {shiftKey: boolean}; + } + ) => { const action = actions.find(a => a.key === key); if (!action) { return; @@ -212,7 +221,7 @@ export function CommandPalette(props: CommandPaletteProps) { analytics.recordGroupAction(action, resultIndex); if ('onAction' in action) { // Run the primary callback before drilling into the secondary actions. - // The modal only owns navigation and close behavior for leaf actions. + // Modifier keys are irrelevant here — this is not a link navigation. action.onAction(); } dispatch({type: 'push action', key: action.key, label: action.display.label}); @@ -231,12 +240,31 @@ export function CommandPalette(props: CommandPaletteProps) { analytics.recordAction(action, resultIndex, ''); dispatch({type: 'trigger action'}); - props.onAction(action); + props.onAction(action, options); }, [actions, analytics, dispatch, props] ); const resultsListRef = useRef(null); + const modifierKeysRef = useRef({shiftKey: false}); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + modifierKeysRef.current = {shiftKey: event.shiftKey}; + }; + + const handleKeyUp = (event: KeyboardEvent) => { + modifierKeysRef.current = {shiftKey: event.shiftKey}; + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, []); const debouncedQuery = useDebouncedValue(state.query, 300); @@ -328,7 +356,11 @@ export function CommandPalette(props: CommandPaletteProps) { } if (e.key === 'Enter' || e.key === 'Tab') { - onActionSelection(treeState.selectionManager.focusedKey); + // Only forward shiftKey for Enter — Shift+Tab is reverse tab + // navigation, not an "open in new tab" gesture. + onActionSelection(treeState.selectionManager.focusedKey, { + modifierKeys: {shiftKey: e.key === 'Enter' && e.shiftKey}, + }); return; } }, @@ -379,7 +411,11 @@ export function CommandPalette(props: CommandPaletteProps) { aria-label={t('Search results')} selectionMode="none" shouldUseVirtualFocus - onAction={onActionSelection} + onAction={key => { + onActionSelection(key, { + modifierKeys: modifierKeysRef.current, + }); + }} /> )} @@ -544,6 +580,20 @@ function flattenActions( } function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuItem { + const isExternal = 'to' in action ? isExternalLocation(action.to) : false; + const trailingItems = + 'to' in action ? ( + + + {isExternal ? : } + + + ) : undefined; + return { key: action.key, label: action.display.label, @@ -561,6 +611,7 @@ function makeMenuItemFromAction(action: CMDKFlatItem): CommandPaletteActionMenuI {action.display.icon} ), + trailingItems, children: [], hideCheck: true, }; diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index cb7169f340fc8c..d2be3027647687 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -344,25 +344,19 @@ export function GlobalCommandPaletteActions() { }} - onAction={() => window.open('https://docs.sentry.io', '_blank', 'noreferrer')} + to="https://docs.sentry.io" /> }} - onAction={() => - window.open('https://discord.gg/sentry', '_blank', 'noreferrer') - } + to="https://discord.gg/sentry" /> }} - onAction={() => - window.open('https://github.com/getsentry/sentry', '_blank', 'noreferrer') - } + to="https://github.com/getsentry/sentry" /> }} - onAction={() => - window.open('https://sentry.io/changelog/', '_blank', 'noreferrer') - } + to="https://sentry.io/changelog/" /> typeof v === 'string' ), - onAction: () => window.open(hit.url, '_blank', 'noreferrer'), + to: hit.url, }); } } diff --git a/static/app/components/commandPalette/ui/locationUtils.ts b/static/app/components/commandPalette/ui/locationUtils.ts new file mode 100644 index 00000000000000..c67f339aed5da2 --- /dev/null +++ b/static/app/components/commandPalette/ui/locationUtils.ts @@ -0,0 +1,19 @@ +import type {LocationDescriptor} from 'history'; + +import {locationDescriptorToTo} from 'sentry/utils/reactRouter6Compat/location'; + +export function getLocationHref(to: LocationDescriptor): string { + const resolved = locationDescriptorToTo(to); + + if (typeof resolved === 'string') { + return resolved; + } + + return `${resolved.pathname ?? ''}${resolved.search ?? ''}${resolved.hash ?? ''}`; +} + +export function isExternalLocation(to: LocationDescriptor): boolean { + const currentUrl = new URL(window.location.href); + const targetUrl = new URL(getLocationHref(to), currentUrl.href); + return targetUrl.origin !== currentUrl.origin; +} diff --git a/static/app/components/commandPalette/ui/modal.spec.tsx b/static/app/components/commandPalette/ui/modal.spec.tsx index 181fc1014a820b..77e0d4b7c56747 100644 --- a/static/app/components/commandPalette/ui/modal.spec.tsx +++ b/static/app/components/commandPalette/ui/modal.spec.tsx @@ -157,4 +157,51 @@ describe('CommandPaletteModal', () => { // Secondary action is now visible expect(await screen.findByRole('option', {name: 'Child Action'})).toBeInTheDocument(); }); + + it('opens external links in a new tab', async () => { + const closeModalSpy = jest.fn(); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + render( + + + + + + + + ); + + await userEvent.click(await screen.findByRole('option', {name: 'External Link'})); + + expect(openSpy).toHaveBeenCalledWith( + 'https://docs.sentry.io', + '_blank', + 'noreferrer' + ); + expect(closeModalSpy).toHaveBeenCalledTimes(1); + openSpy.mockRestore(); + }); + + it('opens internal links in a new tab when shift-enter is used', async () => { + const closeModalSpy = jest.fn(); + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + render( + + + + + + + + ); + + await screen.findByRole('textbox', {name: 'Search commands'}); + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + + expect(openSpy).toHaveBeenCalledWith('/target/', '_blank', 'noreferrer'); + expect(closeModalSpy).toHaveBeenCalledTimes(1); + openSpy.mockRestore(); + }); }); diff --git a/static/app/components/commandPalette/ui/modal.tsx b/static/app/components/commandPalette/ui/modal.tsx index 209bc16eba7205..edf80cbef6c07b 100644 --- a/static/app/components/commandPalette/ui/modal.tsx +++ b/static/app/components/commandPalette/ui/modal.tsx @@ -5,6 +5,10 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import type {CMDKActionData} from 'sentry/components/commandPalette/ui/cmdk'; import type {CollectionTreeNode} from 'sentry/components/commandPalette/ui/collection'; import {CommandPalette} from 'sentry/components/commandPalette/ui/commandPalette'; +import { + getLocationHref, + isExternalLocation, +} from 'sentry/components/commandPalette/ui/locationUtils'; import type {Theme} from 'sentry/utils/theme'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -13,9 +17,17 @@ export default function CommandPaletteModal({Body, closeModal}: ModalRenderProps const navigate = useNavigate(); const handleSelect = useCallback( - (action: CollectionTreeNode) => { + ( + action: CollectionTreeNode, + options?: {modifierKeys?: {shiftKey: boolean}} + ) => { if ('to' in action) { - navigate(normalizeUrl(action.to)); + const normalizedTo = normalizeUrl(action.to); + if (isExternalLocation(normalizedTo) || options?.modifierKeys?.shiftKey) { + window.open(getLocationHref(normalizedTo), '_blank', 'noreferrer'); + } else { + navigate(normalizedTo); + } } else if ('onAction' in action) { // When the action has children, the palette will push into them so the // user can select a secondary action — keep the modal open. The